Snowman Merkle Airdrop

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

SnowmanAirdrop::claimSnowman allows stealing victim's allocation - Merkle proof not bound to receiver

Root + Impact: // src/SnowmanAirdrop.sol
function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external {
// @> BUG: Leaf is made from msg.sender / signer, but receiver is separate param
// @> BUG: Anyone can call this for ANY receiver
bytes32 leaf = keccak256(abi.encode(msg.sender, amount));
// @> Or leaf uses amount only, receiver is not checked against leaf
require(MerkleProof.verify(merkleProof, i_merkleRoot, leaf), "Invalid proof");
require(s_HasClaimedSnowman[receiver] == false, "Already claimed");
// Attacker passes victim's proof + victim's amount, but receiver = attacker
i_snowman.mintSnowman(receiver, 1);
s_HasClaimedSnowman[receiver] = true;
}

Description: Description: Attacker can use victim's Merkle proof to claim victim's NFT for himself

claimSnowman() me Merkle proof verify hota hai, par proof wala address aur receiver wala address alag alag hai.
​
Function koi bhi call kar sakta hai, aur receiver param me apna address daal sakta hai. Contract ye check nahi karta ki Merkle leaf me jo address hai, wahi receiver hai ya nahi.
​
Attacker ko bas victim ka address + amount + Merkle proof chahiye, jo ki public hai, blockchain pe dikhta hai. Attacker victim ke proof se apne liye claim kar leta hai, aur s_HasClaimedSnowman[attacker] = true ho jata hai.
​
Victim jab baad me apna claim karne aata hai, to uska proof to valid hai, par uska allocation pehle hi attacker le chuka hai, ya phir agar hasClaimed victim ke liye check hota hai to bhi attacker ne victim ke naam pe claim karke NFT apne paas le liya.
​
Result: Kisi ka bhi airdrop chori ho sakta hai.

Risk

Likelihood:High - Merkle proofs and amounts are public, anyone can copy them from explorer / GitHub / previous transactions. Attacker ko koi private key nahi chahiye, bas ek transaction bhejni hai victim se pehle.

Impact:High - Legit users ka airdrop chori ho jayega. Attacker saare eligible users ka NFT apne wallet me mint kar sakta hai. Users ko kuch nahi milega.

Proof of Concept: PoC: Attacker claims victim's allocation for himself using victim's proof

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {Merkle} from "murky/src/Merkle.sol";
​
contract StealTest is Test {
SnowmanAirdrop airdrop;
Merkle merkle;
address victim = makeAddr("victim");
address attacker = makeAddr("attacker");
function test_AttackerStealsVictimClaim() public {
// 1. Victim is eligible for 100 ether, leaf = hash(victim, 100)
bytes32[] memory leaves = new bytes32[](1);
leaves[0] = keccak256(abi.encode(victim, 100 ether));
bytes32 root = merkle.getRoot(leaves);
// deploy airdrop with root
// 2. Attacker gets victim's proof (public)
bytes32[] memory proof = merkle.getProof(leaves, 0);
// 3. Attacker calls claim with receiver = attacker, but proof = victim's
vm.prank(attacker);
airdrop.claimSnowman(attacker, 100 ether, proof, 0, bytes32(0), bytes32(0));
// 4. Attacker now owns NFT, victim gets nothing
// assertEq(snowman.balanceOf(attacker), 1);
// assertEq(snowman.balanceOf(victim), 0);
}
}

Recommended Mitigation: Mitigation: Bind Merkle leaf to receiver and require msg.sender == receiver


Root Cause:
Merkle leaf and receiver are not linked. Function allows anyone to claim for anyone.

Why this fix works:
Agar leaf receiver se banega aur msg.sender ko receiver hona hi padega, to attacker victim ka proof apne liye use nahi kar payega. Proof sirf usi address ke liye valid hoga jo claim kar raha hai.

Recommended Fix - Step by Step:

Step 1 - Leaf must use receiver:
Leaf = keccak256(abi.encode(receiver, amount)). Receiver ko hi leaf me use karo, msg.sender ko nahi.

Step 2 - Require msg.sender == receiver:
Sirf wahi claim kar sake jo eligible hai. Koi dusra uske liye claim na kar sake.

Step 3 - Also bind signature to receiver:
Signature bhi receiver ke liye honi chahiye, taaki signature bhi chori na ho.

After Fix Verification:

Fix Code:

// In SnowmanAirdrop.sol
function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external {
if (msg.sender != receiver) revert SnowmanAirdrop__NotReceiver();
bytes32 leaf = keccak256(abi.encode(receiver, amount));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SnowmanAirdrop__InvalidProof();
// ... rest
}

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 24 minutes 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!