Ocean Pool NFT Reward Drain
Exploit Transactions
0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9Victim Addresses
0xdca503449899d5649d32175a255a8835a03e4006BSCLoss Breakdown
Similar Incidents
StakingDYNA Reward Backdating Drain
36%SNKMiner Referral Reward Drain
36%QiQi Reward Quote Override Drain
35%BSC staking pool reentrancy drain
33%DexToken BEP20USDT pool drain from token-logic exploit
33%H2O helper-token reward drain from unauthorized claim loop
33%Root Cause Analysis
Ocean Pool NFT Reward Drain
1. Incident Overview TL;DR
On BSC block 30056630, transaction 0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9 used helper contract 0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd to drain Ocean Pool 0xdca503449899d5649d32175a255a8835a03e4006. The helper had already been deployed by EOA 0xa6566574edc60d7b2adbacedb71d5142cf2677fb in tx 0x731b87a416bbf645a453f0617d6e4ff2388c04d8bcf2864722b20ae7dd2b34bf, but the exploit itself is fully realized in the seed transaction.
The root cause is a cross-function accounting bug. Pool.emergencyWithdraw() returns the user's principal stake and zeroes rewardDebt, but it does not clear nftAmount, nftAddition, poolInfo.stakeSupply, or poolInfo.nftAddition. Pool.unstakeNft(uint256[]) then calls pendingFit(msg.sender) and transfers BNO rewards before it removes NFTs or recomputes reward weight. That lets an unprivileged user receive positive BNO after allstake is already zero, violating the invariant that a zero-stake user must not keep live reward weight.
2. Key Background
Ocean Pool distributes BNO rewards through pendingFit(address), which computes pending rewards from (user.allstake + user.nftAddition) * accPerShare / 1e12 - user.rewardDebt. The NFT boost is tracked separately: updatePool() recomputes user.nftAddition as user.allstake * user.nftAmount * poolInfo.nftWeights / 100 and mirrors that value into poolInfo.nftAddition.
The pool is permissionless at the relevant touchpoints. stakeNft(uint[] memory tokenIds) and unstakeNft(uint[] memory tokenIds) are public, and the only gate they enforce is payment of the public withdrawalFee. stakeNft() also checks nftContract.isOfficialNFT(tokenId) and nftContract.ownerOf(tokenId) == msg.sender, so any unprivileged holder of official NFTs can enter the boosted reward path.
The critical state variables are:
userInfo[msg.sender].allstake: principal stake.userInfo[msg.sender].rewardDebt: already-accounted rewards.userInfo[msg.sender].nftAmount: staked NFT count.userInfo[msg.sender].nftAddition: reward boost derived from stake and NFT count.poolInfo.stakeSupplyandpoolInfo.nftAddition: aggregate accounting used in reward distribution.
3. Vulnerability Analysis & Root Cause Summary
This is an ATTACK-class protocol bug in the pool's own accounting, not an off-chain or privileged-access issue. The broken invariant is straightforward: once a user has withdrawn all principal stake, that user must not retain positive reward weight or receive additional BNO rewards unless new stake is added.
The verified Pool source shows why the invariant fails. pendingFit() pays based on user.allstake + user.nftAddition. updatePool() only refreshes nftAddition for the current caller when that function is reached. emergencyWithdraw() transfers out allstake and zeroes rewardDebt, but it leaves the NFT-related reward state and the aggregate pool accounting untouched. unstakeNft() then computes pending = pendingFit(msg.sender) and transfers BNO before it reduces nftAmount and before updatePool() clears the stale boost. As a result, a zero-stake user can still monetize stale NFT reward weight.
The relevant victim-side code path is visible in the verified Pool contract:
function pendingFit(address _user) public view returns(uint256){
UserInfo storage user = userInfo[_user];
...
uint256 userreward =
(user.allstake.add(user.nftAddition)).mul(accPerShare).div(1e12).sub(user.rewardDebt);
return userreward;
}
function updatePool() public {
...
if(userInfo[msg.sender].nftAddition != 0){
poolInfo.nftAddition = poolInfo.nftAddition.sub(userInfo[msg.sender].nftAddition);
}
userInfo[msg.sender].nftAddition =
userInfo[msg.sender].allstake.mul(userInfo[msg.sender].nftAmount).mul(poolInfo.nftWeights).div(100);
poolInfo.nftAddition = poolInfo.nftAddition.add(userInfo[msg.sender].nftAddition);
...
}
function emergencyWithdraw() public {
pledgeAddress.safeTransfer(address(msg.sender), userInfo[msg.sender].allstake);
userInfo[msg.sender].allstake = 0;
userInfo[msg.sender].rewardDebt = 0;
}
function unstakeNft(uint[] memory tokenIds) public payable notPause{
uint256 pending = pendingFit(msg.sender);
if(pending > 0){
safeGoodTransfer(msg.sender,pending);
emit Withdraw(msg.sender, pending);
}
...
userInfo[msg.sender].nftAmount = userInfo[msg.sender].nftAmount.sub(1);
...
updatePool();
}
4. Detailed Root Cause Analysis
The exploit is permissionless from pre-state sigma_B at block 30056629, immediately before the seed transaction. At that point the helper contract already exists, the pool still holds BNO inventory, and the adversary controls official NFT IDs 13 and 14. The ACT predicate is: an unprivileged user can call emergencyWithdraw() so that userInfo.allstake == 0, then still receive positive BNO from unstakeNft() before any new stake is added because stale NFT weight remains live.
The exploit sequence is:
stakeNft([13, 14])deposits official NFTs into the pool and increasesnftAmount.pledge(amount)adds BNO stake and refreshesnftAdditionthroughupdatePool().emergencyWithdraw()returns the full principal and zeroes onlyallstakeandrewardDebt.unstakeNft([13, 14])immediately callspendingFit(msg.sender)and transfers BNO while stalenftAdditionand aggregate accounting still exist.- Only after paying the reward does
unstakeNft()remove NFTs and callupdatePool().
The seed trace shows this exact sequence in the first loop:
Pool::stakeNft{value: 8000000000000000}([13, 14])
...
emit Pledge(user: 0xD138b9a58D3e5f4be1CD5eC90B66310e241C13CD, amount: 277856480175428872286290)
Pool::emergencyWithdraw()
...
Pool::unstakeNft{value: 8000000000000000}([13, 14])
...
BNO::transfer(0xD138b9..., 3743027038091722541135)
emit Withdraw(user: 0xD138b9..., amount: 3743027038091722541135)
The same trace pattern repeats 100 times in one transaction. The trace evidence also shows:
- 200 fee-bearing
stakeNft/unstakeNftcalls with NFT callbacks. - 100 fee-bearing
pledgecalls. - 100 zero-value
emergencyWithdraw()calls. - 100 reward
Withdrawevents emitted during post-emergency NFT exits.
This validates the precise code-level breakpoint stated in the analysis: emergencyWithdraw() unwinds principal without unwinding reward weight, and unstakeNft() realizes that stale weight before fixing state.
The exploit conditions are limited and public:
- The adversary needs an unprivileged address that can hold at least one official NFT and interact with the pool.
- To recreate the full incident loop, the adversary also needs a BNO stake position; a user who is already staked can trigger the bug immediately with
emergencyWithdraw()followed byunstakeNft(). - The adversary must be able to pay the public
withdrawalFee.
The violated security principles are equally concrete:
- Emergency exit paths must preserve the same accounting invariants as ordinary exit paths.
- Reward transfers must use current live stake state, not stale NFT boost that survived a principal withdrawal.
- User-level and pool-level accounting must be unwound atomically when principal is removed.
5. Adversary Flow Analysis
The adversary cluster contains:
- EOA
0xa6566574edc60d7b2adbacedb71d5142cf2677fb, which deployed the helper contract and sent the exploit transaction. - Helper contract
0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd, which executed the repeated pool interactions.
The helper contract page on BscScan identifies the same EOA as creator and links the creation tx 0x731b87a416bbf645a453f0617d6e4ff2388c04d8bcf2864722b20ae7dd2b34bf. The exploit transaction metadata independently confirms that tx 0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9 was sent from that EOA to the helper contract.
The end-to-end exploit flow is:
- The EOA deploys a custom helper contract with the pool, NFT, and BNO addresses embedded.
- In the exploit tx, the helper first acquires NFT IDs
13and14. - The helper then loops 100 times through
stakeNft -> pledge -> emergencyWithdraw -> unstakeNft. - Each loop re-establishes a positive
nftAddition, removes principal without clearing that boost, and cashes out a fresh BNO reward duringunstakeNft. - The helper retains the drained BNO after transfer-tax deductions.
Victim-side public components involved in the flow are:
- Pool
0xdca503449899d5649d32175a255a8835a03e4006 - BNO
0xa4dbc813f7e1bf5827859e278594b1e0ec1f710f - NFT
0x8ee0c2709a34e9fda43f2bd5179fa4c112bed89a
6. Impact & Losses
The balance diff for the seed transaction shows direct monetary loss:
{
"holder": "0xdca503449899d5649d32175a255a8835a03e4006",
"before": "1563370168598147731548146",
"after": "782078794329514643016889",
"delta": "-781291374268633088531257"
}
The helper contract simultaneously ends with:
{
"holder": "0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd",
"before": "0",
"after": "763070793094412702283980",
"delta": "763070793094412702283980"
}
Measured loss and side effects:
- Pool loss:
781291374268633088531257BNO. - Token decimals:
18. - Pool inventory fell from
1563370168598147731548146BNO to782078794329514643016889BNO in one transaction. - The exploit also paid 300 public withdrawal fees, sending
2400000000000000000wei to the fee wallet0x978489611b7508e8515ee039d3f62612e128f50f.
7. References
- Seed transaction metadata:
0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9 - Seed transaction trace:
artifacts/collector/seed/56/0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9/trace.cast.log - Seed transaction balance diff:
artifacts/collector/seed/56/0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9/balance_diff.json - Verified Pool source:
https://bscscan.com/address/0xdca503449899d5649d32175a255a8835a03e4006#code - Helper contract page and creator information:
https://bscscan.com/address/0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd - NFT source artifact:
artifacts/collector/seed/56/0x8ee0c2709a34e9fda43f2bd5179fa4c112bed89a/src/Contract.sol - BNO source artifact:
artifacts/collector/seed/56/0xa4dbc813f7e1bf5827859e278594b1e0ec1f710f/src/Contract.sol - Auditor Forge validation log:
artifacts/auditor/forge-test.log