53056 sc low native withdraw to msg sender only non payable contract stakers cannot withdraw permanent funds lock

Submitted on Aug 14th 2025 at 18:24:58 UTC by @tansegv for Attackathon | Plume Network

  • Report ID: #53056

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/StakingFacet.sol

  • Impacts:

    • Permanent freezing of funds

Description

withdraw() hardcodes the payout address to msg.sender and requires the native transfer to succeed. Contract wallets without a payable receive/fallback cannot accept native PLUME, causing withdraw() to revert and making matured funds unreachable.

The staking system pays out matured “parked” PLUME by sending native value to msg.sender. For contract-based stakers that lack a payable entry point, this transfer reverts, so withdraw() reverts as well. As a result, these users cannot withdraw matured funds at all.

Vulnerability Details

  • Hardcoded payout target: payout is always msg.sender (the caller). No withdrawTo(...) is offered.

  • Raw native send with require:

// StakingFacet.sol
function withdraw() external {
    ...
    uint256 amountToWithdraw = $.stakeInfo[user].parked;
    ...
    _removeParkedAmounts(user, amountToWithdraw);
    _cleanupValidatorRelationships(user);
    emit Withdrawn(user, amountToWithdraw);
    (bool success,) = user.call{ value: amountToWithdraw }("");
    if (!success) {
        revert NativeTransferFailed();
    }
}
  • Why this fails for many contracts: When a contract without a payable receive() or payable fallback() receives native value, the call reverts. Because the function requires success, the entire withdraw() reverts, making the funds effectively inaccessible to that account.

Impact Details

Proof of Concept

1

Deploy a non-payable contract staker

Example:

contract NonPayableStaker {
    // no `receive()` and no payable fallback
    function tryWithdraw(address staking) external {
        IStaking(staking).withdraw(); // will revert when staking sends native back
    }
}
2

Fund and stake from the contract

Fund the contract (e.g., in a test harness) and call stake(validatorId) sending native from that contract.

3

Unstake and wait for cooldown

Unstake to start the cooldown; wait for cooldown to mature so funds become "parked".

4

Attempt withdraw from the non-payable contract

From NonPayableStaker, call withdraw().

  • Internally: (bool success,) = msg.sender.call{value: amount}("") → reverts because there is no payable receive.

  • The function reverts with NativeTransferFailed(). Funds remain parked and cannot be withdrawn by this staker.

(Note: In Foundry you can vm.deal(nonPayable, X) to fund it without a payable receiver, then run the above flow and assert a revert on withdraw().)

Was this helpful?