Snowman Merkle Airdrop

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

Signature verification failed due to typo in Struct Hash

Description

  • The claimSnowman function is intended to validate a claim by checking an EIP-712 signature provided by the legitimate user. This ensures that only the rightful owner can authorize the transfer of their Snow tokens to claim an NFT, even if a third party submits the transaction.

  • A typo (addres instead of address) in the MESSAGE_TYPEHASH constant forces the contract to compute an incorrect EIP-712 message hash. As a result, the claimSnowman function will only validate signatures that are generated against this specific flawed hash. However, any standard EIP-712 compliant wallet will generate a signature based on the correct hash, causing a permanent mismatch during verification. This leads to every valid claim attempt reverting with the SA__InvalidSignature() error, making users who use standard-compliant wallets can not claim Snowman NFTs.

// src/SnowmanAirdrop.sol
@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)"); // keccak256 hash of the SnowmanClaim struct's type signature, used for EIP-712 compliant message signing
  • Relevant Github link

https://github.com/CodeHawks-Contests/2025-06-snowman-merkle-airdrop/blob/b63f391444e69240f176a14a577c78cb85e4cf71/src/SnowmanAirdrop.sol#L49

Risk

Likelihood

  • This vulnerability occurs every time the claimSnowman function is called, resulting in the signature verification logic always failing.

Impact

  • The core functionality of the airdrop is broken, making it impossible for users to claim their Snowman NFTs.

Proof of Concept

  • This test demonstrates the core logic flaw: a signature generated by a standard EIP-712 compliant wallet will always be rejected. It proves that the only way to successfully claim a Snowman NFT is to generate a signature using the non-standard, flawed message hash that contains the typo.

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
import {Helper} from "../script/Helper.s.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
/**
* @title CorrectMessageHash Contract
* @notice This contract is used to demonstrate the hashing vulnerability in SnowmanAirdrop.
* It generates the CORRECT EIP-712 hash for comparison.
*/
contract CorrectMessageHash is EIP712 {
// This is the CORRECT type hash, with "address" spelled correctly.
bytes32 private constant CORRECT_MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver,uint256 amount)");
struct SnowmanClaim {
address receiver;
uint256 amount;
}
constructor() EIP712("Snowman Airdrop", "1") {}
function getCorrectMessageHash(address receiver, uint256 amount) public view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(CORRECT_MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}
}
contract TestSnowmanAirdrop is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
CorrectMessageHash correctMessageHash;
Helper deployer;
bytes32 public ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
// Proofs
bytes32 alProofA = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 alProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 alProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];
bytes32 bobProofA = 0x51c4b9a3cc313d7d7325f2d5d9e782a5a484e56a38947ab7eea7297ec86ff138;
bytes32 bobProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 bobProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] BOB_PROOF = [bobProofA, bobProofB, bobProofC];
bytes32 clProofA = 0x0065f7c9c934093ee1c4d51b77e77ad69d1c21351298d21cc720df18a39412f5;
bytes32 clProofB = 0xe4f70a2d0da3e6c29810b3eb84deeae82d06479d602b0e64225458c968f98cc1;
bytes32 clProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] CL_PROOF = [clProofA, clProofB, clProofC];
bytes32 danProofA = 0xc7c84a70b50ff4103e9a8b3a716b446a138a507fc1b65ebdfae38439e52b2612;
bytes32 danProofB = 0xe4f70a2d0da3e6c29810b3eb84deeae82d06479d602b0e64225458c968f98cc1;
bytes32 danProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] DAN_PROOF = [danProofA, danProofB, danProofC];
bytes32 eliProofA = 0x0000000000000000000000000000000000000000000000000000000000000000;
bytes32 eliProofB = 0x0000000000000000000000000000000000000000000000000000000000000000;
bytes32 eliProofC = 0xd7ed3892547c15a926b49d400e13fefe2c9f08de658f08b09925d5790383e978;
bytes32[] ELI_PROOF = [eliProofA, eliProofB, eliProofC];
// Multi claimers and key
address alice;
uint256 alKey;
address satoshi;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
satoshi = makeAddr("gas_payer");
correctMessageHash = new CorrectMessageHash();
}
function testExploit_ClaimFailsWithCorrectHashAndSucceedsWithFlawedHash() public {
// Standard setup for Alice to be able to claim.
assert(nft.balanceOf(alice) == 0);
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Generate the CORRECT EIP-712 message hash using the helper contract.
// This is the hash that a standard-compliant wallet would produce.
bytes32 correctDigest = correctMessageHash.getCorrectMessageHash(alice, snow.balanceOf(alice));
// Alice signs the correct digest with her private key.
(uint8 correctV, bytes32 correctR, bytes32 correctS) = vm.sign(alKey, correctDigest);
vm.prank(satoshi);
// This claim MUST fail. The Airdrop contract will generate a *different*, flawed hash
// for verification, causing a mismatch with the signature generated from the correct hash.
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, AL_PROOF, correctV, correctR, correctS);
// Verify that the claim failed and Alice did not receive an NFT.
assert(nft.balanceOf(alice) == 0);
// Generate the FLAWED message hash directly from the vulnerable Airdrop contract.
bytes32 flawedDigest = airdrop.getMessageHash(alice);
// Alice signs the flawed digest. This is the only digest the contract will accept.
(uint8 flawedV, bytes32 flawedR, bytes32 flawedS) = vm.sign(alKey, flawedDigest);
// Satoshi submits the claim again, this time with the signature of the flawed hash.
vm.prank(satoshi);
// The claim succeeds because the signature was created from the exact same
// flawed hashing logic that the contract uses for verification.
airdrop.claimSnowman(alice, AL_PROOF, flawedV, flawedR, flawedS);
// Verify that Alice successfully received her NFT.
assert(nft.balanceOf(alice) == 1);
assert(nft.ownerOf(0) == alice);
}
}

Recommended Mitigation

The typo in the MESSAGE_TYPEHASH constant must be corrected to match the SnowmanClaim struct definition and conform to the EIP-712 standard.

// src/SnowmanAirdrop.sol
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver,uint256 amount)");
Updates

Lead Judging Commences

yeahchibyke Lead Judge 12 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Inconsistent MESSAGE_TYPEHASH with standard EIP-712 declaration

A typo in the `MESSAGE_TYPEHASH` variable of the `SnowmanAirdrop` contract will prevent signature verification claims. Used `addres` instead of `address`

Support

FAQs

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