Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

SnowmanAirdrop builds the Merkle leaf from the live Snow balance instead of the allocated amount, so anyone can block a user's claim by changing their balance

Root + Impact

claimSnowman builds the Merkle leaf from the receiver's current Snow balance instead of the amount allocated in the Merkle tree, so anyone can block a user's claim by changing their balance.

Description

As I understand the contract's logic, the Merkle root is meant to record who is eligible and for how much.

The mistake is that the contract does not check the amount allocated to the user in the Merkle tree. Instead, it checks the current Snow balance in their wallet.

Because Snow is a normal ERC20 token, anyone can send Snow to any address. An attacker can send 1 wei of Snow to a victim, so the victim's balance no longer matches the tree and their claim reverts with SA__InvalidProof. Even the user themselves can break their own claim by buying or earning more Snow after the tree was built.

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: High. Sending 1 wei of Snow costs almost nothing, and it can also happen by accident when a user earns or buys more Snow.

Impact: Medium. Eligible users can be blocked from claiming their Snowman NFTs. They can transfer the extra Snow away, but an attacker can repeat the attack or front-run the claim transaction.

Proof of Concept

Alice is in the Merkle tree with her allocated amount. An attacker sends her 1 wei of Snow, and her claim fails.

function testDustTransferBlocksClaim() public {
address attacker = makeAddr("attacker");
​
// attacker gets 1 Snow and sends it to alice
vm.prank(attacker);
snow.earnSnow();
vm.prank(attacker);
snow.transfer(alice, 1);
​
// alice's balance no longer matches her Merkle leaf
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, airdrop.getMessageHash(alice));
​
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
​
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}

Recommended Mitigation

Pass the allocated amount as a parameter and verify it against the Merkle tree, instead of reading the live balance.

- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
...
- uint256 amount = i_snow.balanceOf(receiver);
+ if (i_snow.balanceOf(receiver) < amount) revert SA__ZeroAmount();
​
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
​
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!