Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

SnowmanAirdrop's EIP-712 type string is malformed, so signatures from any standards-compliant wallet are rejected and users must blind-sign a raw hash

The EIP-712 type string is malformed, so signatures produced by any standards-compliant wallet are rejected and users are pushed into blind-signing a raw hash

Description

  • SnowmanAirdrop inherits OpenZeppelin's EIP712 and uses _hashTypedDataV4, which signals that claim authorisations are meant to be produced by standard typed-data signing, the flow a wallet exposes as eth_signTypedData_v4. That flow lets a wallet display the claim's fields to the user before they sign. Its entire security value depends on the wallet and the contract deriving the same digest, which EIP-712 guarantees by fixing an exact canonical form for the type string.

  • The declared type string deviates from that canonical form in two independent ways: the type name is spelt addres rather than address, and there is a space after the comma separating the members. EIP-712's encodeType permits neither. A wallet following the standard therefore computes a different type hash, a different struct hash and a different digest, and the signature it produces fails _isValidSignature.

@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^ ^
// @> "addres" is not a type @> EIP-712 forbids this space
//
// canonical form required by the standard:
// keccak256("SnowmanClaim(address receiver,uint256 amount)")
struct SnowmanClaim {
address receiver;
uint256 amount;
}

The rest of the implementation is correct, which is what isolates the defect. getMessageHash encodes the struct in the standard way, and the domain separator is standard:

function getMessageHash(address receiver) public view returns (bytes32) {
...
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Because SnowmanClaim has only static members, abi.encode of the struct lays its fields out inline, so this expression is byte-identical to the standard hashStruct of keccak256(abi.encode(typeHash, receiver, amount)). The proof of concept below verifies this directly rather than assuming it: it reconstructs the contract's own digest from the malformed type string plus the standard encoding and asserts the result equals getMessageHash. That equality establishes that the type string is the only deviation from the standard, and therefore the only cause of the rejection.

The reason the project's own tests do not catch this is that they never perform typed-data signing. test/TestSnowmanAirdrop.t.sol asks the contract for the digest and signs that value directly:

bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);

A signer that derives the digest from the contract will always agree with the contract, whatever the type string says. The mismatch only appears against a signer that derives the digest from the standard, which is every real wallet.

Risk

Likelihood:

  • This occurs on every attempt to claim using a standards-compliant signature. There is no configuration, timing or balance under which the canonical digest matches, because the type string is a compile-time constant.

  • The contract's use of EIP712 and _hashTypedDataV4 advertises typed-data signing, so an integrator building the claim flow the intended way reaches this on their first attempt.

  • The airdrop's documented feature of claiming on someone else's behalf depends entirely on this signature path, so the affected flow is not an edge case but the reason the signature exists.

Impact:

  • Signatures from any standards-compliant wallet are rejected with SA__InvalidSignature, so the claim-on-behalf feature is unusable through ordinary tooling.

  • The only workaround is for users to sign the raw 32-byte digest returned by getMessageHash. A wallet cannot decode that into readable fields, so it presents an opaque hash and the user signs blind. This removes exactly the protection typed-data signing exists to provide, and blind signing of an arbitrary hash supplied by a third party is a well-known phishing vector, which is a security regression rather than merely an inconvenience.

  • Any external indexer, relayer or front end that constructs the digest from the published struct definition will produce signatures the contract rejects, so integrations break silently.

Proof of Concept

The domain separator is rebuilt through ERC-5267's eip712Domain(), exactly as a wallet would, so nothing about the digest is taken from the contract except the values the standard says to take.

function test_C6_canonical_eip712_signature_is_rejected() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
// Rebuild the domain separator from ERC-5267, exactly as a wallet would.
(, string memory name, string memory version, uint256 chainId, address verifying,,) = airdrop.eip712Domain();
bytes32 domainSeparator = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)),
keccak256(bytes(version)),
chainId,
verifying
)
);
// The canonical EIP-712 type string: no spaces after commas, "address"
// spelled correctly.
bytes32 canonicalTypehash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 contractTypehash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
assertTrue(canonicalTypehash != contractTypehash, "the two type hashes differ");
uint256 amount = snow.balanceOf(alice);
// CAUSAL CHECK: rebuild the contract's OWN digest using the malformed
// type string and the standard hashStruct encoding. If this reproduces
// getMessageHash exactly, then the struct encoding is already correct
// and the type string is the ONLY defect - which is the claim being made.
bytes32 contractStructHash = keccak256(abi.encode(contractTypehash, alice, amount));
bytes32 rebuiltContractDigest =
keccak256(abi.encodePacked("\x19\x01", domainSeparator, contractStructHash));
assertEq(
rebuiltContractDigest,
airdrop.getMessageHash(alice),
"the type string is the only deviation; the struct encoding is standard"
);
bytes32 structHash = keccak256(abi.encode(canonicalTypehash, alice, amount));
bytes32 canonicalDigest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
assertTrue(canonicalDigest != airdrop.getMessageHash(alice), "digests differ");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, canonicalDigest);
// A correctly-implemented wallet signs this. The contract rejects it.
vm.prank(alice);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}

Result:

[PASS] test_C6_canonical_eip712_signature_is_rejected()

The claimant is genuinely eligible, holds the correct balance, presents a valid Merkle proof, and signs with her own key. The claim still reverts with SA__InvalidSignature, solely because she signed the message the standard describes.

Recommended Mitigation

Correct the type string to its canonical form. This is a one-line change and requires no structural modification, since the encoding around it is already standard.

- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver,uint256 amount)");

EIP-712 defines encodeType as the struct name followed by its members in parentheses, each written as the type, a single space, and the member name, separated by commas with no additional whitespace. The corrected string above is that form for this struct.

Worth adding a regression test that derives the digest independently of the contract, since a test that asks the contract for its own digest cannot detect this class of defect. Note that OpenZeppelin's EIP712 keeps _domainSeparatorV4 internal, so the separator has to be rebuilt through the public ERC-5267 eip712Domain() accessor rather than read directly:

+ (, string memory n, string memory ver, uint256 cid, address vc,,) = airdrop.eip712Domain();
+ bytes32 separator = keccak256(
+ abi.encode(
+ keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
+ keccak256(bytes(n)), keccak256(bytes(ver)), cid, vc
+ )
+ );
+ bytes32 typehash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
+ bytes32 structHash = keccak256(abi.encode(typehash, alice, amount));
+ bytes32 digest = keccak256(abi.encodePacked("\x19\x01", separator, structHash));
+ assertEq(digest, airdrop.getMessageHash(alice));

Distinctness

This concerns the derivation of the signed digest and is independent of the previously reported issues. It is separate from the report on the unread claim flag, which also touches the signature: that report concerns the absence of a nonce and of a replay guard, and its fix adds a claim check and a nonce without altering the type string; this report's fix corrects the type string and adds no guard. A claim can fail here for a claimant who has never claimed before, and can succeed repeatedly there for a claimant signing the contract's own digest, so the two are independent failures of the same authorisation step.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 6 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] Unconsistent `MESSAGE_TYPEHASH` with standart EIP-712 declaration on contract `SnowmanAirdrop`

# Root + Impact ## Description * Little typo on `MESSAGE_TYPEHASH` Declaration on `SnowmanAirdrop` contract ```Solidity // src/SnowmanAirdrop.sol 49: bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)"); ``` **Impact**: * `function claimSnowman` never be `TRUE` condition ## Proof of Concept Applying this function at the end of /test/TestSnowmanAirdrop.t.sol to know what the correct and wrong digest output HASH. Ran with command: `forge test --match-test testFrontendSignatureVerification -vvvv` ```Solidity function testFrontendSignatureVerification() public { // Setup Alice for the test vm.startPrank(alice); snow.approve(address(airdrop), 1); vm.stopPrank(); // Simulate frontend using the correct format bytes32 FRONTEND_MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount)"); // Domain separator used by frontend (per EIP-712) bytes32 DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256("Snowman Airdrop"), keccak256("1"), block.chainid, address(airdrop) ) ); // Get Alice's token amount uint256 amount = snow.balanceOf(alice); // Frontend creates hash using the correct format bytes32 structHash = keccak256( abi.encode( FRONTEND_MESSAGE_TYPEHASH, alice, amount ) ); // Frontend creates the final digest (per EIP-712) bytes32 frontendDigest = keccak256( abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR, structHash ) ); // Alice signs the digest created by the frontend (uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, frontendDigest); // Digest created by the contract (with typo) bytes32 contractDigest = airdrop.getMessageHash(alice); // Display both digests for comparison console2.log("Frontend Digest (correct format):"); console2.logBytes32(frontendDigest); console2.log("Contract Digest (with typo):"); console2.logBytes32(contractDigest); // Compare the digests - they should differ due to the typo assertFalse( frontendDigest == contractDigest, "Digests should differ due to typo in MESSAGE_TYPEHASH" ); // Attempt to claim with the signature - should fail vm.prank(satoshi); vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector); airdrop.claimSnowman(alice, AL_PROOF, v, r, s); assertEq(nft.balanceOf(alice), 0); } ``` ## Recommended Mitigation on contract `SnowmanAirdrop` Line 49 applying this: ```diff - bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)"); + bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount)"); ```

Support

FAQs

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

Give us feedback!