All incidents

NovaX TokenStake Oracle Manipulation Exploit

Share
Aug 06, 2024 04:05 UTCAttackLoss: 84,319.2 NovaX, 24,971.81 USDTManually checked1 exploit txWindow: Atomic

Root Cause Analysis

NovaX TokenStake Oracle Manipulation Exploit

1. Incident Overview TL;DR

On BNB Smart Chain (chainid 56), a single adversary-crafted transaction in block 41116211 (tx 0xb1ad1188d6…e012) exploited the NovaX TokenStake contract by abusing its price oracle. A helper contract first manipulated the NovaX/USDT PancakeSwap pair that feeds Oracle 0xaeb77ff2…, then performed a same-transaction stake followed by withdraw against TokenStake 0x55c9eebd…. Because TokenStake directly uses the manipulated oracle output for both recording stake value and redeeming it, the attacker withdrew more NovaX than deposited and simultaneously drained USDT from the NovaX/USDT pool. As a result, TokenStake lost 84,319,204,255,820,354,933,135 NovaX tokens and the NovaX/USDT pair lost 26,476,325,961,551,338,835,137 USDT, while adversary EOAs received 66,251,109,303,267,001,599,526 NovaX and 24,971.812420929473237347 USDT. The gas cost (0.00693603 BNB) is negligible relative to the USDT profit, so the transaction is strictly profitable and constitutes an anyone-can-take (ACT) opportunity.

2. Key Background

The NovaX ecosystem on BSC includes:

  • NovaXToken (0xb800aff8…) — an ERC‑20-like token with standard balances and taxes.
  • TokenStake (0x55c9eebd…) — a staking contract that accepts NovaX deposits into several pools and tracks each stake’s value in an internal “USD” unit.
  • Oracle (0xaeb77ff2…) — a price oracle that converts between USD‑denominated values and NovaX amounts using a combination of an internal swap contract and a NovaX/USDT PancakeSwap pair.
  • InternalSwap (0xe7610919…) — a contract recording an owner‑set NovaX/USDT price used by the oracle as one of its sources.
  • PancakeRouter (0x10ed43c7…) and Pancake pairs (0x05a911cc… NovaX/USDT, 0x7efaef62… USDT/BUSD) — standard AMM infrastructure used for price manipulation.

TokenStake values stakes and withdrawals by calling the oracle. For a given token token and amount _tokenAmount, it records a USD‑denominated value using:

function tokenToUsd(address token, uint256 _tokenAmount) public view returns (uint256) {
    address oracleContract = oracleContracts[token];
    return (1000000 * _tokenAmount)
        / IOracle(oracleContract).convertUsdBalanceDecimalToTokenDecimal(1000000);
}

function usdToToken(address token, uint256 _usdAmount) public view returns (uint256) {
    address oracleContract = oracleContracts[token];
    return IOracle(oracleContract).convertUsdBalanceDecimalToTokenDecimal(_usdAmount);
}

Each stake stores totalValueStakeUsd = tokenToUsd(novaxToken, amount). Later, withdraw(_stakeId) computes the number of NovaX tokens to return by inverting this via usdToToken, again delegating to the oracle.

The Oracle contract combines an internal swap price and, when configured with typeConvert = 2, a NovaX/USDT Pancake pair price. The critical function is:

function convertUsdBalanceDecimalToTokenDecimal(uint256 _balanceUsdDecimal)
    public
    view
    returns (uint256)
{
    uint256 tokenInternalSwap = convertInternalSwap(_balanceUsdDecimal, true);
    uint256 tokenPairConvert;
    if (pairAddress != address(0)) {
        (uint256 _reserve0, uint256 _reserve1, ) = IPancakePair(pairAddress).getReserves();
        (uint256 _tokenBalance, uint256 _stableBalance) =
            address(tokenAddress) < address(stableToken)
                ? (_reserve0, _reserve1)
                : (_reserve1, _reserve0);

        uint256 _minTokenAmount = (_balanceUsdDecimal * minTokenAmount) / 1000000;
        uint256 _maxTokenAmount = (_balanceUsdDecimal * maxTokenAmount) / 1000000;
        uint256 _tokenAmount    = (_balanceUsdDecimal * _tokenBalance) / _stableBalance;

        if (_tokenAmount < _minTokenAmount) {
            tokenPairConvert = _minTokenAmount;
        }

        if (_tokenAmount > _maxTokenAmount) {
            tokenPairConvert = _maxTokenAmount;
        }

        tokenPairConvert = _tokenAmount; // overwrites any min/max clamp
    }
    if (typeConvert == 1) {
        return tokenInternalSwap;
    } else if (typeConvert == 2) {
        return tokenPairConvert;
    } else {
        if (tokenPairConvert == 0 || tokenInternalSwap == 0) {
            return tokenPairConvert + tokenInternalSwap;
        } else {
            return (tokenPairConvert + tokenInternalSwap) / 2;
        }
    }
}

Because the final assignment tokenPairConvert = _tokenAmount overwrites any clamping, the oracle’s pair‑based output can be pushed arbitrarily high or low within a single transaction by manipulating the NovaX/USDT reserves.

3. Vulnerability Analysis & Root Cause Summary

The root cause is a price‑oracle logic bug combined with same‑transaction stake and withdraw in TokenStake:

  • TokenStake encodes each stake’s USD value using the oracle’s NovaX/USDT price at deposit time, and later uses the oracle again at withdraw time to convert that USD value back into NovaX.
  • Oracle’s convertUsdBalanceDecimalToTokenDecimal intends to clamp the pair‑derived token amount between minTokenAmount and maxTokenAmount, but then unconditionally overwrites the clamped value with the raw _tokenAmount computed from AMM reserves.
  • When Oracle is configured with typeConvert = 2 (pair‑only), TokenStake’s tokenToUsd/usdToToken effectively compute:
    • stakeValueUsd = 1e6 * A / k_deposit
    • withdrawTokenValue = stakeValueUsd * k_withdraw / 1e6 = A * (k_withdraw / k_deposit) where k_* is the oracle’s tokens‑per‑USD output based on pair reserves.
  • A helper contract can manipulate the NovaX/USDT pair between the “deposit” and “withdraw” steps of a single transaction so that k_withdraw / k_deposit > 1, causing TokenStake to return more NovaX than was staked.

In short, TokenStake trusts an unbounded, manipulation‑prone spot price from a PancakeSwap pair as a stable conversion factor between stake and withdraw, enabling an over‑withdrawal of NovaX in a single transaction.

4. Detailed Root Cause Analysis

4.1 Stake and Withdraw Accounting

When a user stakes NovaX via TokenStake, the contract:

  1. Looks up the stake pool to obtain stakeToken (NovaX) and its parameters.
  2. Calls tokenToUsd(stakeToken, _stakeValue) to compute a USD‑denominated amount.
  3. Stores both the raw token amount and the derived USD value in the StakedToken struct.

The relevant functions and withdraw logic are:

function tokenToUsd(address token, uint256 _tokenAmount) public view returns (uint256) {
    address oracleContract = oracleContracts[token];
    return (1000000 * _tokenAmount)
        / IOracle(oracleContract).convertUsdBalanceDecimalToTokenDecimal(1000000);
}

function usdToToken(address token, uint256 _usdAmount) public view returns (uint256) {
    address oracleContract = oracleContracts[token];
    return IOracle(oracleContract).convertUsdBalanceDecimalToTokenDecimal(_usdAmount);
}

function withdraw(uint256 _stakeId) public override lock {
    StakedToken memory _stakedUserToken = stakedToken[_stakeId];
    require(_stakedUserToken.userAddress == msg.sender, "TS:O");
    require(!_stakedUserToken.isWithdraw, "TS:W");
    require(canNotWithdraw[_stakeId] == 0, "TS:C");
    require(_stakedUserToken.unlockTime <= block.timestamp, "TS:T");

    claimInternal(_stakeId);

    StakeTokenPools memory stakeTokenPool = stakeTokenPools[_stakedUserToken.poolId];
    address stakeToken = stakeTokenPool.stakeToken;
    uint256 withdrawTokenValue =
        usdToToken(stakeToken, _stakedUserToken.totalValueStakeUsd);
    require(IERC20(stakeToken).balanceOf(address(this)) >= withdrawTokenValue, "TS:E");
    require(IERC20(stakeToken).transfer(_stakedUserToken.userAddress, withdrawTokenValue), "TS:U");
    // … update accounting …
}

Let A be the number of NovaX tokens staked. Define k(P) as the oracle’s NovaX‑per‑USD output at price state P. At stake time:

  • TokenStake calls convertUsdBalanceDecimalToTokenDecimal(1,000,000) using the current pair reserves, obtaining k_deposit.
  • It stores stakeValueUsd = 1,000,000 * A / k_deposit.

At withdraw time:

  • It calls the oracle again with _balanceUsdDecimal = stakeValueUsd, using the (possibly manipulated) current reserves to get k_withdraw.
  • It returns withdrawTokenValue = stakeValueUsd * k_withdraw / 1,000,000 = A * (k_withdraw / k_deposit).

If the adversary can increase k_withdraw relative to k_deposit within the same transaction, withdrawTokenValue strictly exceeds A, violating the intended invariant:

For each stakeId, withdraw(_stakeId) must not return more NovaX (valued via a trusted, bounded price source) than corresponds to the stake’s recorded USD value based on the deposit‑time price.

4.2 Oracle Min/Max Clamp Bug

Oracle attempts to guard against extreme prices by defining minTokenAmount and maxTokenAmount and computing bounds on the token amount for a given USD value. However, the implementation:

  1. Computes _minTokenAmount, _maxTokenAmount, and _tokenAmount from NovaX/USDT reserves.
  2. Conditionally sets tokenPairConvert = _minTokenAmount or _maxTokenAmount if _tokenAmount is below/above the bounds.
  3. Unconditionally executes tokenPairConvert = _tokenAmount.

This last assignment nullifies the min/max clamp. When typeConvert == 2, the return value is tokenPairConvert, which is always equal to the raw _tokenAmount derived from the current reserves. Thus:

  • The oracle is effectively a simple spot‑price converter based solely on pair reserves.
  • Any intra‑transaction manipulation of the NovaX/USDT pair directly and unboundedly affects the conversion rate used by TokenStake.

4.3 Exploit Transaction Behavior

The exploit transaction 0xb1ad1188d6…e012 has:

  • Sender / gas payer: EOA 0x81ca56b6….
  • To: helper contract 0x42bc5a77….
  • Value: 0 BNB, gas cost 6,936,030,000,000,000 wei (0.00693603 BNB) per native_balance_deltas.

The callTracer trace shows a sequence of USDT and NovaX movements through PancakeRouter and the NovaX/USDT pair, followed by TokenStake calls and final profit transfers. A shortened excerpt:

{
  "calls": [
    {
      "from": "0x7efaef62…",
      "to":   "0x55d39832…",
      "type": "CALL",
      "input": "0xa9059cbb… -> transfer USDT to 0x42bc5a77…"
    },
    {
      "from": "0x42bc5a77…",
      "to":   "0x55d39832…",
      "type": "CALL",
      "input": "0x095ea7b3… -> approve USDT for PancakeRouter"
    },
    {
      "from": "0x10ed43c7…",
      "to":   "0x05a911cc…",
      "type": "CALL",
      "input": "0x23b872dd/0x022c0d9f… -> move USDT into NovaX/USDT pair and execute swap"
    },
    {
      "from": "0x42bc5a77…",
      "to":   "0x55c9eebd…",
      "type": "CALL",
      "input": "0x7b0472f0… -> TokenStake.stake(poolId, amount)"
    },
    {
      "from": "0x42bc5a77…",
      "to":   "0x55c9eebd…",
      "type": "CALL",
      "input": "0x2e1a7d4d… -> TokenStake.withdraw(stakeId)"
    },
    {
      "from": "0x42bc5a77…",
      "to":   "0x55d39832…",
      "type": "CALL",
      "input": "0xa9059cbb… -> transfer USDT to 0x80773808…"
    }
  ],
  "from": "0x81ca56b6…",
  "to":   "0x42bc5a77…",
  "input": "0xe235aeca… (helper entrypoint)"
}

The corresponding balance_diff.json confirms the net asset movements:

{
  "native_balance_deltas": [
    {
      "address": "0x81ca56b6…",
      "delta_wei": "-6936030000000000"
    }
  ],
  "erc20_balance_deltas": [
    {
      "token":  "0x55d39832…",          "contract_name": "BEP20USDT",
      "holder": "0x05a911cc…",          "delta": "-26476325961551338835137"
    },
    {
      "token":  "0x55d39832…",
      "holder": "0x80773808…",          "delta": "24971812420929473237347"
    },
    {
      "token":  "0xb800aff8…",          "contract_name": "NovaXToken",
      "holder": "0x55c9eebd…",          "delta": "-84319204255820354933135"
    },
    {
      "token":  "0xb800aff8…",
      "holder": "0xb45fe3a6…",          "delta": "66251109303267001599526"
    }
  ]
}

These diffs show:

  • TokenStake loses 84,319,204,255,820,354,933,135 NovaX tokens.
  • The NovaX/USDT pair loses 26,476,325,961,551,338,835,137 USDT.
  • EOA 0xb45fe3a6… gains 66,251,109,303,267,001,599,526 NovaX.
  • EOA 0x80773808… gains 24,971,812,420,929,473,237,347 USDT.

Oracle and InternalSwap are only read during the transaction (no state writes), so the exploit relies purely on AMM reserve manipulation plus the oracle’s logic bug, not on misconfigured InternalSwap values during the tx.

4.4 ACT Opportunity Characterization

All steps of the exploit are available to any unprivileged on‑chain actor:

  • TokenStake’s stake and withdraw functions are publicly callable and do not require special roles.
  • Oracle’s configuration (pairAddress, typeConvert = 2, min/max values) is set beforehand and used as‑is by any caller.
  • PancakeRouter and the NovaX/USDT pair are standard public DeFi contracts; anyone can route swaps to manipulate reserves, subject only to liquidity and slippage.
  • The helper contract 0x42bc5a77… is adversary‑controlled but not privileged; any other adversary can deploy an identical contract or equivalent logic to reproduce the strategy.

The success predicate is a strictly positive profit in BEP20 USDT, measured on EOA 0x80773808…:

  • Before the exploit tx: 88,921,629,982,434,813,436 USDT units.
  • After the exploit tx: 25,060,734,050,911,908,050,783 USDT units.
  • Net gain: 24,971,812,420,929,473,237,347 USDT units, versus a fixed gas cost of 0.00693603 BNB paid by EOA 0x81ca56b6….

This constitutes a clear, reproducible ACT opportunity: any adversary with capital to manipulate the NovaX/USDT pair and pay gas can realize the same profit mechanism.

5. Adversary Flow Analysis

5.1 Adversary‑Related Cluster Accounts

The minimal adversary‑related cluster consists of:

  • 0x81ca56b6973ff63e3ff2b3f99cb6a6d211269e79 — EOA sender of both the helper deployment tx and the exploit tx; pays gas.
  • 0x42bc5a77985b2149a8fd085bf1d3fcda4eb71d53 — helper/orchestrator contract deployed by 0x81ca56…; its runtime bytecode hard‑codes TokenStake, NovaXToken, Oracle, PancakeRouter, NovaX/USDT, and profit recipient addresses.
  • 0x8077380811e1228e21cb9c157b4de54ca7cefcee — EOA recipient of USDT profit; runtime bytecode at block 41116210 is empty (result: "0x"), confirming it is an EOA.
  • 0xb45fe3a6db227a05bbe43c0786d69bf3da3bedc3 — EOA recipient of NovaX profit; also has empty runtime bytecode.

5.2 Lifecycle Stages

  1. Helper deployment (priming stage)

    • Tx: 0x5b483b45164063672e84e8fa69f920d340e8062451aeb4f0b1741908d0256e1a in block 41116150.
    • Sender: 0x81ca56… deploying 0x42bc5a77….
    • Runtime bytecode analysis shows hard‑coded references to TokenStake, NovaXToken, Oracle, PancakeRouter, NovaX/USDT pair, USDT pair, and the USDT profit address 0x8077…. This establishes 0x42bc5a77… as an adversary‑controlled orchestrator dedicated to the exploit.
  2. Single‑tx price manipulation and over‑withdrawal (exploit stage)

    • Tx: 0xb1ad1188d620746e2e64785307a7aacf2e8dbda4a33061a4f2fbc9721048e012 in block 41116211.
    • Steps (from callTracer and decoded ABIs):
      • USDT is transferred from the external USDT/BUSD pair 0x7efaef62… to 0x42bc5a77…, then approved and routed via PancakeRouter 0x10ed43c7… into the NovaX/USDT pair 0x05a911cc…, skewing its reserves and effective NovaX/USDT price.
      • With the manipulated price in place, 0x42bc5a77… calls TokenStake.stake(poolId, amount) on 0x55c9eebd…, causing TokenStake to create a new stake with totalValueStakeUsd computed at the “cheap” deposit‑time oracle price.
      • Immediately afterward in the same transaction, 0x42bc5a77… calls TokenStake.withdraw(stakeId) for the freshly created stake. TokenStake calls Oracle again, now observing a different NovaX/USDT reserve ratio, and computes a larger withdrawTokenValue = A * (k_withdraw / k_deposit).
      • TokenStake transfers withdrawTokenValue NovaX to the orchestrator/beneficiary, while the NovaX/USDT pair and TokenStake absorb the opposing legs of the manipulated swaps.
      • Finally, 0x42bc5a77… routes the excess USDT to EOA 0x8077… and the excess NovaX to EOA 0xb45f…, crystallizing profit.

No privileged roles or governance operations are used; all calls are standard external function calls available to any account.

6. Impact & Losses

From balance_diff.json for tx 0xb1ad…e012:

  • NovaX losses (victim side)

    • TokenStake 0x55c9eebd… loses 84,319,204,255,820,354,933,135 NovaX units.
    • The NovaX/USDT pair 0x05a911cc… gains 18,068,094,952,553,353,333,609 NovaX units (part of the manipulated swap path).
    • EOA 0xb45fe3a6… gains 66,251,109,303,267,001,599,526 NovaX units as direct profit.
  • USDT losses (liquidity side)

    • NovaX/USDT pair 0x05a911cc… loses 26,476,325,961,551,338,835,137 USDT units.
    • External USDT/BUSD pair 0x7efaef62… gains 1,504,513,540,621,865,597,790 USDT units.
    • EOA 0x80773808… gains 24,971,812,420,929,473,237,347 USDT units as direct profit.
  • Gas cost

    • Gas payer 0x81ca56… pays exactly 6,936,030,000,000,000 wei (0.00693603 BNB).

Defining the reference asset as BEP20 USDT alone, the adversary’s portfolio change on 0x8077… is:

  • Before: 88.921629982434813436 USDT.
  • After: 25,060.734050911908050783 USDT.
  • Delta: +24,971.812420929473237347 USDT.

Even after accounting for any reasonable USDT value of 0.00693603 BNB gas, the net profit in USDT is strictly positive. Additional upside from the NovaX position on 0xb45f… is not required to prove profitability.

7. References

  • Seed transaction metadata and traces

    • Tx 0xb1ad1188d620746e2e64785307a7aacf2e8dbda4a33061a4f2fbc9721048e012, chainid 56 (BSC).
    • Metadata, receipt, and cast trace: artifacts/root_cause/seed/56/0xb1ad…e012/metadata.json, trace.cast.log.
    • CallTracer and prestate traces:
      artifacts/root_cause/data_collector/iter_2/tx/56/0xb1ad…e012/debug_trace_callTracer.json,
      artifacts/root_cause/data_collector/iter_1/tx/56/0xb1ad…e012/debug_trace_prestate_tracer.json.
  • Balance diffs and profit computation

    • artifacts/root_cause/seed/56/0xb1ad…e012/balance_diff.json (NovaX and USDT deltas, native BNB gas cost).
  • Contract source code (verified)

    • NovaXToken 0xb800aff8391abacdeb0199ab9cebf63771fcf491.
    • TokenStake 0x55c9eebd368873494c7d06a4900e8f5674b11bd2 (includes tokenToUsd, usdToToken, and withdraw).
    • Oracle 0xaeb77ff298970a7fb6dc6f5c4a7f02426db814ea (includes convertUsdBalanceDecimalToTokenDecimal).
    • InternalSwap 0xe76109198ffcd88e16e95e4cc0bafe01f5756c6a.
    • PancakeRouter 0x10ed43c718714eb63d5aa57b78b54704e256024e.
    • NovaX/USDT pair 0x05a911cc7b9e1481a795ba548049285715a6c7bc and USDT/BUSD pair 0x7efaef62fddcca950418312c6c91aef321375a00.
    • All under artifacts/root_cause/data_collector/iter_1/contract/56/*/source/.
  • Helper and adversary account evidence

    • Helper deployment tx and txlist traces for 0x81ca56… and 0x42bc5a77…:
      artifacts/root_cause/data_collector/iter_2/address/56/0x81ca56…/txlist_alt_41115000_41120000.json_traces.json.
    • Runtime bytecode for helper and profit EOAs:
      artifacts/root_cause/data_collector/iter_2/contract/56/0x42bc5a77…/runtime_bytecode.json,
      …/0x80773808…/runtime_bytecode.json,
      …/0xb45fe3a6…/runtime_bytecode.json.
  • Root cause analyzer artifacts

    • Final analyzer reasoning and ACT gap analysis:
      artifacts/root_cause/root_cause_analyzer/iter_3/current_analysis_result.json.