Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

claimSnowman() uses balance at verification time but re-reads balance at transfer time, TOCTOU vulnerability

Root + Impact

Description

  • Normal behavior: claimSnowman() checks that the receiver has a non-zero Snow balance before proceeding, then uses that balance to determine the NFT mint amount.

  • The issue: The function checks i_snow.balanceOf(receiver) == 0 at line 76, then re-reads i_snow.balanceOf(receiver) at line 84 for the transfer amount. Between these two reads, the receiver's balance can change. Additionally, the Merkle leaf is computed using the live balance at claim time rather than a committed balance, meaning the Merkle proof (which was generated for a specific amount) may not match the current balance, causing legitimate claims to fail or allowing balance manipulation to affect claim amounts.

```solidity
// src/SnowmanAirdrop.sol#76-92
// @> Check 1: balance checked here
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
// ... signature verification ...
// @> Read 2: balance re-read — can differ from Check 1
uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
// @> Merkle proof was generated for original amount, not current amount
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();
```

Risk

Likelihood:

  • A receiver can transfer Snow tokens to another address between the check and the read in the same block.

  • Front-running bots can manipulate the balance between the two reads.

  • Receiver can manipulate claim amount by changing balance between check and use.

  • Legitimate users whose balance changes between Merkle tree generation and claim time may be permanently blocked.

Proof of Concept

The following test demonstrates that a receiver whose Snow balance changes
between Merkle tree generation and claim time is permanently locked out.
The Merkle tree encodes the balance at snapshot time, but claimSnowman()
recomputes the leaf using the live balance. Any discrepancy causes
SA__InvalidProof to revert. Additionally a receiver can manipulate their
claim amount by transferring tokens before calling claimSnowman(), causing
the live balance to differ from the committed Merkle amount.

function testTOCTOUBalanceManipulation() public {
// Merkle tree was generated when receiver had 100 Snow tokens
// receiver has 100 Snow tokens at snapshot time
assertEq(snow.balanceOf(receiver), 100);
// Before claiming, receiver transfers 50 tokens away
vm.prank(receiver);
snow.transfer(otherAddress, 50);
// receiver now has 50 tokens — different from Merkle snapshot
assertEq(snow.balanceOf(receiver), 50);
// claimSnowman() reads live balance = 50
// Merkle leaf computed with amount=50
// But proof was generated for amount=100
// Mismatch causes permanent revert
vm.prank(receiver);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(receiver, merkleProof, v, r, s);
// Legitimate user permanently locked out of airdrop
}

Recommended Mitigation

The fix passes amount as an explicit parameter to claimSnowman() instead
of reading it from the live balance. The Merkle proof verifies that the
receiver is entitled to exactly that amount — no more, no less. The live
balance check is removed entirely since the Merkle proof provides the
source of truth. This eliminates both the TOCTOU race condition and the
balance manipulation vector in a single change.

- function claimSnowman(
- address receiver,
- bytes32[] calldata merkleProof,
- uint8 v, bytes32 r, bytes32 s
- ) external nonReentrant {
- if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
- uint256 amount = i_snow.balanceOf(receiver);
- bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
+ function claimSnowman(
+ address receiver,
+ uint256 amount,
+ bytes32[] calldata merkleProof,
+ uint8 v, bytes32 r, bytes32 s
+ ) external nonReentrant {
+ if (amount == 0) revert SA__ZeroAmount();
+ bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!