Snowman Merkle Airdrop

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

User's balance change make Merkle proof fails.

Description:
If a user receives extra Snow tokens before claiming their NFT in SnowmanAirdrop, their claim will fail. This is because the SnowmanAirdrop::claimSnowman function uses the snow balance of the user to make the leaf for the merkle tree, and how this was set before with a fixed value, the related verification will fails.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
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();
}
@> 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);
}

Impact:
Users who receive additional Snow tokens (e.g., via transfer) before claiming cannot claim their NFT, potentially locking them out of the airdrop.

Proof of Concept:
Add the following after the TestSnowmanAirdrop test suite:

function testBalanceChangeDenialClaim() public {
// Bob send to Alice 1 snow increasing her balance to 2
vm.prank(bob);
snow.transfer(alice, 1);
// Alice claim setup
vm.prank(alice);
snow.approve(address(airdrop), 2);
// Get alice's digest
bytes32 alDigest = airdrop.getMessageHash(alice);
// alice signs a message
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
// Alice try to claims a Nft using her signed message
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
vm.prank(alice);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}

Recommended Mitigation:
On SnowmanAirdrop::claimSnowmanfunction allow only one nft per valid user:

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
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();
}
+ if (s_hasClaimedSnowman[receiver]) {
+ revert("Nft Already Recived");
+ }
- uint256 amount = i_snow.balanceOf(receiver);
+ uint256 amount = 1;
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);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge about 2 months 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.