Reproduced Exploit

KittenSwap — a voter claims the next period's incentives

incentivize and _deposit both write getCurrentPeriod() + 1. getRewardForPeriod checks that the caller owns the veNFT and then pays that period. It never checks that the period is not still in the future.

Jun 2025•Other•logic•5 min read

Chain

Other

Category

logic

Date

Jun 2025

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

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.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 58067-c-03-incentive-rewards-may-be-stolen-pashov-audit-group-none. Standalone Foundry PoC and full write-up: 58067-c-03-incentive-rewards-may-be-stolen-pashov-audit-group-none_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/reward-calculation · vuln/logic/missing-check

Reproduction: a self-contained Foundry PoC that compiles and runs offline with only forge-std — no fork and no RPC. Trace: output.txt. PoC: test/58067-c-03-incentive-rewards-may-be-stolen-pashov-audit-group-none_exp.sol.


Key info#

ImpactHIGH — the attacker receives the victim's 500 USDC
ProtocolKittenSwap VotingReward
Vulnerable codegetRewardForPeriod forwards _period with no future-period check
Bug classIncentives booked for the next period are claimable while that period is still open
FindingPashov Audit Group — KittenSwap, 12 June 2025 · C-03 · id 58067
ReportKittenSwap-security-review_2025-06-12.md
SourceAuditVault finding
StatusReproduced here as a local synthetic of the audited claim.
Compilersolc 0.8.27, cancun, via IR

TL;DR#

incentivize and _deposit both write getCurrentPeriod() + 1. getRewardForPeriod checks that the caller owns the veNFT and then pays that period. It never checks that the period is not still in the future.

Alice deposits 500 USDC for the next period. Bob votes into that same period before anyone else and claims it. He is the only voter, so earned is 500 USDC. He receives Alice's 500 USDC. Alice's balance is 0 and the reward contract is empty.

The fixed claim reverts while _period > getCurrentPeriod(). After the clock reaches that period, the same 500 USDC is paid to Alice, who is the voter in the control. The attacker is paid 0.

run() does not warp. At the replay timestamp the current period is 0, so the funded period is 1, and the claim of period 1 succeeds. The control test warps only outside run(), after the early claim has reverted.

The vulnerable code#

SOLIDITY
function incentivize(address _token, uint256 _amount) external virtual nonReentrant {
    if (voter.isWhitelisted(_token) == false)
        revert NotWhitelistedRewardToken();
    uint256 currentPeriod = getCurrentPeriod() + 1;
    uint256 amount = _addReward(currentPeriod, _token, _amount);
}

function _deposit(uint256 _amount, uint256 _tokenId) external onlyVoter {
    uint256 nextPeriod = getCurrentPeriod() + 1;
    tokenIdVotesInPeriod[nextPeriod][_tokenId] += _amount;
    totalVotesInPeriod[nextPeriod] += _amount;
}

function getRewardForPeriod(uint256 _period, uint256 _tokenId, address _token) external virtual nonReentrant {
    if (!veKitten.isApprovedOrOwner(msg.sender, _tokenId))
        revert NotApprovedOrOwner();
    // @> VULN: _period is not checked against getCurrentPeriod(), so the next period is claimable now
    _getReward(_period, _tokenId, _token, msg.sender);
}

earned pays tokenRewardsPerPeriod * votes / totalVotes. One voter takes the whole balance.

Root cause#

The next period is still accepting votes. Paying it early freezes the share at whoever has voted so far. Bob votes 100, claims, and is paid 500e6 * 100 / 100. A later voter is owed a share of a balance that is already zero.

Preconditions#

  • USDC is whitelisted.
  • Alice holds 500 USDC and calls incentivize. The tokens move into VotingReward for period getCurrentPeriod() + 1.
  • Bob owns a veNFT and is the first, and in this run the only, voter for that period.
  • The block timestamp is still inside the current period. run() does not advance it.

Attack walkthrough#

  1. Whitelist USDC. Mint 500 USDC (500000000 base units) to Alice.
  2. Alice calls incentivize. The balance is stored on period 1. Her balance is now 0.
  3. Bob votes 100. totalVotesInPeriod[1] is 100 and his votes are 100.
  4. earned(1, Bob, USDC) is 500 USDC.
  5. Bob calls getRewardForPeriod(1, ...). The owner check passes. There is no period check. _getReward transfers 500 USDC to Bob.
  6. Bob's balance is 500 USDC. The reward contract's balance is 0.

Control: Alice funds the same 500 USDC and Alice is the voter. getRewardForPeriod reverts with FuturePeriod while the period is still ahead. After block.timestamp reaches that period, Alice's claim pays her 500 USDC. The test contract, standing in for an attacker who did not vote, holds 0.

Diagrams#

flowchart TD A["Alice deposits 500 USDC for the next period"] --> B["Bob is the only voter in that period"] B --> C["getRewardForPeriod does not check the period"] C --> D["earned is 500 USDC times 100 over 100"] D --> E["Bob receives Alice's 500 USDC"]

Impact#

Bob receives the 500 USDC Alice deposited. Alice is left with 0. The incentive for the period is gone before the period opens, so any voter who arrives later cannot be paid. The fixed claim pays that same 500 USDC to Alice once the period is no longer in the future, and pays the attacker nothing.

Remediation#

Reject _period > getCurrentPeriod() in getRewardForPeriod before _getReward. Incentives and votes for the next period then stay in the contract until that period is the current one.

Sources#

Taxonomy#

  • severity/high
  • vuln/logic/reward-calculation · vuln/logic/missing-check

Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • 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.