Calculated from recorded token losses using historical USD prices at the incident time.
0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d220x07d398c888c353565cf549bbee3446791a49f285BSC0x7219e1a1e14c3f7e52db43a4a2db21d30957e080BSCThe validated incident is a single BSC transaction, 0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d22, mined in block 86047027. An unprivileged attacker EOA, 0x13be1ae7c8413cc95f3566e9393c618d29965ac8, deployed a helper contract, staked 2 BNB into the Wukong staking proxy at 0x07D398c888c353565CF549bBeE3446791a49F285, and then reentered unstake() from the helper fallback each time the proxy forwarded BNB back to msg.sender.
The root cause is a classic checks-effects-interactions violation in the verified StakingUpgradeableV10 implementation at 0xd828e972b7fc9ad4e6c29628a760386a94cfdeda. unstake() removes LP and sends BNB to the caller before clearing stakeInfoList[index], so the same recorded lpAmount remains reusable during the external call window. Because the proxy custody model pools LP at the contract level, each nested call spends shared LP inventory rather than failing on a per-user balance check. The attack drained 59.484887218045112776 WBNB from the WkToken/WBNB pair and left the attacker EOA with a verified net gain of 57.684053296195112776 BNB after fees. No separate non-monetary predicate is needed for validation; the success condition is pure attacker profit.
The affected protocol component is the Wukong staking proxy 0x07D398c888c353565CF549bBeE3446791a49F285, which delegates to StakingUpgradeableV10. The pool was publicly open before the exploit, so any EOA could call stake() and create a live StakeInfo record. The ACT pre-state at block 86047027 therefore already satisfied the essential exploit conditions: the pool was open, the proxy held LP inventory, and the WkToken/WBNB pair at 0x7219E1a1e14c3F7E52DB43a4A2Db21d30957e080 held live WBNB reserves.
The important architectural detail is that user stakes are only accounting records. The proxy itself holds the LP tokens, while each user record stores an lpAmount entitlement and an isStaking flag. unstake() spends LP from the proxy's shared balance via PancakeRouter removeLiquidityETH(...); it does not withdraw from a segregated user-held LP position. That makes stale bookkeeping especially dangerous, because one stale record can authorize repeated withdrawals against pooled inventory.
WkToken behavior matters to the realized loss profile. The helper was not on the token whitelist, so when LP removal returned the token leg to the staking proxy, the subsequent token path did not preserve value for the attacker. The trace shows WHITE_LIST(helper) -> false and the token leg being transferred to 0x000000000000000000000000000000000000dEaD. The economic gain therefore came from repeated extraction of the WBNB/BNB leg, not from retaining WkToken.
The unrelated seed transaction 0x97e2b875552e4e82d058a775c7dd14198d15df869260235dbaf6577e5e3b13cc was correctly excluded from the final scope. It reflects a separate privileged TokenMt abuse path and is not part of this ACT incident.
The vulnerability class is reentrancy on a stateful withdrawal path. In StakingUpgradeableV10, the invariant should be: one live stake record authorizes exactly one LP redemption, and the record must be deactivated before any external interaction that can call back into unstake(). The implementation violates that invariant because it performs router.removeLiquidityETH(...), computes bnbReceived, and then executes payable(msg.sender).call{value: bnbReceived}("") before zeroing stakeInfoList[index] or flipping isStaking to false. During that external call, hasStaked(msg.sender) still returns true and stakeInfoList[index].lpAmount is unchanged. An attacker-controlled contract can therefore reenter unstake() and force another removeLiquidityETH(...) using the same stale entitlement. The exploit remains permissionless because stake() is public and the only extra requirement is to use a contract recipient with a fallback function. The shared-LP custody model amplifies the bug into a drain because each reentry spends protocol-held LP, not attacker-owned LP.
The verified victim code exposes the exact breakpoint:
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;
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;
(bool success,) = payable(msg.sender).call{value: bnbReceived}("");
require(success, "Failed to transfer BNB");
stakeInfoList[index].isStaking = false;
stakeInfoList[index].startTime = 0;
stakeInfoList[index].amount = 0;
stakeInfoList[index].lpAmount = 0;
}
The exploit path starts with a legitimate public stake. The helper contract 0x1F86f99C533D09c965c7AEa1A67B73d72BAbAE73 stakes 2 BNB, and the trace shows the proxy minting and retaining 890842132399861329863 LP units for that user's record while the proxy's LP inventory remains much larger:
PancakePair::balanceOf(0x07D398c888c353565CF549bBeE3446791a49F285)
← 146401984075546478954171
emit Stake(
user: 0x1F86f99C533D09c965c7AEa1A67B73d72BAbAE73,
bnbAmount: 2000000000000000000,
lpAmount: 890842132399861329863,
isImported: false
)
That ratio is the key exploit precondition after stake setup: the single-user entitlement is tiny relative to the proxy's pooled LP inventory, so repeated redemptions can keep succeeding.
The first unstake() then removes liquidity with the stale lpAmount, unwraps WBNB, and transfers 699999999999999999 wei to the helper. The trace immediately proves the reentrancy window:
StakingUpgradeableV10::unstake()
PancakeRouter::removeLiquidityETH(..., 890842132399861329863, ...)
WBNB::withdraw(699999999999999999)
0x1F86f99C533D09c965c7AEa1A67B73d72BAbAE73::fallback{value: 699999999999999999}()
0x07D398...::hasStaked(0x1F86f99C...)
← true
0x07D398...::unstake()
At that moment, the logical safety invariant is already broken. The helper has received value, but its stake record is still active and still carries the original lpAmount. The nested call therefore repeats the exact same removeLiquidityETH(...) against protocol inventory. The same pattern recurs until the transaction runs out of usable gas.
The WkToken leg does not drive the profit. The trace shows WkToken::WHITE_LIST(helper) -> false, followed by transfer of the token output to 0x000000000000000000000000000000000000dEaD. This is why the attack's monetary predicate is correctly framed in BNB/WBNB rather than in WkToken: the attacker repeatedly monetizes the WBNB side of liquidity removal while the token side is burned away.
The exploit is fully ACT-feasible. Any unprivileged EOA could have reproduced the strategy from the public pre-state by deploying a helper contract with a fallback, funding it with 2 BNB, calling the public stake() function, and then invoking the public unstake() function. No privileged role, hidden state, or attacker-owned legacy contract was required.
The validated transaction sequence contains one adversary-crafted transaction:
0x13be1ae7c8413cc95f3566e9393c618d29965ac8 deploys orchestrator contract 0xddb8fd9441242b25f401096536d6ef83afa9101f.0x1f86f99c533d09c965c7aea1a67b73d72babae73, the address that will become msg.sender for staking and unstaking.stake() with 2 BNB. The staking proxy converts the configured share of the deposit into LP, keeps the LP inside the proxy, and writes a live StakeInfo record with lpAmount = 890842132399861329863.unstake(). The proxy removes liquidity using the recorded lpAmount, burns the token leg because the helper is not whitelisted, and forwards the BNB leg to the helper.hasStaked(helper) still returns true, the fallback reenters unstake() and repeats the same LP redemption against the proxy's pooled inventory.57.880785884886662585 BNB versus 0.196732588691549809 BNB before the tx.This flow is consistent with the ACT adversary model. The only “custom” attacker logic is the fallback-based helper, which is trivial to reproduce from first principles and does not depend on incident-specific attacker calldata or bytecode.
The direct asset loss is the pair-side WBNB depletion recorded in balance_diff.json:
0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c: raw 59484887218045112776, which is 59.484887218045112776 WBNB at 18 decimals.The validated attacker profit is the sender EOA's native balance change:
196732588691549809 wei = 0.196732588691549809 BNB57880785884886662585 wei = 57.880785884886662585 BNB57684053296195112776 wei = 57.684053296195112776 BNBThe fee component is also deterministic. The receipt reports gasUsed = 16678437 and effectiveGasPrice = 50000000, so gas paid was 833921850000000 wei, or 0.00083392185 BNB. The reported attacker delta is already net of that fee.
0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d22860470270x07D398c888c353565CF549bBeE3446791a49F2850xd828e972b7fc9ad4e6c29628a760386a94cfdeda0xdd540a1e727fe562a63b4d7925f229e4e693cc0e0x7219E1a1e14c3F7E52DB43a4A2Db21d30957e080/workspace/session/artifacts/collector/seed/56/0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d22/metadata.json/workspace/session/artifacts/collector/seed/56/0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d22/trace.cast.log/workspace/session/artifacts/collector/seed/56/0x79467533d4d1f332df846dc78c16fe319cd1d3a1a0f01545b4cdd7a2d3a71d22/balance_diff.jsonhttps://bscscan.com/address/0x07D398c888c353565CF549bBeE3446791a49F285#codehttps://bscscan.com/address/0xd828e972b7fc9ad4e6c29628a760386a94cfdeda#code