Snowman Merkle Airdrop

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

Signature Replay in `claimOnBehalf` Allows Attacker to Steal Airdropped NFTs

Root + Impact

The claimOnBehalf function uses a contextless signature hashing scheme. By not including the chainId and the verifyingContract address in the signed hash, it allows a valid signature to be captured and replayed on a different chain or contract, leading to the theft of a user's airdropped NFT.

Description

  • Normal Behavior: A user, who may be unable to pay for gas, signs a message containing their claim details. A third-party relayer submits this signature to the claimOnBehalf function. The contract verifies the signature belongs to the recipient and mints the Snowman NFT to them.

  • The Issue: The cryptographic hash that the user signs is not unique to the contract or the chain. An attacker can trick a user into signing a message for what they believe is a testnet claim. The attacker can then take this signature—which is just a signature of the claim data—and submit it to the mainnet contract to steal the user's real NFT. The mainnet contract approves it because the signature is mathematically valid for the data payload.

function claimOnBehalf(address recipient, uint256 amount, bytes calldata signature) external {
// @> The hash is constructed only from the arguments, with no domain-specific context.
bytes32 messageHash = keccak256(abi.encodePacked(recipient, amount));
// @> The recovery is valid, but the signature itself is replayable across chains/contracts.
address signer = ECDSA.recover(messageHash, signature);
require(signer == recipient, "Invalid signer");
_claim(recipient, amount);
}

Risk

Likelihood:

  • When an attacker tricks a user into signing a message on a phishing site or a testnet.

  • When a user signs a message for any other airdrop that uses the same insecure signature scheme.

Impact:

  • Airdropped NFTs can be claimed by a malicious relayer without the user's consent for the mainnet transaction.

  • Breaks the fundamental security assumption of signature-based claims.

Proof of Concept

An attacker convinces the victim (Alice) to sign a message to claim her airdrop on a "testnet version" of the application. The hash she signs is keccak256(abi.encodePacked(alice_address, amount)). The attacker captures this signature (v,r,s). Since this hash contains no information about the chain or the contract it's for, the attacker can simply submit the same signature to the real SnowmanAirdrop contract on mainnet. The mainnet contract will see it as a valid authorization from Alice and process the claim.

function test_PoC_SignatureReplay() public {
// 1. Attacker computes the contextless hash for Alice's claim.
bytes32 vulnerableHash = keccak256(abi.encodePacked(alice.address, aliceAmount));
// 2. Alice signs it, thinking it's for a harmless purpose (e.g., testnet).
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PRIVATE_KEY, vulnerableHash);
bytes memory signature = abi.encodePacked(r, s, v);
// 3. Attacker takes the signature and replays it on the mainnet contract.
// The transaction succeeds because the signature is mathematically valid.
mainnetAirdrop.claimOnBehalf(alice.address, aliceAmount, aliceProof, signature);
assertEq(snowmanNFT.balanceOf(alice.address), 1);
}

Recommended Mitigation

To prevent all forms of replay attacks, the contract must adopt the EIP-712 standard. This standard creates a unique signature that is cryptographically bound to the specific contract instance and blockchain. This is achieved by creating a domain separator containing the chainId and verifyingContract address, which is mixed into the final hash. A signature created for the testnet contract will now be completely invalid on the mainnet contract, and vice-versa.

// In SnowmanAirdrop.sol, inherit from EIP712 and set the domain
// constructor() EIP712("SnowmanAirdrop", "1") {}
// Replace the insecure hashing with the EIP-712 compliant version
function _getClaimHash(address recipient, uint256 amount) internal view returns (bytes32) {
// _hashTypedDataV4 combines the data with the contract's unique domain separator
bytes32 dataHash = keccak256(abi.encode(
keccak256("Claim(address recipient,uint256 amount)"),
recipient,
amount
));
return _hashTypedDataV4(dataHash);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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