Snowman Merkle Airdrop

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

`SnowmanAirdrop::claimSnowman` validates the merkle proof using the receivers current balance

Description

  • A user in the merkle tree should be able to claim a snowman nft

  • In claimSnowman the receivers current snow token balance is used to verify the Merkle proof. This means that if the users snow balance differs from the amount specified when the merkle tree was first created, the proof will be invalid and the user cannot claim their snowman

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

Risk

Likelihood:

  • When the recipient tries to claimSnowman but their snow balance differs from the amount specified in the merkle tree

Impact:

  • The user won't be able to claim their snowman nft

Proof of Concept

Add this poc to TestSnowmanAirdrop.t.sol

The user alice has 2 snow tokens in balance and when satoshi tries to claim on her behalf the proof is invalid because the token amount specified in the merkle tree for her was only 1 snow token.

function testClaimSnowmanMultipleTokens() public {
vm.warp(block.timestamp + 1 weeks);
vm.startPrank(alice);
snow.earnSnow();
snow.approve(address(airdrop), 1);
vm.stopPrank();
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
vm.prank(satoshi);
vm.expectRevert(abi.encodeWithSignature("SA__InvalidProof()"));
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}

Recommended Mitigation

One possible mitigation is to allow the user to specify the amount of tokens they want to claim.

function claimSnowman(
address receiver,
+ uint256 amount,
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);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge about 1 month 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.