Reproduced Exploit
GAIN (GainOS) Exploit — Path-Dependent `balanceOf` Rebase Bug Drains the AMM Reserve
GAIN is a "gamified rebasoor." Every holder is secretly placed on one of two teams — SideA or SideB — and a holder's reported balance is computed with a different divisor depending on which side they are on:
Loss
~6.4329 WETH (≈ 18 ETH per the PoC header / SlowMist; the on-chain fork drains the single GAIN/WETH pool of 6…
Chain
Ethereum
Category
Access Control
Date
Feb 2024
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2024-02-GAIN_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/GAIN_exp.sol.
Vulnerability classes: vuln/logic/state-update · vuln/logic/incorrect-state-transition
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted standalone). Full verbose trace: output.txt. Verified vulnerable source: GAIN.sol · victim AMM: UniswapV2Pair.sol.
Key info#
| Loss | ~6.4329 WETH (≈ 18 ETH per the PoC header / SlowMist; the on-chain fork drains the single GAIN/WETH pool of 6.433 of its 6.599 WETH) |
| Vulnerable contract | GAIN (GainOS) — 0xdE59b88abEFA5e6C8aA6D742EeE0f887Dab136ac |
| Victim pool | GAIN/WETH Uniswap V2 pair — 0x31d80EA33271891986D873B397d849A92EF49255 |
| Flash-loan source | Uniswap V3 USDT/WETH pool — 0xc7bBeC68d12a0d1830360F8Ec58fA599bA1b0e9b |
| Attacker EOA | 0x0000000f95c09138dfea7d9bcf3478fc2e13dcab |
| Attacker contract | 0x9a4b9fd32054bfe2099f2a0db24932a4d5f38d0f |
| Attack tx | 0x7acc896b8d82874c67127ff3359d7437a15fdb4229ed83da00da1f4d8370764e |
| Chain / fork block / date | Ethereum mainnet / forked at 19,277,619 (tx in 19,277,620) / Feb 2024 |
| Compiler | GAIN: Solidity v0.8.7, optimizer 200 runs · Pair: v0.5.16 |
| Bug class | Broken AMM invariant via a token whose balanceOf is path-dependent / manipulable (rebase "side" accounting) combined with permissionless skim()/sync() |
TL;DR#
GAIN is a "gamified rebasoor." Every holder is secretly placed on one of two teams — SideA
or SideB — and a holder's reported balance is computed with a different divisor depending on
which side they are on:
// GAIN.sol:758-768
function balanceOf(address who) public view override returns (uint256) {
if (... _children_of_gainos[who] == sideA) return _gonBalances[who].div(TOTAL_GONS.div(_sideA));
else if (... _children_of_gainos[who] == sideB) return _gonBalances[who].div(TOTAL_GONS.div(_sideB));
else return _gonBalances[who].div(_gonsPerFragment); // default divisor
}
The three divisors are not equal. _sideA and _sideB are initialized to the original
INITIAL_FRAGMENTS_SUPPLY and only ever grow by the per-side reward yield, while the default
divisor _gonsPerFragment = TOTAL_GONS / _totalSupply tracks the (much larger, rebased)
_totalSupply. At the fork block _sideA / _totalSupply ≈ 0.654, so the same gon balance reports
~35 % less when the account is on SideA than when it is side-less.
The fatal part: an account's side is assigned the first time it receives GAIN while not fee-exempt
(GAIN.sol:898-908). Nothing exempts the AMM pair. So an
attacker can force the pair onto a side by simply sending it dust GAIN, instantly shrinking
balanceOf(pair) — without any GAIN actually leaving the pool. They then call the pair's
permissionless skim() + sync() to commit that fake, smaller balance as the pool's new GAIN
reserve. The constant product k collapses and the marginal price of GAIN explodes; the attacker
sells a trivial amount of GAIN and walks away with almost the entire WETH reserve.
The whole thing is wrapped in a 0.1 WETH Uniswap-V3 flash loan, so the attacker risks ~nothing.
Background — what GAIN does#
GAIN (source) is an Ampleforth-style rebase ("gons") token with a
gamification layer:
- Gons accounting. Internally every account holds
_gonBalances[who](a huge fixed integer). The public balance isgons / divisor.TOTAL_GONSis the fixedMAX_UINT256 - (MAX_UINT256 % INITIAL_FRAGMENTS_SUPPLY)(:600). - Rebase ("snap").
snap()periodically inflates_totalSupplyand recomputes_gonsPerFragment = TOTAL_GONS / _totalSupply(:723). Over the token's life_totalSupplyhad rebased far above the initialINITIAL_FRAGMENTS_SUPPLY. - Two "sides". Each new, non-exempt holder is deterministically toggled onto
SideAorSideBvia the_rejoiceflag on their first inbound transfer (:898-908)._sideAand_sideBare separate circulating-supply counters, each initialized to_totalSupplyat construction (:673-674) and grown only bysnap().
The pool at the fork block (read from the trace's getReserves/balanceOf calls):
| Quantity | Value |
|---|---|
Pair token0 | WETH (reserve0) |
Pair token1 | GAIN (reserve1) |
Pool WETH reserve (reserve0) | 6,598,936,314,221,857,031 wei ≈ 6.5989 WETH ← the prize |
Pool GAIN reserve, side-less (balanceOf(pair)) | 180,049,177,796,806,821,424,078,518 ≈ 1.80e26 |
_sideA / _totalSupply (implied by the trace) | ≈ 0.6544 |
The single fact that makes this exploitable: balanceOf(pair) is not anchored to anything the
pair controls — it depends on a divisor the attacker can change for free by changing the pair's
"side".
The vulnerable code#
1. balanceOf divisor is path-dependent#
// GAIN.sol:758-768
function balanceOf(address who) public view override returns (uint256) {
if (keccak256(abi.encodePacked(_children_of_gainos[who])) == keccak256(abi.encodePacked(sideA)))
{
return _gonBalances[who].div(TOTAL_GONS.div(_sideA)); // divisor A
} else if (keccak256(abi.encodePacked(_children_of_gainos[who])) == keccak256(abi.encodePacked(sideB))) {
return _gonBalances[who].div(TOTAL_GONS.div(_sideB)); // divisor B
} else {
return _gonBalances[who].div(_gonsPerFragment); // default divisor
}
}
_gonsPerFragment = TOTAL_GONS / _totalSupply. Divisor A is TOTAL_GONS / _sideA. Because
_sideA < _totalSupply, divisor A > the default divisor, so the same _gonBalances[who]
reports a smaller balance once who is on SideA. The reported balance of the pair therefore
changes the instant the pair is moved onto a side — even though the pair's gon balance has not
changed.
2. Side assignment is automatic on inbound transfer — and the pair is not exempt#
// GAIN.sol:898-908 (inside _transferFrom, after balances are updated)
if (keccak256(abi.encodePacked(_children_of_gainos[recipient])) != keccak256(abi.encodePacked(sideA)) &&
keccak256(abi.encodePacked(_children_of_gainos[recipient])) != keccak256(abi.encodePacked(sideB)) && !_isFeeExempt[recipient])
{
if (_rejoice == true) { _children_of_gainos[recipient] = sideA; _rejoice = false; }
else { _children_of_gainos[recipient] = sideB; _rejoice = true; }
}
The pair address is a normal, non-exempt recipient. Sending it any GAIN assigns it a side. In
the live trace the side-assignment write is visible as the storage value
0x53696465410000…0a ("SideA", short-string length 5) at
output.txt:1616 for the first non-exempt recipient.
3. The pair commits the fake balance with permissionless skim() + sync()#
The Uniswap-V2 pair reads balanceOf(pair) at face value and writes it straight into its reserves:
// UniswapV2Pair.sol:484-495
function skim(address to) external lock { // pushes (balance - reserve) out — here 0 for GAIN
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
function sync() external lock { // FORCES reserves := current balances
_update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1);
}
sync() blindly trusts whatever GAIN.balanceOf(pair) now returns. Since GAIN's balanceOf just
shrank (the pair is on a "side"), sync() writes a much smaller GAIN reserve while the WETH
reserve is untouched. The pair's own K-guard in swap()
(UniswapV2Pair.sol:474-478) only
protects the swap that calls it — it cannot defend against sync() rewriting reserves out from
under it.
Root cause — why it was possible#
A Uniswap-V2 pair assumes one thing about its tokens: balanceOf is a faithful, monotone record
of tokens actually held. GAIN breaks that assumption in the most dangerous way:
GAIN.balanceOf(account)is not a function of how many tokens the account holds — it is a function of how many gons it holds divided by a divisor that the caller can change for free by flipping the account's "side". No tokens move; the number just gets smaller.
Concretely, four design decisions compose into a critical bug:
- Path-dependent balance. Three different divisors (
_gonsPerFragment,TOTAL_GONS/_sideA,TOTAL_GONS/_sideB) for the same gon balance. They diverged because_sideA/_sideBwere seeded at the tiny initial supply while_totalSupplyrebased upward — so being on a side reduces your reported balance by ~35 %+. - Free, attacker-controlled side assignment. Any inbound GAIN transfer assigns a side to a side-less, non-exempt recipient. The AMM pair is neither side-locked nor fee-exempt, so the attacker assigns the pair a side by sending it dust.
- Permissionless
skim()/sync(). Anyone can force the pair to re-read and commitbalanceOf(pair). The attacker calls them right after shrinking the pair's reported balance, locking the fake (smaller) reserve in. (sync()is also wrapped inside the token itself viamanualSync()/ the snap path, but the standard pair entry points suffice.) - No oracle / no balance sanity check. The pair's reserve update has no TWAP, no bound on
single-step reserve change, and trusts the token's
balanceOfimplicitly.
The intended "gamification" math (assigning sides, separate side supplies) was never meant to feed an AMM, but because the pair holds GAIN like any other holder, the side machinery silently corrupts the pool's accounting.
Preconditions#
- A GAIN/WETH Uniswap-V2 pool exists and holds real WETH liquidity (6.599 WETH at the fork block).
_sideA/_sideBhave diverged from_totalSupply(true after any rebase history) so that being on a side meaningfully shrinks the reported balance. (If all three divisors were equal the trick would be a no-op.)- The pair is not fee-exempt and has not yet been assigned a side — true for the live pair.
- Tiny working capital to seed the manipulation and pay the flash-loan fee. The PoC borrows just
0.1 WETH from a Uniswap-V3 flash and repays
0.1 WETH + 0.0001 WETHfee, netting 6.4329 WETH.
Attack walkthrough (with on-chain numbers from the trace)#
The pair is token0 = WETH (reserve0), token1 = GAIN (reserve1). All figures are taken directly
from the Sync/Swap events and balanceOf static-calls in
output.txt. The exploit body is
exploitGAIN(), invoked inside the flash callback
uniswapV3FlashCallback.
| # | Step (trace ref) | Pool WETH reserve | balanceOf(pair) GAIN | Effect |
|---|---|---|---|---|
| 0 | Initial (:1593-1622) | 6.5989e18 | 1.8005e26 (side-less) | Honest pool. |
| 1 | Flash-borrow 0.1 WETH from V3, transfer to pair, swap(0, 100000) buy 100,000 GAIN to attacker (:1604-1629) | 6.5989e18 (unchanged: WETH was donated, not swapped out) | 1.8005e26 | Attacker gets dust GAIN; attacker assigned SideA (storage write at :1616). |
| 2 | Send 100 GAIN to pair → pair assigned a side; skim + sync (:1630-1663) | 6.5989e18 | 1.1782e26 (−35 %) | Pair now on a side → balanceOf(pair) divided by the larger side-divisor; sync() writes the shrunken reserve. No GAIN left the pool. |
| 3 | Send 188 GAIN to pair + a router micro-sell + skim + sync (:1664-1741) | 6.5989e18 | 8.1688e23 (−99.5 %) | Reported GAIN reserve collapses to ~0.45 % of original; WETH reserve still intact. Invariant destroyed. |
| 4 | Sell 1.3e14 GAIN via router, then final raw swap(6.5329e18 WETH out, 0) (:1742-1815) | 6.5989e16 | 9.481e25 | One trivial GAIN sell against the degenerate pool buys 6.5329 WETH (≈ 99 % of the WETH side). |
| 5 | Repay flash 0.1 + 0.0001 WETH to the V3 pool (:1816-1827) | — | — | Loan + fee returned. |
Why step 4 drains the pool. After sync() the pair believes its reserves are
reserve0 = 6.5989e18 WETH, reserve1 ≈ 8.17e23 GAIN. The marginal price of GAIN (WETH per GAIN) is
now astronomically higher than reality, because reserve1 was slashed while reserve0 was untouched.
A modest GAIN input therefore satisfies the pair's K-check
(UniswapV2Pair.sol:477) while pulling almost
the entire WETH reserve out. The final swap takes amount0Out = 6,532,946,950,955,627,431 wei of
WETH (:1800-1812), leaving the pool with only 6.5989e16 wei (1 % dust).
Profit accounting (WETH)#
| Direction | Amount (wei) | WETH |
|---|---|---|
| Flash-borrowed | 100,000,000,000,000,000 | 0.1 |
| Final WETH extracted from pool | 6,532,946,950,955,627,431 | 6.5329 |
| Flash repayment (principal + fee) | 100,010,000,000,000,000 | 0.10001 |
Net profit (balanceOf(attacker) at end, :1831-1834) | 6,432,936,950,955,627,431 | ≈ 6.4329 |
The attacker started with 0 WETH, borrowed 0.1, and ended holding 6.4329 WETH — essentially the
pool's entire honest WETH liquidity, minus the dust left behind and the flash fee. PoC console
output: Attack Exploit: 6.432936950955627431 ETH.
Diagrams#
Sequence of the attack#
Pool / reported-balance evolution#
Why the burn-free shrink is theft: the divisor swap inside balanceOf#
Why each magic number#
- Flash 0.1 WETH (test:22,39): just enough to (a) donate to the pair to
buy dust GAIN and (b) prove the attack needs essentially no capital. It is repaid with the V3 fee
(
0.0001 WETH). swap(0, 100000)(test:53): buys 100,000 GAIN units for the attacker and triggers the attacker's own side assignment (incidental).transfer(pair, 100)thentransfer(pair, 188)(test:54,57): the decisive moves — each sends dust GAIN to the pair so the pair is assigned a side, after whichskim()+sync()recommit the now-shrunkenbalanceOf(pair). Two rounds compound the shrink down to ~0.45 % of the original GAIN reserve.skim()beforesync()(test:55-56,58-59):skimpushes out any excess balance over reserves (≈ 0 for GAIN here) so thatsyncwrites the reduced balance cleanly as the new reserve.transfer(pair, 130_000_000_000_000)(test:60): the GAIN "input" for the final extraction swap against the degenerate pool.leave_dust = WETHbal - WETHbal/100(test:61-62): the attacker pulls 99 % of the pool's WETH (amount0Out = 6.5329e18), deliberately leaving 1 % so the optimistic transfer + K-check insideswap()does not revert on a fully-drained reserve.
Remediation#
- Make
balanceOfa pure function of tokens held. A token'sbalanceOfmust never depend on mutable, caller-influenceable state like a "side". If different cohorts must see different display values, expose that via a separate view; the canonicalbalanceOfused by AMMs and integrators must be path-independent and monotone w.r.t. the holder's actual balance. - Never let an external party reassign an account's balance basis. The auto side-assignment on inbound transfer (:898-908) lets anyone change the pair's reported balance by sending it dust. Side assignment must be opt-in by the account itself, and the AMM pair (and any contract) should never be silently re-bucketed.
- Exempt the liquidity pair from gamification entirely. Treat the pair like a fee-exempt,
side-less, rebase-neutral address whose
balanceOfalways uses one fixed divisor. - Do not pair manipulable-
balanceOftokens with raw Uniswap-V2. V2sync()blindly trustsbalanceOf; a token whosebalanceOfcan be moved for free turnssync()into a free reserve rewrite. Either use a price oracle/TWAP-gated pool or forbid such tokens. - Bound single-step reserve changes. Any reserve update (incl.
sync) that drops a reserve by more than a small percentage in one step should revert or require governance — a 99.5 % reserve collapse with zero matching outflow is the signature of this class of attack.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile under a whole-project forge build):
_shared/run_poc.sh 2024-02-GAIN_exp -vvvvv
- RPC: a mainnet archive endpoint is required (the fork pins block
19_277_619). - Result:
[PASS] testExploit()withAttack Exploit: 6.432936950955627431 ETH.
Expected tail:
Ran 1 test for test/GAIN_exp.sol:ContractTest
[PASS] testExploit() (gas: 563035)
Logs:
Before Start: 0 ETH
Attack Exploit: 6.432936950955627431 ETH
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.83s
Reference: DeFiHackLabs PoC header (Total Lost ~18 ETH). GainOS — "the first gamified rebasoor."
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2024-02-GAIN_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
GAIN_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "GAIN (GainOS) Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.