Calculated from recorded token losses using historical USD prices at the incident time.
0xcf834aff4de9992f5da9c443600dad9c6277a8a00de5007842fece51564992db0xde46fcf6ab7559e4355b8ee3d7fba0f2730cddd8Ethereum0xae2c7af5fc2ddf45e6250a4c5495e61afc7acf50EthereumThe seed exploit transaction 0xcf834aff4de9992f5da9c443600dad9c6277a8a00de5007842fece51564992db drained ETH from MainPool on Ethereum mainnet. The attacker used a Balancer flash loan to buy ADC, joined MainPool as a fresh player, directly called calcStepIncome(uint256,uint256,uint8) with attacker-chosen parameters, and then withdrew the fabricated income in the same transaction.
The root cause was a public accounting function in MainPool at 0xde46fcf6ab7559e4355b8ee3d7fba0f2730cddd8. calcStepIncome trusted caller-supplied pid_, value_, and dividendAccount_, then increased the selected player's stepIncome and totalSettled while reducing pool balance. Because withdraw() later treated those fields as legitimate settled income, any active player with enough burnable ADC could convert arbitrary forged accounting into real ETH outflow.
MainPool tracks each player per round and pays ETH based on ledger fields such as staticIncome, dynamicIncome, stepIncome, and totalSettled. The round in scope was round , and the auditor-established pre-state at block had .
319138640mainPoolWithdrawBalance(3) = 21850000000000000000Ticket at 0xae2c7af5fc2ddf45e6250a4c5495e61afc7acf50 is the protocol component that sells and burns ADC. Its buyADC() path is public, and both joinGame() and withdraw() rely on Ticket.calDeductionADC(...) and ADC burns rather than privileged authorization. That means ADC is a permissionlessly obtainable consumable, not a security boundary.
The exploit was therefore ACT: an unprivileged actor could fund itself, acquire ADC on-chain, register as a player, and call the vulnerable function without any privileged key, signature, or off-chain secret.
The vulnerability class is an unauthenticated public state-mutator inside critical reward accounting. MainPool.calcStepIncome(uint256 pid_, uint256 value_, uint8 dividendAccount_) was intended to be used by internal protocol flows, but it was exposed as a public function. The function computes spIncome = (value_ * dividendAccount_) / 100 from raw caller input, caps it only by playBiggertReward, then directly writes that amount into plyr[RID][pid_].stepIncome and plyr[RID][pid_].totalSettled. It also subtracts the same amount from mainPoolBalance[RID].
The violated invariant is straightforward: only protocol-controlled reward logic should be able to increase a player's withdrawable income, and any increase should be causally tied to a real protocol event. That invariant was broken at the public calcStepIncome entrypoint. Once the attacker became an active player and supplied the current pool withdrawable amount as value_ with dividendAccount_ = 100, the contract credited the attacker with the full available pool as settled income. withdraw() then realized that forged bookkeeping as ETH.
Representative code from the verified MainPool source:
function calcStepIncome(uint256 pid_,uint256 value_,uint8 dividendAccount_) public{
uint256 spIncome = (value_ * dividendAccount_) / 100;
if (plyr[RID][pid_].totalSettled >= playBiggertReward[RID][pid_]) {
return;
}
if (plyr[RID][pid_].totalSettled + spIncome > playBiggertReward[RID][pid_]) {
spIncome = playBiggertReward[RID][pid_] - plyr[RID][pid_].totalSettled;
}
plyr[RID][pid_].stepIncome += spIncome;
plyr[RID][pid_].totalSettled += spIncome;
mainPoolBalance[RID] -= spIncome;
}
The seed call trace shows the exploit path end to end. Inside the flash-loan-funded helper flow, the attacker first called Ticket.buyADC{value: 3600000000000000000}() and received ADC. The trace then shows MainPool.joinGame{value: 15000000000000000000}(0x24A0c66f185874B251Eb70BEE2C2e35E39848419), which registered the helper as a new active player and increased round-3 withdrawal capacity.
Immediately after joining, the attacker queried the victim state:
MainPool::mainPoolWithdrawBalance(3) -> 36099999999999999900
MainPool::plyrID(0x72052F124841A8158D3d5B6d3D54C9Fe0e25e929) -> 529
MainPool::calcStepIncome(529, 36099999999999999900, 100)
MainPool::withdraw()
The same trace records the crucial storage changes during calcStepIncome. The slot representing the player's settled step income was increased to 0x...1f4fcf6bbe799ff9c, while mainPoolBalance was reduced to 100, showing that the public call forged nearly the entire withdrawable round balance as attacker-owned income.
withdraw() then consumed ADC through Ticket.calDeductionADC(36099999999999999900, false) and transferred 36099999999999999900 wei to the attacker helper. This is why the protocol's burn checks did not stop the exploit: the attacker could permissionlessly buy enough ADC beforehand, so the only real gate was active-player status.
The balance diff for the seed transaction confirms the economic effect. MainPool at 0xde46fcf6ab7559e4355b8ee3d7fba0f2730cddd8 lost 21099999999999999900 wei, the attack contract 0x2ffdce5f0c09a8ee3a568bc01f35894b2d77a6d6 ended with 17499999999999999900 wei, and the gas-paying EOA 0x24a0c66f185874b251eb70bee2c2e35e39848419 spent 40697248287783042 wei on gas. That yields a net attacker-cluster gain of 17.459302751712216858 ETH.
The adversary flow was a single-transaction ACT sequence:
0x24a0c66f185874b251eb70bee2c2e35e39848419 submitted the seed transaction and paid gas.0x2ffdce5f0c09a8ee3a568bc01f35894b2d77a6d6 received a public Balancer flash loan of 18.6 WETH and unwrapped it to ETH.0x72052f124841a8158d3d5b6d3d54c9fe0e25e929 bought ADC for 3.6 ETH through Ticket.buyADC().MainPool with 15 ETH, becoming player 529 in round 3.mainPoolWithdrawBalance(3) = 36099999999999999900 and passed that exact value into calcStepIncome(529, 36099999999999999900, 100).withdraw(), received 36.099999999999999900 ETH, repaid the flash loan, and left 17.499999999999999900 ETH profit in the attack contract.This flow required no privileged role and no reuse of attacker-specific rights. The historical addresses above describe the observed transaction, but the exploitability does not depend on those addresses; any fresh address that can buy ADC and join the pool can reproduce the sequence.
The measurable victim loss in the seed exploit transaction was 21099999999999999900 wei of ETH, recorded as:
{
"token_symbol": "ETH",
"amount": "21099999999999999900",
"decimal": 18
}
The victim protocol component was MainPool, and the exploit drained the active round's withdrawable balance down to a residual boundary observed in trace storage changes and subsequent withdrawal behavior. The attacker cluster realized 17.459302751712216858 ETH net profit after gas, while the remainder of the extracted value funded flash-loan repayment and attack execution.
0xcf834aff4de9992f5da9c443600dad9c6277a8a00de5007842fece51564992db0xefbb16052f55b27678b0ba838f2f16c37643ee5c040faacf3649cad5b76fe7cb0xde46fcf6ab7559e4355b8ee3d7fba0f2730cddd80xae2c7af5fc2ddf45e6250a4c5495e61afc7acf50/workspace/session/artifacts/collector/seed/1/0xcf834aff4de9992f5da9c443600dad9c6277a8a00de5007842fece51564992db/metadata.json/workspace/session/artifacts/collector/seed/1/0xcf834aff4de9992f5da9c443600dad9c6277a8a00de5007842fece51564992db/trace.cast.log/workspace/session/artifacts/collector/seed/1/0xcf834aff4de9992f5da9c443600dad9c6277a8a00de5007842fece51564992db/balance_diff.json