Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Reentrancy Attack on `SnowmanAirdrop.sol::claimSnowman()` and External Calls

[H-7] Reentrancy Attack on SnowmanAirdrop.sol::claimSnowman() and External Calls


Description

The claimSnowman() function in SnowmanAirdrop.sol performs multiple external calls: i_snow.balanceOf(receiver) (external read), i_snow.safeTransferFrom(receiver, address(this), amount) (external write), and i_snowman.mintSnowman(receiver, amount) (external write). While the nonReentrant modifier is present, the primary concern here is not a classic reentrancy attack (where a malicious contract re-enters before the first call finishes). Instead, the vulnerability arises from the sequence and reliance on mutable external state for critical verification steps, particularly when combined with the issues highlighted in [H-6] (unsafe signer) and [H-9] (Merkle proof bound to mutable balance).


Risk

This combination of vulnerabilities can lead to sophisticated replay attacks or double claims. If a user's token balance (i_snow.balanceOf(receiver)) can change between the time a signed claim payload is generated off-chain (which might rely on a specific amount snapshot) and the time the claimSnowman function is executed on-chain, an attacker could potentially exploit this. A user might sign a message based on their balance, transfer tokens out, and then a malicious actor (or even the user themselves) could try to use the same signature/Merkle proof from a different wallet or context. If the amount check is not strictly tied to the initial proof, this could lead to inconsistent state or unintended mints.


Proof of Concept

The vulnerable sequence in SnowmanAirdrop.sol::claimSnowman():

uint256 amount = i_snow.balanceOf(receiver); // External read (mutable state)
i_snow.safeTransferFrom(receiver, address(this), amount); // External write
i_snowman.mintSnowman(receiver, amount); // External write, relies on `amount` which was from mutable state

The proof of concept describes a scenario where "User signs a valid claim with balance of 100. Sends tokens to another wallet, who uses the same v,r,s with valid Merkle proof. New wallet successfully mints due to balance check not tied to proof." This highlights the broader issue of state dependency and signature reusability.


Recommended Mitigation

  1. Bind Merkle Proof Strictly to a Static amount: The amount used to generate the Merkle proof should be a fixed value from a snapshot (off-chain) and should be explicitly passed as an argument to claimSnowman. The on-chain i_snow.balanceOf(receiver) should not be used to derive this amount for Merkle verification. This ensures that the Merkle proof verifies a specific (receiver, amount) pair from a predefined eligibility list.

  2. Implement EIP-712 Nonce-Based Replay Protection & msg.sender Binding: This is paramount. The signature used for the claim must be unique to prevent replay. Including a unique nonce (managed by the contract) and binding the signature to msg.sender (the caller of claimSnowman) ensures that each claim can only be executed once by the rightful claimant. (Refer to the detailed mitigation in [H-6]).

  3. Strict Checks-Effects-Interactions Ordering: While nonReentrant helps, always ensure that all internal state changes (s_hasClaimedSnowman[receiver] = true;) occur before any external calls are made. The current code has s_hasClaimedSnowman[receiver] = true; after safeTransferFrom but before mintSnowman, which is acceptable, but ideally all internal state updates would precede any external interactions. The primary vulnerability here is in the data integrity for proof generation and signature validity, rather than a classic reentrancy bug.

(Note: The code diff for this mitigation largely overlaps with the proposed solutions for [H-6] and [H-9] as they are deeply interconnected.)

Updates

Lead Judging Commences

yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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