All incidents

Ocean Pool NFT Reward Drain

Share
Jul 18, 2023 00:55 UTCAttackLoss: 781,291.37 BNOPending manual check1 exploit txWindow: Atomic
Estimated Impact
781,291.37 BNO
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Jul 18, 2023 00:55 UTC → Jul 18, 2023 00:55 UTC

Exploit Transactions

TX 1BSC
0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9
Jul 18, 2023 00:55 UTCExplorer

Victim Addresses

0xdca503449899d5649d32175a255a8835a03e4006BSC

Loss Breakdown

781,291.37BNO

Similar Incidents

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.stakeSupply and poolInfo.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:

  1. stakeNft([13, 14]) deposits official NFTs into the pool and increases nftAmount.
  2. pledge(amount) adds BNO stake and refreshes nftAddition through updatePool().
  3. emergencyWithdraw() returns the full principal and zeroes only allstake and rewardDebt.
  4. unstakeNft([13, 14]) immediately calls pendingFit(msg.sender) and transfers BNO while stale nftAddition and aggregate accounting still exist.
  5. Only after paying the reward does unstakeNft() remove NFTs and call updatePool().

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/unstakeNft calls with NFT callbacks.
  • 100 fee-bearing pledge calls.
  • 100 zero-value emergencyWithdraw() calls.
  • 100 reward Withdraw events 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 by unstakeNft().
  • 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:

  1. The EOA deploys a custom helper contract with the pool, NFT, and BNO addresses embedded.
  2. In the exploit tx, the helper first acquires NFT IDs 13 and 14.
  3. The helper then loops 100 times through stakeNft -> pledge -> emergencyWithdraw -> unstakeNft.
  4. Each loop re-establishes a positive nftAddition, removes principal without clearing that boost, and cashes out a fresh BNO reward during unstakeNft.
  5. 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: 781291374268633088531257 BNO.
  • Token decimals: 18.
  • Pool inventory fell from 1563370168598147731548146 BNO to 782078794329514643016889 BNO in one transaction.
  • The exploit also paid 300 public withdrawal fees, sending 2400000000000000000 wei to the fee wallet 0x978489611b7508e8515ee039d3f62612e128f50f.

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