Snowman Merkle Airdrop

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

SnowmanAirdrop never checks s_hasClaimedSnowman and signatures have no nonce or expiry, allowing repeated claims and replay of old signatures by anyone

Root + Impact

claimSnowman sets s_hasClaimedSnowman but never checks it, and the signature has no nonce or expiry, so a user can claim more than their allocation and anyone can replay an old signature to force a claim.

Description

After a claim, the contract sets a flag to mark that the user has already claimed.

The problem is that the flag is only written and never checked. A user can accumulate the same Snow amount they already claimed, and receive NFTs again, beyond their allocation.

In addition, the signature has no nonce and no expiry date. The same old signature remains valid, so anyone can reuse it to call claimSnowman for the user and make the contract take the user's Snow tokens.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
@> // no check of s_hasClaimedSnowman[receiver]
...
i_snow.safeTransferFrom(receiver, address(this), amount);
​
@> s_hasClaimedSnowman[receiver] = true;
...
}

Risk

Likelihood: Low. The user's balance has to return to exactly the originally allocated amount.

Impact: Medium. A user can receive more NFTs than their allocation, and anyone can replay an old signature to take a user's Snow without their consent.

Proof of Concept

Alice claims once. Then her balance returns to the same amount, and an attacker replays her old signature.

function testClaimTwiceWithReplayedSignature() public {
address attacker = makeAddr("attacker");
​
// first, normal claim
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, airdrop.getMessageHash(alice));
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
​
assertTrue(airdrop.getClaimStatus(alice));
uint256 nftsAfterFirstClaim = nft.balanceOf(alice);
​
// alice gets the same Snow amount again
deal(address(snow), alice, nftsAfterFirstClaim);
​
// attacker replays the old signature
vm.prank(attacker);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
​
assertEq(nft.balanceOf(alice), nftsAfterFirstClaim * 2);
}

Recommended Mitigation

Check the claim flag before claiming, and add a nonce and deadline to the signed message.

+ 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 (s_hasClaimedSnowman[receiver]) {
+ revert SA__AlreadyClaimed();
+ }
​
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!