This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0xb1ad1188d620746e2e64785307a7aacf2e8dbda4a33061a4f2fbc9721048e0120x55c9eebd368873494c7d06a4900e8f5674b11bd2BSCOn 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.
The NovaX ecosystem on BSC includes:
0xb800aff8…) — an ERC‑20-like token with standard balances and taxes.0x55c9eebd…) — a staking contract that accepts NovaX deposits into several pools and tracks each stake’s value in an internal “USD” unit.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.0xe7610919…) — a contract recording an owner‑set NovaX/USDT price used by the oracle as one of its sources.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.
The root cause is a price‑oracle logic bug combined with same‑transaction stake and withdraw in TokenStake:
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.typeConvert = 2 (pair‑only), TokenStake’s tokenToUsd/usdToToken effectively compute:
stakeValueUsd = 1e6 * A / k_depositwithdrawTokenValue = stakeValueUsd * k_withdraw / 1e6 = A * (k_withdraw / k_deposit)
where k_* is the oracle’s tokens‑per‑USD output based on pair reserves.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.
When a user stakes NovaX via TokenStake, the contract:
stakeToken (NovaX) and its parameters.tokenToUsd(stakeToken, _stakeValue) to compute a USD‑denominated amount.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:
convertUsdBalanceDecimalToTokenDecimal(1,000,000) using the current pair reserves, obtaining k_deposit.stakeValueUsd = 1,000,000 * A / k_deposit.At withdraw time:
_balanceUsdDecimal = stakeValueUsd, using the (possibly manipulated) current reserves to get k_withdraw.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.
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:
_minTokenAmount, _maxTokenAmount, and _tokenAmount from NovaX/USDT reserves.tokenPairConvert = _minTokenAmount or _maxTokenAmount if _tokenAmount is below/above the bounds.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 exploit transaction 0xb1ad1188d6…e012 has:
0x81ca56b6….0x42bc5a77….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:
0xb45fe3a6… gains 66,251,109,303,267,001,599,526 NovaX.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.
All steps of the exploit are available to any unprivileged on‑chain actor:
stake and withdraw functions are publicly callable and do not require special roles.pairAddress, typeConvert = 2, min/max values) is set beforehand and used as‑is by any caller.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…:
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.
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.Helper deployment (priming stage)
0x5b483b45164063672e84e8fa69f920d340e8062451aeb4f0b1741908d0256e1a in block 41116150.0x81ca56… deploying 0x42bc5a77….0x8077…. This establishes 0x42bc5a77… as an adversary‑controlled orchestrator dedicated to the exploit.Single‑tx price manipulation and over‑withdrawal (exploit stage)
0xb1ad1188d620746e2e64785307a7aacf2e8dbda4a33061a4f2fbc9721048e012 in block 41116211.0x7efaef62… to 0x42bc5a77…, then approved and routed via PancakeRouter 0x10ed43c7… into the NovaX/USDT pair 0x05a911cc…, skewing its reserves and effective NovaX/USDT price.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.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).withdrawTokenValue NovaX to the orchestrator/beneficiary, while the NovaX/USDT pair and TokenStake absorb the opposing legs of the manipulated swaps.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.
From balance_diff.json for tx 0xb1ad…e012:
NovaX losses (victim side)
0x55c9eebd… loses 84,319,204,255,820,354,933,135 NovaX units.0x05a911cc… gains 18,068,094,952,553,353,333,609 NovaX units (part of the manipulated swap path).0xb45fe3a6… gains 66,251,109,303,267,001,599,526 NovaX units as direct profit.USDT losses (liquidity side)
0x05a911cc… loses 26,476,325,961,551,338,835,137 USDT units.0x7efaef62… gains 1,504,513,540,621,865,597,790 USDT units.0x80773808… gains 24,971,812,420,929,473,237,347 USDT units as direct profit.Gas cost
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:
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.
Seed transaction metadata and traces
0xb1ad1188d620746e2e64785307a7aacf2e8dbda4a33061a4f2fbc9721048e012, chainid 56 (BSC).artifacts/root_cause/seed/56/0xb1ad…e012/metadata.json, trace.cast.log.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)
0xb800aff8391abacdeb0199ab9cebf63771fcf491.0x55c9eebd368873494c7d06a4900e8f5674b11bd2 (includes tokenToUsd, usdToToken, and withdraw).0xaeb77ff298970a7fb6dc6f5c4a7f02426db814ea (includes convertUsdBalanceDecimalToTokenDecimal).0xe76109198ffcd88e16e95e4cc0bafe01f5756c6a.0x10ed43c718714eb63d5aa57b78b54704e256024e.0x05a911cc7b9e1481a795ba548049285715a6c7bc and USDT/BUSD pair 0x7efaef62fddcca950418312c6c91aef321375a00.artifacts/root_cause/data_collector/iter_1/contract/56/*/source/.Helper and adversary account evidence
0x81ca56… and 0x42bc5a77…:artifacts/root_cause/data_collector/iter_2/address/56/0x81ca56…/txlist_alt_41115000_41120000.json_traces.json.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
artifacts/root_cause/root_cause_analyzer/iter_3/current_analysis_result.json.