Reproduced Exploit
KittenSwap: killed-gauge distributions can be replayed to block valid payouts
Chain
Other
Category
untagged
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/dos · vuln/logic · vuln/reward-accounting
Reproduction: a faithful minimal reproduction of the vulnerable finding — the killed-gauge branch of
Voter::_distributeis reproduced verbatim (marked@>) with faithful minimal doubles (KITTEN token, Minter period funding, Algebra/standard gauges); local deploy, no fork.
Root cause#
When a gauge is killed (isAlive == false), _distribute routes that gauge's pending emissions back to the minter and zeroes them out — but, unlike the live else branch, it never sets es.distributed = true. The if (es.distributed) revert guard therefore never trips for a killed gauge, so anyone can call distribute(killedGauge) repeatedly within the same period. The vulnerable branch, reproduced verbatim from the audited Voter:
function _distribute(uint256 _period, address _gauge) internal {
...
IVoter.Emissions storage es = ps.gaugeEmissions[_gauge];
if (es.distributed) revert EmissionsAlreadyDistributedForPeriod();
uint256 emissions = (ps.totalEmissions * ps.gaugeTotalVotes[_gauge]) /
ps.globalTotalVotes;
...
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);
...
}
Only the live else branch persists es.distributed = true, so a normal gauge is protected from a second distribution. The killed branch omits that write, leaving the guard permanently disarmed for that gauge.
Why it's exploitable here#
Following the finding's worked example — period_2 begins with 100 KITTEN of emissions and votes gauge_a = 45, gauge_b = 45, killed Algebra gauge_c = 10:
- The attacker calls
distribute(gauge_c). The minter funds the Voter with100e18, and the killed branch routesgauge_c's10e18share back to the minter. The Voter now holds90e18— exactly what is owed togauge_aandgauge_b. - Because
es.distributedwas never set, the attacker replaysdistribute(gauge_c)nine more times. Each call recomputes the same10e18and routes it back to the minter, draining the Voter to0. distribute(gauge_a)now reverts: the Voter has no KITTEN left to fund the live standard gauge'stransferFrompull.gauge_aandgauge_b's combined90e18of valid emissions are blocked for the entire period.
(The finding notes a killed standard gauge reverts on a zero notify, so the replay only works on Algebra gauges, whose gauge accepts a zero-amount notify because its rewards come from claimed pool fees — the synthetic models gauge_c as exactly such a killed Algebra gauge.)
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xbd4fd5a3…:
- L192 — Once-per-period guard error: Setup: the custom error the once-per-period guard reverts with, meant to stop any gauge from being distributed twice in the same period.
- L235 — Guard checks the distributed flag: The guard reverts only when es.distributed is already true, but a killed gauge never sets that flag, so this check never blocks a replay.
- L237 — Compute the gauge's emission share: Emissions are computed pro-rata from the gauge's votes: 10 of 100 total votes yields 10e18 KITTEN, recomputed identically on every call.
- L242 — Killed branch never sets the flag: Root cause: the killed-gauge branch sends emissions back to the minter and zeroes them but never sets es.distributed=true, so distribute() replays every call.
- L244 — Live branch marks distributed: Only the live else branch stores es.amount and sets es.distributed=true, protecting normal gauges; killed gauges skip it and stay replayable.
- L262 — Drained Voter blocks valid payouts: The nine replays drain the Voter's remaining 90e18 back to the minter, so gauge_a and gauge_b's valid payouts revert; that misrouted amount is the harm.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 61951-h-01-repeated-distributions-for-killed-gauges-can-block-vali_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: one honest distribution leaves 90e18 owed to the live gauges, then nine killed-gauge replays drain the Voter back to the minter, so the live gauge's valid distribution reverts. 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: killed-gauge distributions can be replayed to block valid payouts".
- 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.