Snowman Merkle Airdrop

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

H03-claimSnowman uses live balance, risk of invalid proof

Root + Impact

Description

  • The airdrop contract allows eligible users to claim Snowman NFTs by proving their entitlement via an EIP-712 signature and a Merkle proof based on their Snow token balance.

  • However, the Merkle leaf is constructed dynamically using the user's current Snow token balance (via balanceOf) instead of a fixed amount determined at the time of snapshot, as welle as the signature through `getMessageHash` .


    If the balance of the user changes, This results in invalid signature and proof mismatches and failed claims.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
// @audit
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
// @audit
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); // send tokens to contract... akin to burning
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
@audit here
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Risk

Impact:

Likelihood:

A Merkle tree snapshot is generated using a fixed balance at a specific time, and a user changes their balance after snapshot but before claiming.

A signature is made by a specific receiver before a change with its Snow balance.

Impact:

  • Users are **unable to claim **their Snowman NFTs if their balance changes even if they were eligible at snapshot time.

  • Airdrop becomes inaccessible to a significant portion of users who moved tokens, breaking the trust and usability of the system.

  • An attacker may attempt to transfer small portions of tokens to invalidate the evidence and make it more difficult, or even impossible, to claim the airdrop.

Proof of Concept

Add the following test in TestSnowmanAirdrop

Here, Bob, our malicious attacker, transfers token to Alice to make the signature verification and merkle proof invalid

Contract will revert with SA__InvalidSignature()

The reason is because the signature is checked with getMessageHashwhich uses the live balance

function testClaimSnowmanFailedProof() public {
// Alice claim test
assert(nft.balanceOf(alice) == 0);
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Get alice's digest
bytes32 alDigest = airdrop.getMessageHash(alice);
// alice signs a message
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
// malicious bob transfers tokens to Alice
vm.prank(bob);
snow.transfer(alice, 1);
// satoshi calls claims on behalf of alice using her signed message
// Revert because wrong proof
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}

Recommended Mitigation

Verify amount via Merkle proof instead of computing it from balanceOf.

Change the function getMessageHash too to take the amount in parameter instead of using the live balance

Require also to fix M01- s_hasClaimedSnowman is not checked in claimSnowman

- if (i_snow.balanceOf(receiver) == 0) {
- revert SA__ZeroAmount();
- }
- uint256 amount = i_snow.balanceOf(receiver);
+ error AlreadyClaimed();
+ function claimSnowman(address receiver, uint256 amount, ...) external {
+ require(!s_hasClaimedSnowman[receiver], AlreadyClaimed());
+ bytes32 leaf = keccak256(abi.encodePacked(receiver, amount));
...
}
- function getMessageHash(address receiver)
+ function getMessageHash(address receiver, uint256 amount)
Updates

Lead Judging Commences

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

Invalid merkle-proof as a result of snow balance change before claim action

Claims use snow balance of receiver to compute the merkle leaf, making proofs invalid if the user’s balance changes (e.g., via transfers). Attackers can manipulate balances or frontrun claims to match eligible amounts, disrupting the airdrop.

Support

FAQs

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