Reproduced Exploit
KittenSwap: distributing a stale zero-vote period resets a gauge's reward rate to 0
Chain
Other
Category
logic
Date
Jan 1970
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: KittenSwap-security-review_2025-07-31. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.
Vulnerability classes: vuln/logic/reward-calculation · impact/loss-of-funds/reward-theft
Reproduction: a faithful minimal reproduction of the vulnerable finding — the vulnerable
Voter._distributeemissions calculation, plus theAlgebraGauge._notifyRewardAmount/EternalVirtualPool.setRatesreset chain, are reproduced verbatim (marked@>) with faithful minimal doubles; local deploy, no fork.
Root cause#
Voter.distribute(_period, _gauge) accepts any valid past period so a missed distribution can settle in the current period, but _distribute never returns early when the supplied gauge had no votes for that past period (ps.gaugeTotalVotes[_gauge] == 0). Because the past period itself had global votes, emissions computes to exactly 0 with no guard, and that 0 is forwarded to IGauge(_gauge).notifyRewardAmount(0). The vulnerable lines, reproduced verbatim from the finding:
@> uint256 emissions = (ps.totalEmissions * ps.gaugeTotalVotes[_gauge]) / // @audit: gauge could have 0 votes for past period
ps.globalTotalVotes;
address _pool = gaugeToPool[_gauge];
// transfer emissions to minter if gauge is killed
if (gauge[_pool].isAlive == false) {
kitten.transfer(address(minter), emissions);
emissions = 0;
} else {
es.amount = emissions;
es.distributed = true;
}
if (gauge[_pool].isAlgebra) _claimAndDistributeAlgebraPoolFees(_pool);
kitten.approve(_gauge, emissions);
IGauge(_gauge).notifyRewardAmount(emissions);
For an Algebra gauge, notifyRewardAmount(0) reaches AlgebraGauge._notifyRewardAmount(0, 0) → algebraGaugeFactory.setRates(key, 0 / duration, 0 / duration) → EternalVirtualPool.setRates(0, 0), which overwrites the gauge's eternal-farming reward rate with 0. Rewards already funded for the live period stay locked in the virtual pool but never stream.
Why it's exploitable here#
Following the finding's first vector ("resetting reward rates to 0") with the PoC's concrete numbers:
- Period 6 (current) — the gauge has votes, so a legitimate distribution funds the eternal virtual pool with
1000e18KITTEN and sets a positiverewardRate0(~1.65e15/sec over the one-week duration). This is expected behavior. - The attacker calls
distribute(4, algebraGauge). Period 4 is a valid past period that did have global votes, but the gauge never existed then — sogaugeTotalVotes[gauge] == 0whileglobalTotalVotes > 0. emissions = totalEmissions * 0 / globalTotalVotes = 0. There is no early return, so the0flows onward:notifyRewardAmount(0)→_notifyRewardAmount(0,0)→setRates(key, 0, 0)→EternalVirtualPool.setRates(0, 0).rewardRate0is reset from its positive value back to0. The1000e18KITTEN already funded remains in the pool at rate0and never streams to the period-6 voters it was funded for — a reward-theft / denial-of-service.- The attack is repeatable
ntimes, once per valid past period that existed before the gauge was created.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xcc0e8eed…:
- L297 — Zero emissions, no early return: Root cause: Voter._distribute never returns early when the gauge had no votes for the supplied past period, so emissions computes to 0 and is forwarded on.
- L319 — Algebra branch forwards zero emissions: The gauge is flagged Algebra, so _distribute claims pool fees and then forwards the zero emissions into the gauge's notifyRewardAmount.
- L335 — Minter address fixed in harness: Setup: the harness fixes the MINTER address that _distribute would forward emissions to if the gauge were killed.
- L336 — Virtual pool key defined: Setup: KEY identifies the eternal-farming virtual pool that the factory forwards setRates calls to.
- L341 — Virtual pool reference held: Setup: the harness holds the eternal-farming virtual pool whose reward rate the zero-emissions distribution will reset to 0.
- L348 — Pre-attack reward rate recorded: Setup: resetRateFrom records the positive reward rate set by the legitimate period-6 distribution, before the attack zeros it.
- L354 — Harm marker token deployed: Setup: the harness deploys the marker token that records the 1000e18 KITTEN stranded at rate 0 as the measured harm.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 61952-h-02-reward-rates-can-be-reset-to-0-and-future-rewards-can-b_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: a legitimate period-6 distribution funds the pool with 1000e18 KITTEN and a positive reward rate, then distribute(4, gauge) for a zero-vote past period resets the rate to 0 and strands the full 1000e18 from its voters. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- No executable Forge reproduction is claimed; the historical source/toolchain was unavailable for this finding.
- AuditVault finding: KittenSwap-security-review_2025-07-31.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "KittenSwap: distributing a stale zero-vote period resets a gauge's reward rate to 0".
- 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.