MESSAGE_TYPEHASH hashes a misspelled, non-standard type string, so no EIP-712 wallet signature can ever verify and standard-flow claims always revertSnowmanAirdrop inherits OpenZeppelin's EIP712 and builds the claim digest with _hashTypedDataV4 (src/SnowmanAirdrop.sol:26, :119-121). The comment on its typehash says it is "used for EIP-712 compliant message signing". But the string it hashes isn't the struct's EIP-712 type string. It misspells address as addres and adds a space after the comma:
| Type string | keccak256 |
|
|---|---|---|
The contract (:49) |
SnowmanClaim(addres receiver, uint256 amount) |
0xff59e96f…54f9ab |
EIP-712, as forge eip712 derives it from the struct at :36-39 |
SnowmanClaim(address receiver,uint256 amount) |
0x8cc878fc…88044d |
A wallet answering an eth_signTypedData_v4 request derives the type string from the struct definition, so it signs a different digest from the one getMessageHash returns. claimSnowman always checks the signature against getMessageHash (:80-82), and _isValidSignature has no other digest to try (:102-109), so a valid signature from the receiver is rejected with SA__InvalidSignature.
No typed-data request can be shaped to produce the contract's digest, under EIP-712's encodeType rule. A type string is the struct name, then type name for each member joined by ,, then every other struct it references, appended. The literal has one ( and ends at the first ), so nothing can be appended to it. The only struct a member's type could then be is SnowmanClaim itself, and no way of splitting the literal gives a member that type. However the literal is split into members, the first member's type is addres or a longer string containing a space or comma, and none of those is a valid EIP-712 type. A second constraint holds even for a lenient encoder: the contract hashes three words (typehash, receiver, amount), and each EIP-712 member encodes to one word, so a matching type needs exactly two members.
The domain is correct: eip712Domain() reports it, and the digest uses it. Only the type string is wrong, and as a compiled-in constant it can't be changed after deployment.
On the code as written, every claim needs an accepted signature from the receiver, because claimSnowman has no route that skips the check. So there's no other claim route for a holder whose wallet signs the standard way. The only signature that works is one over the raw output of getMessageHash, which is what the repo's own test does with vm.sign. The README doesn't tell users that's required: it only says claims use "the recipient's v, r, s signatures" (README.md:33).
Likelihood: High. It happens on every claim made through a wallet's standard typed-data flow, for every listed account, from deployment on. No attacker is needed. On the committed allowlist, all five accounts get 0 NFTs through typed data, and 5 once they sign the raw digest instead (test_F002_everyAccountOnTheCommittedListIsRejected below).
Impact: Medium. Holders on the standard path can't claim, which disrupts the protocol's main function. No funds are lost: a rejected claim moves nothing, and the Snow stays with the holder. The one working route needs a signer that will sign a raw 32-byte hash:
Context, not reproduced in the PoC below: cast wallet sign --no-hash with a software key signs the raw digest, and in the audit's own tests its output claimed, while cast wallet sign --data over the typed data was rejected. The PoC's raw-digest control signs the same digest with vm.sign.
MetaMask's improvement proposal MIP-3 says eth_sign "was disabled by default, but could be re-enabled in MetaMask's settings", and proposes removing it entirely. It's listed as Accepted, which doesn't show the setting has been removed, and it also names older MetaMask versions and alternative wallets as remaining options.
MetaMask's signing docs say Ledger and Trezor sign data only with personal_sign. By reading, that signs a prefixed hash ("\x19Ethereum Signed Message:\n32" plus the digest), not the digest the contract checks, so it can't produce an accepted signature. So those users have no raw route either.
So in practice the users who can claim are those with a command-line signer and a software key, or a wallet that still allows raw signing. The project ships no frontend, but the harm doesn't depend on one: a valid EIP-712 signature from the receiver is rejected, and that's the adverse effect of the non-compliance.
These two axes give the High/Medium band in the CodeHawks matrix, and this report claims Medium.
Save the file below as test/F002MalformedTypehash.t.sol in the contest repository and run, from the repository root (no fork needed):
It imports only the repository's own src/ and script/ files, forge-std, and OpenZeppelin from lib/. It deploys the system in its own setUp with the repo's DeploySnowmanAirdrop script, so all the contracts are the real ones, with the committed allowlist, and nothing is mocked or stubbed. The wallet's digest comes from Foundry's EIP-712 encoder (vm.eip712HashTypedData), given the eth_signTypedData_v4 JSON a frontend would send: the struct exactly as SnowmanAirdrop declares it, and the domain read from the airdrop's own eip712Domain(). No wallet application was run; the encoder stands in for one. The defect is a string literal, so it doesn't depend on the OpenZeppelin version. Actors:
Attacker: none. The harm is a rejected claim.
Victim: alice, the committed list's first account, signing the standard way.
Protocol: SnowmanAirdrop, with Snow and Snowman. relayer is a neutral third party who submits claims.
Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:
The wallet signs 0x7d7285c4…091d00d8, while the contract checks 0xee53487b…a66c284c9. Alice's typed-data signature is hers, and OpenZeppelin's ECDSA.tryRecover finds no fault with it. Yet the claim reverts whether a relayer submits it or she does, and nothing moves. The same claim signed over the raw getMessageHash output succeeds. Changing only line 49 to the EIP-712 type string makes the two digests equal.
test_F002_everyAccountOnTheCommittedListIsRejected repeats this for all five accounts on the committed allowlist: 0 NFTs through typed data, then 5 once each signs the raw digest instead.
Use the struct's EIP-712 type string:
Notes:
Tested: this change was checked on a patched copy of SnowmanAirdrop. The same wallet signature now claims, relayed or self-submitted, and so does cast wallet sign --data output. All five listed accounts claim, and signing getMessageHash output directly still works.
Tests: stop signing getMessageHash output directly in the repo's tests. Add one that compares MESSAGE_TYPEHASH with the value forge eip712 derives, and one that signs real encoder output.
Deployment: the constant is compiled in and nothing is upgradeable, so fixing a deployed airdrop means deploying a new one.
# 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)"); ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.