Snowman Merkle Airdrop

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

`claimSnowman` is callable by anyone with the signature — relayer/replayer griefs timing and token approvals

Root + Impact

Description

  • "Claim on behalf" is documented as a feature, but the function combines it with (a) a safeTransferFrom from receiver that requires a standing approval, (b) no deadline, and (c) the C-03 replay path. Once the signature exists, any party who obtains it can force the burn-and-mint at any moment.

  • This trades user timing control for "gasless claims" — a design choice that becomes unsafe given the other findings.

// src/SnowmanAirdrop.sol
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external nonReentrant
{
@> // msg.sender is unrestricted — anyone with the signature can submit
...
i_snow.safeTransferFrom(receiver, address(this), amount);
...
}

Risk

Likelihood:

  • Reason 1: Whenever a user broadcasts a claim tx that fails for any reason (gas, H-01 dust grief), the signature leaks to anyone watching the mempool.

  • Reason 2: Custodial relayers, frontends, and Telegram support channels routinely come into possession of pre-built signatures.

Impact:

  • Impact 1: A third party burns the user's Snow at an inconvenient moment, robbing the user of timing flexibility.

  • Impact 2: Combined with C-03 and M-04, the same signature replays indefinitely after the original claim.

Proof of Concept

The PoC simulates the realistic leak vector: Alice has signed her claim and pre-approved Snow to the airdrop (both are normal preconditions). A random relayer obtains the signature through any channel (mempool, frontend telemetry, a leaked support log). The relayer submits the claim using its own msg.sender, which the contract accepts because it never gates by msg.sender. After execution, Alice's Snow is burned and her Snowman is minted — at a moment Alice did not choose. The assert proves the claim went through against Alice's will; combined with C-03's replay path, the relayer can also choose to replay the claim later after Alice (or anyone) refills her balance.

function test_thirdPartyForcesClaim() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
(uint8 v, bytes32 r, bytes32 s) = _signClaim(alicePk);
vm.prank(makeAddr("randomRelayer"));
airdrop.claimSnowman(alice, proof, v, r, s);
assertEq(snowman.balanceOf(alice), 1); // claim happened against alice's will
}

Recommended Mitigation

The minimal fix adds a deadline parameter both as a function argument and as a field in the signed EIP-712 struct, so signatures bound to a deadline expire automatically. Users can also revoke a pending signature by calling a future invalidateSignature helper (not shown). If the "claim on behalf" feature is not core to the protocol, a stricter alternative is to require msg.sender == receiver, but that breaks the gasless-claim use case the README advertises — so a deadline is the safer middle ground.

- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 deadline, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external nonReentrant
{
+ if (block.timestamp > deadline) revert SA__SignatureExpired();
...
}

The signed SnowmanClaim struct must also include deadline so its value is bound into the digest.

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!