All incidents

StakedV3 Forced Rebalance Exploit

Share
Jul 17, 2023 14:00 UTCAttackLoss: 31,099.53 BUSDPending manual check1 exploit txWindow: Atomic
Estimated Impact
31,099.53 BUSD
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Jul 17, 2023 14:00 UTC → Jul 17, 2023 14:00 UTC

Exploit Transactions

TX 1BSC
0x557628123d137ea49564e4dccff5f5d1e508607e96dd20fe99a670519b679cb5
Jul 17, 2023 14:00 UTCExplorer

Victim Addresses

0xb8dc09eec82cab2e86c7edc8dd5882dd92d22411BSC
0x556b9306565093c855aea9ae92a594704c2cd59eBSC

Loss Breakdown

31,099.53BUSD

Similar Incidents

Root Cause Analysis

StakedV3 Forced Rebalance Exploit

1. Incident Overview TL;DR

On BNB Chain block 30043574, transaction 0x557628123d137ea49564e4dccff5f5d1e508607e96dd20fe99a670519b679cb5 let an unprivileged adversary force StakedV3 pool 2 to unwind and recreate its shared Pancake V3 position at an attacker-controlled spot price. The attacker borrowed 12,000,001 BUSD through three public Pancake V3 flash loans, pushed the BUSD/USDT 0.01% pool at 0x4f3126d5de26413abdcf6948943fb9d0847d9818 out of the shared position's active range [-17, 17], called the public StakedV3.Invest entrypoint, then reversed the price move and repaid the flash liquidity.

The root cause is a public vault-wide rebalance path in StakedV3.Invest that trusts the instantaneous Pancake V3 spot price. Challenge(id) reads live slot0() and treats a one-sided concentrated-liquidity position as invalid, while wightReset(id) immediately recomputes target weights from a fresh manipulated quote. Because pool 2 is a shared strategy position rather than caller-isolated liquidity, the attack externalized the full rebalance loss onto StakedV3's vault and left the attacker with 31099528530032487542556 wei BUSD profit.

2. Key Background

StakedV3 at 0xb8dc09eec82cab2e86c7edc8dd5882dd92d22411 manages shared Pancake V3 liquidity positions. For pool id 2, the protocol stores one shared tokenId inside pools[2]; it is not a per-user NFT. Historical RPC reads at block 30043573 show:

StakedV3.pools(2):
  token0   = BUSD 0xe9e7cea3dedca5984780bafc599bd69add087d56
  token1   = USDT 0x55d398326f99059ff775485246999027b3197955
  pool     = 0x4f3126d5de26413abdcf6948943fb9d0847d9818
  farm     = 0x556b9306565093c855aea9ae92a594704c2cd59e
  fee      = 100
  point    = 1000000
  tokenId  = 138703

MasterChefV3.userPositionInfos(138703):
  liquidity = 22267725135386876207004445
  tickLower = -17
  tickUpper = 17
  user      = 0xb8dc09eec82cab2e86c7edc8dd5882dd92d22411
  pid       = 14

That structure matters because any public call that mutates pools[2] acts on the entire vault position. Concentrated-liquidity positions also become one-sided when the current spot moves outside the active tick range. Pancake V3 flash loans and swaps are permissionless, so a searcher can temporarily force that state and still revert the price within the same transaction.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK-class incident: a public vault rebalance can be forced from a transient, manipulable AMM price. The vulnerability is not hidden in attacker-specific calldata or privileged access; it is in the StakedV3 strategy logic itself. Invest is callable by any address and operates directly on pools[id], which holds shared vault state. Challenge decides whether the current position is "valid" by decomposing the active NFT against the current Pancake V3 slot0() price and returns false when either side becomes zero. When that happens, Invest executes a full harvest, liquidity removal, reswap, NFT withdrawal, and weight reset before minting or appending liquidity again. wightReset then recalculates weights from _amountOut, which itself depends on the same manipulated price path. The resulting invariant break is that a vault-wide rebalance is triggered and parameterized by attacker-controlled spot data rather than a manipulation-resistant price source.

4. Detailed Root Cause Analysis

The decisive logic is visible in the verified StakedV3 source.

Origin: verified StakedV3 source on BscScan

function Invest(
    uint id,
    uint amount,
    uint quoteAmount,
    uint investType,
    uint cycle,
    uint deadline
) public payable nonReentrant {
    ...
    if(isFarm[id]) {
        (bool pass,PoolToken memory tokens) = Challenge(id);
        if(!pass) {
            _harvest(id);
            _remove(id,tokens,deadline);
            _reSwap(id,tokens);
            _withdraw(id);
            wightReset(id);
        }
        ...
        if(!pass) {
            (quoteAmount,) = _amountOut(id,pools[id].token0,pools[id].token1,amount0,false);
        }
        Swap(id,pools[id].token0,pools[id].token1,amount0,quoteAmount,0);
        if(pools[id].tokenId == 0) {
            Mint(id,deadline);
        } else {
            Append(id,tokens,deadline);
        }
    }
}

Origin: verified StakedV3 source on BscScan

function wightReset(uint id) private {
    (uint amountOut,) = _amountOut(id,pools[id].token0,pools[id].token1,10 ** 18,false);
    pools[id].wight0 = 10 ** 18;
    pools[id].wight1 = amountOut;
}

function Challenge(uint id) public view returns (bool result,PoolToken memory tokens) {
    ...
    (sqrtPriceX96,,,,,,) = IUniswapV3Pool(pools[id].pool).slot0();
    ...
    (amount0,amount1) = ICompute(compute).getAmountsForLiquidity(
        sqrtPriceX96,
        sqrtRatioAX96,
        sqrtRatioBX96,
        tokenPosition.liquidity
    );
    if(amount0 == 0 || amount1 == 0) {
        result = false;
    } else {
        result = true;
    }
}

These functions establish the explicit invariant and breakpoint:

  • Invariant: an arbitrary caller must not be able to trigger a full vault-wide unwind and remint from a transient AMM spot price.
  • Breakpoint: the if (!pass) branch in Invest, where a manipulated Challenge(id) result forces _harvest, _remove, _reSwap, _withdraw, and wightReset.

MasterChefV3 confirms what the unwind does to the shared NFT.

Origin: verified MasterChefV3 source on BscScan

function withdraw(uint256 _tokenId, address _to) external nonReentrant returns (uint256 reward) {
    if (_to == address(this) || _to == address(0)) revert WrongReceiver();
    UserPositionInfo storage positionInfo = userPositionInfos[_tokenId];
    if (positionInfo.user != msg.sender) revert NotOwner();
    reward = harvestOperation(positionInfo, _tokenId, _to);
    ...
    delete userPositionInfos[_tokenId];
    ...
    nonfungiblePositionManager.safeTransferFrom(address(this), _to, _tokenId);
    emit Withdraw(msg.sender, _to, pid, _tokenId);
}

Because userPositionInfos[138703].user was StakedV3 before the exploit, StakedV3 was authorized to remove the shared NFT once Invest entered that branch.

5. Adversary Flow Analysis

The seed transaction is a single-transaction ACT sequence.

  1. The attacker EOA 0x3a10408fd7a2b2a43bd14a17c0d4568430b93132 called helper contract 0x18703a4fd7b3688607abf25424b6ab304def2512.
  2. The helper took three public Pancake V3 flash loans for BUSD from 0x22536030b9ce783b6ddfb9a39ac7f439f568e5e6, 0x85faac652b707fdf6907ef726751087f9e0b6687, and 0x369482c78bad380a036cab827fe677c1903d1523, totaling 12000001000000000000000000 wei BUSD.
  3. The helper swapped 12000000000000000000000000 wei BUSD into the attacked BUSD/USDT pool and pushed the tick to 29796, far outside the original [-17, 17] range.
  4. While the manipulated spot was live, the helper called StakedV3.Invest(2, 1000000000000000000, 2, 1, 7, 1689612419).
  5. Invest unwound shared tokenId 138703, withdrew it from MasterChefV3, recalculated weights from the manipulated price, minted tokenId 182823, and redeposited that new NFT back into MasterChefV3 for StakedV3.
  6. The helper then swapped the acquired USDT back into BUSD, repaid all three flash loans plus fees, and transferred the residual BUSD to the initiating EOA.

Origin: collector seed trace

emit Swap(... amount0: -11822760359241603066372096, amount1: 12000000000000000000000000, ... tick: 29796 ...)
0xB8dC09Eec82CaB2E86C7EdC8DD5882dd92d22411::Invest(2, 1000000000000000000, 2, 1, 7, 1689612419)
0x556B9306565093C855AEA9AE92A594704c2Cd59e::harvest(138703, 0xB8dC09Eec82CaB2E86C7EdC8DD5882dd92d22411)
0x556B9306565093C855AEA9AE92A594704c2Cd59e::decreaseLiquidity((138703, 22267725135386876207004445, ...))
0x556B9306565093C855AEA9AE92A594704c2Cd59e::withdraw(138703, 0xB8dC09Eec82CaB2E86C7EdC8DD5882dd92d22411)
emit Transfer(... to: 0xB8dC09Eec82CaB2E86C7EdC8DD5882dd92d22411, param2: 138703)
emit Transfer(... to: 0xB8dC09Eec82CaB2E86C7EdC8DD5882dd92d22411, param2: 182823)
0x46A15B0b27311cedF172AB29E4f4766fbE7F4364::safeTransferFrom(0xB8dC09..., 0x556B930..., 182823)
emit Transfer(from: PancakeV3Pool: [0x4f3126d5...], to: 0x18703A4f..., value: 12034115342258351564409378)
emit Transfer(from: 0x18703A4f..., to: 0x3A10408f..., value: 31099528530032487542556)

Post-state RPC reads at block 30043574 are consistent with the trace: StakedV3.pools(2).tokenId == 182823, MasterChefV3.userPositionInfos(182823).user == StakedV3, MasterChefV3.userPositionInfos(138703) is cleared, and NonfungiblePositionManager.ownerOf(138703) == StakedV3.

6. Impact & Losses

The measurable loss is the attacker's positive terminal BUSD extraction from the forced rebalance:

  • Token: BUSD
  • Raw on-chain amount: 31099528530032487542556
  • Decimal precision: 18
  • Human amount: 31,099.528530032487542556 BUSD

Origin: collector balance diff

{
  "native_balance_deltas": [
    {
      "address": "0x3a10408fd7a2b2a43bd14a17c0d4568430b93132",
      "delta_wei": "-25548873000000000"
    }
  ],
  "erc20_balance_deltas": [
    {
      "token": "0xe9e7cea3dedca5984780bafc599bd69add087d56",
      "holder": "0x3a10408fd7a2b2a43bd14a17c0d4568430b93132",
      "delta": "31099528530032487542556"
    }
  ]
}

The strategy also lost its original MasterChefV3 position 138703 and replaced it with newly minted tokenId 182823 priced from the manipulated market state. That is the concrete vault-side state transition that realized the loss.

7. References

  1. Seed transaction: 0x557628123d137ea49564e4dccff5f5d1e508607e96dd20fe99a670519b679cb5
  2. Victim contract: StakedV3 0xb8dc09eec82cab2e86c7edc8dd5882dd92d22411
  3. Farm contract: MasterChefV3 0x556b9306565093c855aea9ae92a594704c2cd59e
  4. Position manager: 0x46a15b0b27311cedf172ab29e4f4766fbe7f4364
  5. Attacked Pancake V3 pool: 0x4f3126d5de26413abdcf6948943fb9d0847d9818
  6. Collector artifacts used for validation: seed metadata, seed trace, and seed balance diff under /workspace/session/artifacts/collector/seed/56/0x557628123d137ea49564e4dccff5f5d1e508607e96dd20fe99a670519b679cb5/
  7. Verified source reviewed independently: StakedV3 and MasterChefV3 via Etherscan V2 / BscScan verified source endpoints