Snowman Merkle Airdrop

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

Missing Signature Replay Protection in SnowmanAirdrop Contract

Root + Impact

Description

The SnowmanAirdrop contract uses signatures to authorize users to claim Snowman NFTs. When a user calls the claimSnowman function, they provide a signature that should be verified against the signer's address.

However, the contract only tracks which addresses have claimed NFTs (using s_hasClaimedSnowman[receiver]) but doesn't track which signatures have been used. This creates a vulnerability where signatures could potentially be replayed in certain scenarios, such as if the contract is redeployed with the same parameters or if multiple users attempt to use the same signature.

// Root cause in the codebase with @> marks to highlight the relevant section
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();
}
@> // The contract only checks if the receiver has claimed, not if this signature has been used before
@> s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Risk

Likelihood:

  • The vulnerability will occur when the contract is redeployed with the same signer address but a fresh state. Since the signature verification only depends on the message and signer, previously used signatures would be valid again.

  • The vulnerability also occurs in scenarios where multiple users have access to the same signature. Since the contract only checks if a receiver address has claimed, different users could potentially use the same signature if they haven't claimed yet.

Impact:

  • Users could claim NFTs multiple times across different contract deployments using the same signature, potentially receiving more NFTs than intended by the protocol.

  • In a scenario where signatures are shared or leaked, multiple users could claim using the same authorization signature, bypassing the intended authorization flow where each claim should have a unique signature from the authorized signer.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
contract SignatureReplayTest is Test {
address user;
uint256 userPrivateKey;
function setUp() public {
(user, userPrivateKey) = makeAddrAndKey("user");
}
function testSignatureReplayVulnerability() public {
// Create two mock verifier contracts
MockSignatureVerifier verifier1 = new MockSignatureVerifier();
MockSignatureVerifier verifier2 = new MockSignatureVerifier();
// Create a signature for the user
bytes32 messageHash = keccak256(abi.encodePacked(user, uint256(100)));
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, digest);
// Use signature on first contract
vm.prank(user);
verifier1.processClaim(user, v, r, s);
assertTrue(verifier1.hasClaimed(user));
// Use SAME signature on second contract
vm.prank(user);
verifier2.processClaim(user, v, r, s);
assertTrue(verifier2.hasClaimed(user));
// Vulnerability confirmed: same signature works on different contracts
}
}
// Simplified mock to demonstrate the vulnerability
contract MockSignatureVerifier {
mapping(address => bool) public hasClaimed;
function processClaim(address user, uint8 v, bytes32 r, bytes32 s) external {
bytes32 messageHash = keccak256(abi.encodePacked(user, uint256(100)));
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
require(ecrecover(digest, v, r, s) == user, "Invalid signature");
// Only tracks if user has claimed, not the signature itself
hasClaimed[user] = true;
}
}

Recommended Mitigation

// Recommended Mitigation
1. Add a nonce to the signature data:
```solidity
// Add nonce tracking
mapping(address => uint256) private s_userNonces;
// Update message hash to include nonce
function getMessageHash(address receiver) public view returns (bytes32) {
uint256 nonce = s_userNonces[receiver];
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, nonce))
);
}
// Increment nonce after successful claim
function claimSnowman(...) external {
// Verify signature...
s_userNonces[receiver]++;
// Rest of function...
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
20 days ago
yeahchibyke Lead Judge 20 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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