All incidents

Nimbus Spot-Oracle Reward Drain

Share
Dec 05, 2022 08:57 UTCAttackLoss: 16,480,822.53 GNIMBPending manual check2 exploit txWindow: 8d 18h
Estimated Impact
16,480,822.53 GNIMB
Label
Attack
Exploit Tx
2
Addresses
3
Attack Window
8d 18h
Dec 05, 2022 08:57 UTC → Dec 14, 2022 03:40 UTC

Exploit Transactions

TX 1BSC
0x7d2d8d2cda2d81529e0e0af90c4bfb39b6e74fa363c60b031d719dd9d153b012
Dec 05, 2022 08:57 UTCExplorer
TX 2BSC
0x42f56d3e86fb47e1edffa59222b33b73e7407d4b5bb05e23b83cb1771790f6c1
Dec 14, 2022 03:40 UTCExplorer

Victim Addresses

0x3aa2b9de4ce397d93e11699c3f07b769b210bbd5BSC
0x706065716569f20971f9cf8c66d092824c284584BSC
0xdef57a7722d4411726ff40700eb7b6876bee7ecbBSC

Loss Breakdown

16,480,822.53GNIMB

Similar Incidents

Root Cause Analysis

Nimbus Spot-Oracle Reward Drain

1. Incident Overview TL;DR

Nimbus reward contracts at 0x3aa2b9de4ce397d93e11699c3f07b769b210bbd5, 0x706065716569f20971f9cf8c66d092824c284584, and 0xdef57a7722d4411726ff40700eb7b6876bee7ecb priced GNIMB and NIMB through PriceFeedSwap contracts instead of a manipulation-resistant oracle. In setup transaction 0x7d2d8d2cda2d81529e0e0af90c4bfb39b6e74fa363c60b031d719dd9d153b012, the adversary bought GNIMB and then staked into the three pools while the stake-time conversion path read manipulated spot prices. In exploit transaction 0x42f56d3e86fb47e1edffa59222b33b73e7407d4b5bb05e23b83cb1771790f6c1, the adversary borrowed public flash liquidity, pushed the NIMB spot price, harvested rewards, withdrew stake, and exited through Nimbus liquidity.

The root cause is deterministic: PriceFeedSwap.latestAnswer() returns NimbusRouter.getAmountsOut(...) output, and the reward contracts trust that value in both stake-time and claim-time accounting. Because the adversary controls AMM reserves through public swaps, it can inflate the stored NIMB-equivalent principal and the later GNIMB payout rate inside the same economic system.

2. Key Background

Nimbus uses a PriceFeeds aggregator at 0xb8ac7fabff0d901878c269330b32cdd8d2ba3b8c to convert between tokens. For this incident, the relevant registered feeds are 0x58edbb48887711e179e66c4e86351fd06af52a87 for GNIMB and 0x199600cb7fdb1a72fc978f435e04b1cd1d260aa3 for NIMB. Both feeds are PriceFeedSwap contracts wired to the public Nimbus router at 0x2c6cf65f3cd32a9be1822855abf2321f6f8f6b24.

The three victim contracts all stake GNIMB, accrue rewards in NIMB-equivalent units, and pay out in GNIMB. When usePriceFeeds is enabled, stake-time principal conversion and claim-time payout conversion both flow through the same PriceFeeds.queryRate() path. That design matters because Nimbus router quotes are ordinary AMM spot quotes, so they are directly sensitive to adversary-controlled swaps.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK-class ACT issue caused by reward accounting that treats live AMM spot quotes as trustworthy oracle prices. The reward contracts snapshot rewardsTokenAmount during _stake by converting GNIMB into NIMB-equivalent units. Later, earnedByNonce() converts accrued NIMB-equivalent rewards back into GNIMB using the same price-feed system. PriceFeeds._queryRateCall() simply forwards to the registered feed’s latestAnswer(), and each relevant PriceFeedSwap.latestAnswer() is a direct swapRouter.getAmountsOut(...) read over the GNIMB or NIMB path. The invariant that reward principal and payout must be derived from manipulation-resistant prices is therefore broken at the first latestAnswer() call. Once the adversary can move the underlying AMM reserves before staking and again before claiming, the contracts deterministically over-credit stake basis and overpay GNIMB rewards.

4. Detailed Root Cause Analysis

The reward-accounting path is explicit in the collected source:

function earnedByNonce(address account, uint256 nonce) public view returns (uint256) {
    uint256 amount = stakeNonceInfos[account][nonce].rewardsTokenAmount *
        (block.timestamp - stakeNonceInfos[account][nonce].stakeTime) *
        stakeNonceInfos[account][nonce].rewardRate / (100 * rewardDuration);
    return getTokenAmountForToken(address(rewardsToken), address(rewardsPaymentToken), amount);
}

function _stake(uint256 amount, address user) private whenNotPaused {
    stakingToken.safeTransferFrom(msg.sender, address(this), amount);
    uint256 amountRewardEquivalent = getEquivalentAmount(amount);
    ...
    stakeNonceInfos[user][stakeNonce].rewardsTokenAmount = amountRewardEquivalent;
}

The pricing layer the contracts trust is equally direct:

function _queryRateCall(address token) internal view returns (uint256 rate) {
    IPriceFeedsExt _Feed = pricesFeeds[token];
    rate = uint256(_Feed.latestAnswer());
}
function latestAnswer() external override view returns (uint256) {
    return swapRouter.getAmountsOut(10 ** decimals, swapPath)[swapPath.length - 1]
        * multiplier / MULTIPLIER_DEFAULT;
}

The setup trace shows the first leg of exploitation. Transaction 0x7d2d8d2cda2d81529e0e0af90c4bfb39b6e74fa363c60b031d719dd9d153b012 buys GNIMB through the Nimbus router and then calls stake() on all three pools. Inside those stake calls, the trace records PriceFeeds::queryRate(...) followed by PriceFeedSwap::latestAnswer() calls for both feeds, confirming that stake-time basis capture depends on manipulated spot quotes.

The exploit trace shows the second leg. Transaction 0x42f56d3e86fb47e1edffa59222b33b73e7407d4b5bb05e23b83cb1771790f6c1 borrows flash liquidity, swaps into NIMB to move the NIMB spot price, and then calls getReward() and withdraw(0). The trace again records queryRate and latestAnswer() during reward payout, so claim-time GNIMB conversion also depends on adversary-controlled AMM reserves. This is the exact code-level breakpoint described in the validated root cause.

5. Adversary Flow Analysis

The adversary cluster centers on EOA 0x86aa1c46f2ae35ba1b228dc69fb726813d95b597, which deployed and configured setup and exploit helper contracts in earlier related transactions, including deployment transaction 0x843b7769eefae98803074812429f4197f9603d7e68874ae1442fade6b3e01aab.

The on-chain flow is:

  1. Deploy helper contracts for the three reward pools and wire them to attacker-controlled setup and exploit contracts.
  2. In 0x7d2d8d2c..., buy GNIMB through the public router and stake into the three pools while GNIMB spot pricing is elevated.
  3. Wait for accrual.
  4. In 0x42f56d3e..., source public flash liquidity, buy NIMB to elevate the claim-time conversion path, then call getReward() on all three pools and withdraw(0) on the unlocked pool.
  5. Exit by selling extracted GNIMB back through public Nimbus liquidity.

Representative trace points from the collected evidence:

... NimbusRouter::swapExactTokensForTokensSupportingFeeOnTransferTokens(... [NBU_WBNB, GNIMB] ...)
... 0x3aA2B9de4ce397d93E11699C3f07B769b210bBD5::stake(154608210771026836098104)
... PriceFeeds::queryRate(GNIMB, NIMB)
... PriceFeedSwap::latestAnswer()
... NimbusRouter::swapExactTokensForTokensSupportingFeeOnTransferTokens(... [NBU_WBNB, NIMB] ...)
... 0x3aA2B9de4ce397d93E11699C3f07B769b210bBD5::getReward()
... 0x3aA2B9de4ce397d93E11699C3f07B769b210bBD5::withdraw(0)
... LockStakingRewardFixedAPY::getReward()

6. Impact & Losses

The combined victim loss is GNIMB only. The validated loss artifact records a total depletion of 16,480,822.532403475022448462 GNIMB, encoded on-chain as "16480822532403475022448462".

The exploit balance diff for 0x42f56d3e... shows the three reward pools going to zero GNIMB balance in the same transaction:

[
  {
    "holder": "0x3aa2b9de4ce397d93e11699c3f07b769b210bbd5",
    "delta": "-13974836647503669949252311"
  },
  {
    "holder": "0x706065716569f20971f9cf8c66d092824c284584",
    "delta": "-1722821572931921197708161"
  },
  {
    "holder": "0xdef57a7722d4411726ff40700eb7b6876bee7ecb",
    "delta": "-783134358825383610974990"
  }
]

Those deltas sum to the total reported loss and match the exploit predicate in the ACT package: the reward pools are drained because reward accounting trusted attacker-manipulable spot pricing at both stake and claim time.

7. References

  • Setup transaction trace: 0x7d2d8d2cda2d81529e0e0af90c4bfb39b6e74fa363c60b031d719dd9d153b012
  • Exploit transaction trace: 0x42f56d3e86fb47e1edffa59222b33b73e7407d4b5bb05e23b83cb1771790f6c1
  • Victim source: StakingRewardsFixedAPY.sol and LockStakingRewardFixedAPY.sol
  • Price aggregator source: PriceFeeds at 0xb8ac7fabff0d901878c269330b32cdd8d2ba3b8c
  • Feed source: PriceFeedSwap at 0x58edbb48887711e179e66c4e86351fd06af52a87 and 0x199600cb7fdb1a72fc978f435e04b1cd1d260aa3
  • Router source: NimbusRouter at 0x2c6cf65f3cd32a9be1822855abf2321f6f8f6b24
  • Loss evidence: exploit balance_diff.json for 0x42f56d3e86fb47e1edffa59222b33b73e7407d4b5bb05e23b83cb1771790f6c1