Snowman Merkle Airdrop

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

Add Nonce Protection

Root + Impact

Description

  • The claimSnowman function in SnowmanAirdrop.sol uses EIP712 signatures for claim authorization but lacks nonce-based replay protection. This means each signature can potentially be reused multiple times for the same claim operation.

  • Technical Impact

    • A valid signature can be replayed multiple times

    • The same signature parameters (v, r, s) could be reused to repeatedly call claimSnowman

    • While the s_hasClaimedSnowman mapping prevents multiple successful claims, unnecessary gas is spent processing replayed signatures

    Business Impact

    • Increased gas costs from processing replayed transactions

    • Potential DoS vector if malicious actors continuously replay signatures

    • Deviation from EIP712 best practices, which recommend nonce usage.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SnowmanAirdrop is EIP712, ReentrancyGuard {
// >>> MISSING NONCE MAPPING
@> // mapping(address => uint256) private s_nonces; // Should be added here
struct SnowmanClaim {
address receiver;
uint256 amount;
@> // uint256 nonce; // Should include nonce in the struct
}
bytes32 private constant MESSAGE_TYPEHASH =
@> keccak256("SnowmanClaim(address receiver, uint256 amount)");
// Should be: keccak256("SnowmanClaim(address receiver, uint256 amount, uint256 nonce)")
function claimSnowman(
address receiver,
bytes32[] calldata merkleProof,
uint8 v,
bytes32 r,
bytes32 s
) external nonReentrant {
@> // No nonce validation here
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();
}
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
@> // No nonce increment after successful claim
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
uint256 amount = i_snow.balanceOf(receiver);
@> return _hashTypedDataV4(
keccak256(
abi.encode(
MESSAGE_TYPEHASH,
SnowmanClaim({
receiver: receiver,
amount: amount
@> // nonce: s_nonces[receiver] // Should include nonce here
})
)
)
);
}
}

Risk

Likelihood: Low

  • Every transaction containing a valid signature can be replayed until the user successfully claims their Snowman NFT. The signature parameters (v, r, s) remain valid indefinitely since there's no nonce or timestamp invalidation.

  • Malicious actors monitoring the mempool can easily extract valid signatures from pending transactions and replay them, as the signature validation in _isValidSignature only checks if the signer matches the receiver.

  • The attack requires minimal technical knowledge - simply copying and resubmitting the same transaction parameters will trigger the vulnerability.

Impact:

  • Unnecessary gas consumption for users and network congestion

  • Potential denial of service through transaction spam

  • While actual double-claims are prevented by the claiming status check

  • Deviation from EIP712 security standards, which explicitly recommend nonce usage to prevent replay attacks. This reduces the contract's security posture and trustworthiness.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./test/TestSnowmanAirdrop.t.sol";
contract SnowmanAirdropReplayTest is Test {
SnowmanAirdrop public airdrop;
Snow public snowToken;
Snowman public snowmanNFT;
address public alice = address(0x1);
uint256 public alicePrivateKey = 0x1;
bytes32[] public merkleProof;
function setUp() public {
// Deploy contracts
snowToken = new Snow();
snowmanNFT = new Snowman();
// Create merkle tree and root
bytes32 merkleRoot = bytes32(0x123...); // Example root
airdrop = new SnowmanAirdrop(merkleRoot, address(snowToken), address(snowmanNFT));
// Setup Alice's account
vm.deal(alice, 100 ether);
snowToken.transfer(alice, 1000);
// Generate valid merkle proof for Alice
merkleProof = generateMerkleProof(alice, 1000);
}
function testSignatureReplay() public {
// 1. Generate valid signature for Alice
bytes32 messageHash = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePrivateKey, messageHash);
// 2. First claim attempt - Should succeed
vm.startPrank(alice);
snowToken.approve(address(airdrop), 1000);
airdrop.claimSnowman(alice, merkleProof, v, r, s);
// Verify first claim succeeded
assertTrue(airdrop.getClaimStatus(alice));
assertEq(snowmanNFT.balanceOf(alice), 1000);
// 3. Replay attack - Same signature can be resubmitted
bytes memory replayCalldata = abi.encodeWithSelector(
airdrop.claimSnowman.selector,
alice,
merkleProof,
v,
r,
s
);
// Multiple replays possible
for (uint256 i = 0; i < 3; i++) {
// Each replay will process signature validation before reverting
(bool success,) = address(airdrop).call(replayCalldata);
assertFalse(success); // Reverts but only after processing signature
}
vm.stopPrank();
}
function testReplayGasWaste() public {
// Setup same as above...
// Measure gas for first valid claim
uint256 gasStart = gasleft();
airdrop.claimSnowman(alice, merkleProof, v, r, s);
uint256 gasUsed = gasStart - gasleft();
// Measure gas for replay attempt
gasStart = gasleft();
try airdrop.claimSnowman(alice, merkleProof, v, r, s) {
// Should not reach here
} catch {
uint256 replayGasUsed = gasStart - gasleft();
// Demonstrate significant gas still used on replay
assertTrue(replayGasUsed > 30000); // Arbitrary threshold
}
}
function testMempoolReplay() public {
// Simulate mempool monitoring
vm.startPrank(address(0xATTACKER));
// Extract signature from pending tx
vm.createSelectFork(block.number);
bytes memory pendingTx = vm.getTransactionByHash(txHash);
(address receiver, bytes32[] memory proof, uint8 v, bytes32 r, bytes32 s) =
abi.decode(pendingTx[4:], (address, bytes32[], uint8, bytes32, bytes32));
// Replay extracted signature
airdrop.claimSnowman(receiver, proof, v, r, s);
vm.stopPrank();
}
}

Recommended Mitigation

// In SnowmanAirdrop.sol
// Add nonce mapping
+ mapping(address => uint256) private s_nonces;
// Update struct
struct SnowmanClaim {
address receiver;
uint256 amount;
- // No nonce field
+ uint256 nonce;
}
// Update message type hash
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount, uint256 nonce)");
// Update claim function signature
- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(
+ address receiver,
+ bytes32[] calldata merkleProof,
+ uint8 v,
+ bytes32 r,
+ bytes32 s,
+ uint256 nonce
+ )
external
nonReentrant
{
+ // Verify nonce
+ if (nonce != s_nonces[receiver]) revert SA__InvalidNonce();
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
// Rest of validations...
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
+ s_nonces[receiver]++;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
// Update message hash generation
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(
abi.encode(
MESSAGE_TYPEHASH,
SnowmanClaim({
receiver: receiver,
amount: amount,
- // No nonce
+ nonce: s_nonces[receiver]
})
)
)
);
}
// Add new error
+ error SA__InvalidNonce();
// Add new event for nonce updates
+ event NonceIncremented(address indexed user, uint256 newNonce);
Updates

Lead Judging Commences

yeahchibyke Lead Judge 27 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.