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
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 {
bytes32[] memory leaves = new bytes32[](1);
leaves[0] = keccak256(abi.encode(victim, 100 ether));
bytes32 root = merkle.getRoot(leaves);
bytes32[] memory proof = merkle.getProof(leaves, 0);
vm.prank(attacker);
airdrop.claimSnowman(attacker, 100 ether, proof, 0, bytes32(0), bytes32(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
}