Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: high
Likelihood: medium

Signature Bypass (Signature Front-Running)

Author Revealed upon completion

Root + Impact

Description

  • The normal behavior is that recipients create signatures to claim their own Snowman NFTs, with the expectation that only they can initiate the claiming process using their signature.

  • The specific issue is that anyone can call the claimSnowman function for any recipient as long as they possess a valid signature, allowing attackers to forcibly claim NFTs on behalf of others without their consent.


// Root cause in the codebase with @> marks to highlight the relevant section
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external // @> Anyone can call this function
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
// @> Missing check: msg.sender != receiver validation
uint256 amount = i_snow.balanceOf(receiver);
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; // @> Marks user as claimed without their consent
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Risk

Likelihood:

  • High - Signatures can be intercepted from mempool transactions where they are publicly visible before confirmation

  • High - Users might share signatures through insecure channels or signatures could be extracted from failed transactions

  • Medium - MEV bots and front-runners actively monitor mempool for profitable transactions containing signatures

Impact:

  • Griefing Attack - Attackers can force users to claim their NFTs at disadvantageous times, potentially affecting tax implications or personal timing preferences

  • MEV/Front-running - Malicious actors can extract value by controlling the timing of claims, potentially sandwiching user transactions or manipulating market conditions

  • Loss of User Control - Users lose autonomy over when they claim their NFTs, breaking the intended user experience and potentially causing confusion

  • Potential Financial Loss - In scenarios where timing matters (e.g., market conditions, gas prices, tax events), forced claiming could result in suboptimal outcomes for users

Proof of Concept


note: A full Foundry/Hardhat test is available upon request or in the appendix.

1. Get Alice's signature and Merkle proof (off-chain or via phishing).
2. Call claimSnowman(alice, proof, v, r, s) from attacker account.
3. Alice's claim is now marked as used; Alice cannot claim.
note: A full Foundry/Hardhat test is available upon request. High level steps:
// Set up addresses
// Deploy contracts
// Create merkle tree for Alice with 100 Snow tokens
// Deploy airdrop
// Give Alice 100 Snow tokens (she earned them legitimately)
// Mint snow tokens to Alice
// Alice buys 100 Snow tokens
// Alice approves airdrop to spend her Snow tokens
// Alice creates a signature to claim her own NFTs
// Bob (attacker) intercepts Alice's signature and claims for Alice
// Record initial state
// Bob calls claimSnowman using Alice's signature
// Check final state

Recommended Mitigation

Require that only the intended receiver can call claimSnowman by adding a check:

- remove this code
+ add this code
// >>> ERRORS
error SA__InvalidProof(); // Thrown when the provided Merkle proof is invalid
error SA__InvalidSignature(); // Thrown when the provided ECDSA signature is invalid
error SA__ZeroAddress();
error SA__ZeroAmount();
+ error SA__UnauthorizedCaller(); // New error for unauthorized caller
// >>> EXTERNAL FUNCTIONS
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
+ // MITIGATION: Only allow receiver to claim for themselves
+ if (msg.sender != receiver) {
+ revert SA__UnauthorizedCaller();
+ }
+
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver);
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;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Support

FAQs

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