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
- Attacker EOA (
0x13be1ae7c8413cc95f3566e9393c618d29965ac8) deploysAttackerFactoryvia CREATE. AttackerFactorydeploys three identical child contracts (AttackerChild1,AttackerChild2,AttackerChild3), each patched at construction time with the factory address as the profit recipient.- 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. - Inside the flash swap callback (
pancakeCall), the child unwraps the borrowed WBNB to BNB, then callsstake()on the vulnerable proxy with 2 BNB, receiving LP tokens credited to its address instakeInfoList. - The child immediately calls
unstake()on the proxy. The proxy removes LP and sends BNB back to the child’sreceive()fallback — before marking the stake as inactive. - The child’s
receive()fallback checkshasStaked(address(this))(selector0xc93c8f34) — which still returnstrue— then immediately re-entersunstake(). This recursion repeats 82 times for Child1, 64 times for Child2, and 44 times for Child3. - 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. - 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 Contract | Reentrancy Iterations | BNB Profit (SELFDESTRUCT) |
|---|---|---|
AttackerChild1 (0x1f86...ae73) | 82 | 25.295 BNB |
AttackerChild2 (0xe89d...bc00) | 64 | 19.695 BNB |
AttackerChild3 (0xa1bc...3548) | 44 | 12.695 BNB |
| Total to Attacker EOA | 190 | 57.685 BNB |
- Attacker net profit: 57.685 BNB (per
funds_flow.jsonattacker_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 to0xdEaD(burn address). The PancakePair(WUKONG/WBNB) pool lost ~59.5 WBNB equivalent in reserves (net_changesfor0x7219...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:
0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d22on 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 viacast 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 fromChild.receive()triggered by the BNB transfer attrace_callTracer.jsoncalls[1].calls[1].calls[1].calls[2]… at increasing depths. - State-update ordering proof: In
StakingUpgradeableV10.sollines 303–307,payable(msg.sender).call{value: bnbReceived}("")appears at line 303 whilestakeInfoList[index].isStaking = falseappears at line 307 — confirming the Checks-Effects-Interactions violation. hasStaked()returningtrueduring reentrancy: The trace shows0xc93c8f34(hasStaked) called by the child contract and returning successfully (not reverting) at every reentrancy level, confirming the stale state.
Related URLs
- Transaction: https://bscscan.com/tx/0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d22
- Vulnerable proxy: https://bscscan.com/address/0x07d398c888c353565cf549bbee3446791a49f285
- Vulnerable implementation: https://bscscan.com/address/0xd828e972b7fc9ad4e6c29628a760386a94cfdeda#code
- Attacker EOA: https://bscscan.com/address/0x13be1ae7c8413cc95f3566e9393c618d29965ac8