WUKONG Staking Protocol Reentrancy — StakingUpgradeableV10.unstake()

On 2026-03-11, the WUKONG staking protocol on BNB Chain was exploited via a classic reentrancy attack against its unstake() function in the StakingUpgradeableV10 implementation. The vulnerability arises because unstake() sends BNB to the caller (via a low-level call) before updating the user’s staking state (isStaking = false). An attacker deploying three purpose-built child contracts used PancakeSwap V2 flash swaps to borrow 2.01 BNB each, staked into the protocol, and recursively re-entered unstake() up to 82 times per child, draining the staking pool of approximately 57.68 BNB (~$33,457 USD).


Root Cause

Vulnerable Contract

  • Name: StakingUpgradeableV10
  • Proxy address: 0x07d398c888c353565cf549bbee3446791a49f285 (EIP-1967 proxy; no verified source — recovered via TAC)
  • Implementation address: 0xd828e972b7fc9ad4e6c29628a760386a94cfdeda (verified source on BSCScan)
  • Proxy status: Yes — all calls delegate to the implementation. The vulnerability lives in the implementation.
  • Source type: Verified (src/StakingUpgradeableV10.sol)

Vulnerable Function

  • Function name: unstake()
  • Full signature: unstake()
  • Selector: 0x2def6620
  • File: src/StakingUpgradeableV10.sol, lines 269–312

Vulnerable Code

function unstake() external whenNotPaused {
    require(hasStaked(msg.sender), "No stake found");
    uint256 index = userStakeIndex[msg.sender];
    require(stakeInfoList[index].isStaking, "Already unstaked");

    // 更新统计
    totalStakeAmount -= stakeInfoList[index].amount;
    totalStakeLpAmount -= stakeInfoList[index].lpAmount;

    // 移除LP
    uint256 tokenAmountBefore = stakingToken.balanceOf(address(this));
    uint256 bnbAmountBefore = address(this).balance;
    router.removeLiquidityETH(
        address(stakingToken), stakeInfoList[index].lpAmount, 0, 0, address(this), block.timestamp
    );
    uint256 bnbReceived = address(this).balance - bnbAmountBefore;
    uint256 tokenReceived = stakingToken.balanceOf(address(this)) - tokenAmountBefore;
    // 销毁所有代币
    if (tokenReceived > 0) {
        try ITokenWhiteList(address(stakingToken)).WHITE_LIST(msg.sender) returns (bool isWhitelisted) {
            if (isWhitelisted) {
                IERC20(address(stakingToken)).transfer(msg.sender, tokenReceived);
            } else {
                IERC20(address(stakingToken)).transfer(address(0xdEaD), tokenReceived);
            }
        } catch {
            IERC20(address(stakingToken)).transfer(address(0xdEaD), tokenReceived);
        }
    }
    // 返回BNB给用户                                                // <-- VULNERABILITY
    (bool success,) = payable(msg.sender).call{value: bnbReceived}("");  // <-- sends BNB BEFORE state update
    require(success, "Failed to transfer BNB");

    // 更新质押状态                                                 // <-- STATE UPDATE TOO LATE
    stakeInfoList[index].isStaking = false;     // <-- VULNERABILITY: written AFTER external call
    stakeInfoList[index].startTime = 0;
    stakeInfoList[index].amount = 0;
    stakeInfoList[index].lpAmount = 0;
    emit Unstake(msg.sender, bnbReceived, stakeInfoList[index].lpAmount);
}

Why It’s Vulnerable

Expected behavior: Before sending BNB to the caller, the function SHOULD set stakeInfoList[index].isStaking = false (and zero out amount and lpAmount). This would prevent any reentrant call to unstake() from passing the hasStaked() guard, since hasStaked() checks stakeInfoList[index].isStaking.

Actual behavior: The function sends BNB via an uncapped call on line 303 (payable(msg.sender).call{value: bnbReceived}("")) and only afterwards (lines 307–310) updates isStaking, amount, and lpAmount to zero. During the time the external call executes, stakeInfoList[index].isStaking is still true and lpAmount still holds the original LP token balance.

Why this matters: The attacker’s child contract implements a receive() fallback that, upon receiving BNB, calls back into unstake() on the proxy. Because isStaking has not been cleared and lpAmount has not been zeroed, the reentrant call passes both require(hasStaked(msg.sender)) and require(stakeInfoList[index].isStaking) checks. The protocol then calls router.removeLiquidityETH again with the same LP token balance (which is still recorded in stakeInfoList[index].lpAmount), withdrawing LP tokens that were already burned in the first call iteration. The protocol holds enough LP tokens on behalf of all stakers to satisfy the repeated withdrawals until the pool is drained.

Normal flow: Stake 2 BNB → receive LP tokens → call unstake() → LP burned, BNB returned, isStaking = false.

Attack flow: Stake 2 BNB → call unstake() → LP burned → BNB sent to attacker contract → attacker’s receive() re-enters unstake() → LP burned again (using same lpAmount still in storage) → BNB sent again → … (82 iterations) → only then does isStaking = false write execute.

The missing guard is a nonReentrant modifier (or equivalent checks-effects-interactions ordering). No such guard exists on unstake().


Attack Execution

High-Level Flow

  1. Attacker EOA (0x13be1ae7c8413cc95f3566e9393c618d29965ac8) deploys AttackerFactory via CREATE.
  2. AttackerFactory deploys three identical child contracts (AttackerChild1, AttackerChild2, AttackerChild3), each patched at construction time with the factory address as the profit recipient.
  3. For each child, the factory calls run() (0xc0406226) which triggers a PancakeSwap V2 flash swap on the WBNB/BUSD pair (0x58f876857a02d6762e0101bb5c46a8c1ed44dc16), borrowing 2.01 WBNB at zero upfront cost.
  4. Inside the flash swap callback (pancakeCall), the child unwraps the borrowed WBNB to BNB, then calls stake() on the vulnerable proxy with 2 BNB, receiving LP tokens credited to its address in stakeInfoList.
  5. The child immediately calls unstake() on the proxy. The proxy removes LP and sends BNB back to the child’s receive() fallback — before marking the stake as inactive.
  6. The child’s receive() fallback checks hasStaked(address(this)) (selector 0xc93c8f34) — which still returns true — then immediately re-enters unstake(). This recursion repeats 82 times for Child1, 64 times for Child2, and 44 times for Child3.
  7. After each child’s reentrancy chain unwinds, the child wraps accumulated BNB back to WBNB, repays the 2.01 WBNB flash loan fee to the pair, then sends remaining profits to the factory via SELFDESTRUCT.
  8. After all three children complete, the factory SELFDESTRUCTs, forwarding all accumulated BNB to the attacker EOA.

Detailed Call Trace

[CREATE] EOA 0x13be...9ac8 -> AttackerFactory 0xddb8...101f
  [CREATE] Factory -> AttackerChild1 0x1f86...ae73
  [CALL]   Factory -> Child1.run() (0xc0406226)
    [STATICCALL] Child1 -> VulnerableProxy.totalStakeAmount() (0x94409a56) [reads pool state]
      [DELEGATECALL] Proxy -> Implementation 0xd828...deda (same selector)
    [CALL]   Child1 -> PancakePair(WBNB/BUSD).swap() (0x022c0d9f) [flash swap: borrow 2.01 WBNB]
      [CALL]   Pair -> WBNB.transfer(Child1, 2.01 WBNB) (0xa9059cbb)
      [CALL]   Pair -> Child1.pancakeCall(0x84800812) [flash swap callback]
        [CALL]   Child1 -> WBNB.withdraw(2.01 WBNB) (0x2e1a7d4d)    [unwrap WBNB -> 2.01 BNB]
        [CALL]   Child1 -> VulnerableProxy.stake() (0x3a4b66f1) {value: 2 BNB}
          [DELEGATECALL] Proxy -> Implementation.stake()
            [... buys WUKONG tokens, adds liquidity to PancakePair(WUKONG/WBNB), mints LP ...]
        [CALL]   Child1 -> VulnerableProxy.unstake() (0x2def6620) [FIRST CALL — begins reentrancy]
          [DELEGATECALL] Proxy -> Implementation.unstake()
            [STATICCALL] check stakingToken.balanceOf(this)
            [CALL] PancakeRouter.removeLiquidityETH() (0x02751cec) [burns LP, releases WBNB+WUKONG]
              [CALL] Pair.burn() -> sends WBNB + WUKONG to proxy
              [CALL] WBNB.transfer to router, then unwrap to BNB
              [CALL] Router sends BNB -> Proxy (receive())
            [STATICCALL] check WHITE_LIST(msg.sender)
            [CALL] WUKONG.transfer(0xdEaD, tokenReceived)            [burns recovered WUKONG]
            [CALL] VulnerableProxy -> Child1 {value: ~0.7 BNB}       [BNB refund — TRIGGERS REENTRANCY]
              [STATICCALL] Child1 -> VulnerableProxy.hasStaked(Child1) (0xc93c8f34) -> true
              [CALL]        Child1 -> VulnerableProxy.unstake() (0x2def6620) [SECOND CALL — isStaking still true]
                [DELEGATECALL] Proxy -> Implementation.unstake()
                  [CALL] PancakeRouter.removeLiquidityETH() [burns same LP balance again]
                  [CALL] WUKONG.transfer(0xdEaD, ...)
                  [CALL] VulnerableProxy -> Child1 {value: ~0.7 BNB} [TRIGGERS REENTRANCY AGAIN]
                    [STATICCALL] hasStaked -> true
                    [CALL] unstake() ... [THIRD CALL]
                      ... [repeats 82 total iterations for Child1]
            // Only now does isStaking = false write execute (unwinding the call stack)
        [CALL]   Child1 -> WBNB.deposit() (0xd0e30db0) {value: accumulated BNB}  [rewrap]
        [CALL]   Child1 -> WBNB.transfer(Pair, 2.01 WBNB) (0xa9059cbb)           [repay flash loan]
        [SELFDESTRUCT] Child1 -> Factory {value: ~25.29 BNB}
      [STATICCALL] Pair checks balances (post-swap verification)

  [CREATE] Factory -> AttackerChild2 0xe89d...bc00
  [CALL]   Factory -> Child2.run() ... [same pattern, 64 unstake iterations, profit: 19.69 BNB]
  [CREATE] Factory -> AttackerChild3 0xa1bc...3548
  [CALL]   Factory -> Child3.run() ... [same pattern, 44 unstake iterations, profit: 12.69 BNB]

  [SELFDESTRUCT] Factory -> EOA 0x13be...9ac8 {value: 57.684887 BNB}

Financial Impact

Source: funds_flow.json attacker_gains and SELFDESTRUCT values from trace_callTracer.json.

Child ContractReentrancy IterationsBNB Profit (SELFDESTRUCT)
AttackerChild1 (0x1f86...ae73)8225.295 BNB
AttackerChild2 (0xe89d...bc00)6419.695 BNB
AttackerChild3 (0xa1bc...3548)4412.695 BNB
Total to Attacker EOA19057.685 BNB
  • Attacker net profit: 57.685 BNB (per funds_flow.json attacker_gains, labeled “ETH” for native asset)
  • USD equivalent: ~$33,457 at ~$580/BNB (approximate at time of attack)
  • Flash loan cost: 2.01 WBNB borrowed per child (total 6.03 WBNB). Each child repaid 2.015038 WBNB (2.01 WBNB principal + 0.25% PancakeSwap fee ≈ 0.005038 WBNB) and kept the remainder.
  • Who lost funds: Existing legitimate stakers in the WUKONG staking pool. The pool’s LP token reserves were drained across 190 reentrancy iterations.
  • WUKONG token impact: Each unstake() iteration transferred recovered WUKONG tokens to 0xdEaD (burn address). The PancakePair(WUKONG/WBNB) pool lost ~59.5 WBNB equivalent in reserves (net_changes for 0x7219...e080: -59.50 WBNB, -55,769,347 WUKONG).
  • Protocol solvency: The staking contract’s LP token balance was effectively drained. The protocol cannot be used for normal unstaking by remaining stakers.

Evidence

  • Transaction: 0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d22 on BNB Chain
  • Block: 86047027 (2026-03-11T22:16:23Z)
  • Receipt status: 0x1 (success); 16,678,437 gas used; 979 logs
  • Selector verification:
    • unstake()0x2def6620 (confirmed via cast sig "unstake()" and ABI)
    • stake()0x3a4b66f1 (confirmed via ABI)
    • hasStaked(address)0xc93c8f34 (confirmed via ABI)
    • totalStakeAmount()0x94409a56 (confirmed via ABI)
  • Reentrancy evidence in trace: The trace shows unstake() (0x2def6620) called recursively 82/64/44 times per child, with each call originating from Child.receive() triggered by the BNB transfer at trace_callTracer.json calls[1].calls[1].calls[1].calls[2]… at increasing depths.
  • State-update ordering proof: In StakingUpgradeableV10.sol lines 303–307, payable(msg.sender).call{value: bnbReceived}("") appears at line 303 while stakeInfoList[index].isStaking = false appears at line 307 — confirming the Checks-Effects-Interactions violation.
  • hasStaked() returning true during reentrancy: The trace shows 0xc93c8f34 (hasStaked) called by the child contract and returning successfully (not reverting) at every reentrancy level, confirming the stale state.