[H-1]Replay Attack in claimSnowman
Allows Multiple NFT Claims Due to Missing s_hasClaimedSnowman
Check
Description
-
Normal Behavior: The SnowmanAirdrop
contract is designed to allow eligible users (those included in a Merkle tree and possessing the required Snow tokens) to claim a Snowman NFT a single time. The claimSnowman
function validates a user's Merkle proof and their signature, transfers their Snow tokens to the contract, mints them an NFT, and then records that the user has claimed by setting s_hasClaimedSnowman[receiver]
to true
.
-
Specific Issue: The claimSnowman
function does not check the s_hasClaimedSnowman[receiver]
status at the beginning of the function call. If a user re-acquires the exact same amount of Snow tokens they used for their initial successful claim, the original (and previously valid) signature and Merkle proof can be re-submitted by anyone. This allows the user to claim NFTs multiple times, bypassing the intended one-claim-per-user mechanism.
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();
}
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
}
Risk
Likelihood: High
-
A legitimate user successfully claims their Snowman NFT using their signature and a valid Merkle proof.
-
The same user re-acquires the exact quantity of Snow tokens they staked for the initial claim (e.g., via the earnSnow()
function in Snow.sol
after the cooldown, by purchasing more, or receiving a transfer).
-
The original signature and Merkle proof (which are now valid again because the user's Snow balance matches the state at the time of original signing and Merkle leaf generation) are re-submitted to the claimSnowman
function by any party (the user themselves or a third party like "satoshi" in the test).
Impact: High
-
Unintended NFT Inflation & Value Dilution: Users can exploit this to mint more Snowman NFTs than they are entitled to according to the Merkle airdrop rules. This inflates the NFT supply beyond what was intended, potentially devaluing the NFTs for all holders.
-
Compromise of Airdrop Fairness: The core principle of a Merkle airdrop is to ensure a fair, one-time distribution to a specific set of users with specific allocations. This vulnerability breaks that fairness, allowing some users to receive multiple allocations.
-
Excessive Token Staking/Burning: The Snow tokens intended to be staked (and effectively burned or locked in the airdrop contract) are processed multiple times for the same original entitlement, leading to more tokens being removed from the user's control and sent to the airdrop contract than designed.
Proof of Concept
The following test case from TestSnowmanAirdrop.t.sol
demonstrates the replay attack. Alice successfully claims an NFT, then re-acquires the necessary Snow tokens, and the original claim data is used to claim a second NFT for her.
function testReplayAttackOnClaimSnowman() public {
assertEq(snow.balanceOf(alice), 1, "PRE-REQ: Alice should have 1 wei Snow before first claim");
uint256 initialAirdropSnowBalance = snow.balanceOf(address(airdrop));
uint256 initialAliceNftId = nft.getTokenCounter();
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 alDigestOriginal = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigestOriginal);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assertEq(nft.balanceOf(alice), 1);
assertEq(snow.balanceOf(alice), 0);
assertTrue(airdrop.getClaimStatus(alice));
assertEq(nft.ownerOf(initialAliceNftId), alice);
assertEq(snow.balanceOf(address(airdrop)), initialAirdropSnowBalance + 1);
vm.warp(block.timestamp + 1 weeks + 1 seconds);
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1, "Alice's Snow balance should be 1 wei again for replay attempt");
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 alDigestForReplay = airdrop.getMessageHash(alice);
assertEq(alDigestForReplay, alDigestOriginal, "Digest for replay should match original digest if balance is restored");
uint256 nftBalanceBeforeReplay = nft.balanceOf(alice);
uint256 snowBalanceOfAliceBeforeReplay = snow.balanceOf(alice);
uint256 airdropSnowBalanceBeforeReplayAttempt = snow.balanceOf(address(airdrop));
uint256 nextNftId = nft.getTokenCounter();
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assertEq(nft.balanceOf(alice), nftBalanceBeforeReplay + 1, "VULNERABILITY: Alice received an additional NFT due to replay!");
assertEq(snow.balanceOf(alice), snowBalanceOfAliceBeforeReplay - 1, "VULNERABILITY: Alice's Snow balance decreased again due to replay!");
assertEq(nft.ownerOf(nextNftId), alice, "VULNERABILITY: Alice should own the second NFT from replay");
assertTrue(airdrop.getClaimStatus(alice), "Alice's claim status remains true");
assertEq(snow.balanceOf(address(airdrop)), airdropSnowBalanceBeforeReplayAttempt + 1, "VULNERABILITY: Airdrop contract Snow balance increased again from replay");
}
Recommended Mitigation
Add a check at the beginning of the claimSnowman
function to ensure that the receiver
has not already claimed their NFT. This can be done by checking the s_hasClaimedSnowman[receiver]
mapping.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// ... imports ...
contract SnowmanAirdrop is EIP712, ReentrancyGuard {
using SafeERC20 for Snow;
// >>> ERRORS
error SA__InvalidProof();
error SA__InvalidSignature();
error SA__ZeroAddress();
error SA__ZeroAmount();
+ error SA__AlreadyClaimed(); // Add new error for already claimed
// ... (struct, variables, event, constructor) ...
mapping(address => bool) private s_hasClaimedSnowman;
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
+ if (s_hasClaimedSnowman[receiver]) {
+ revert SA__AlreadyClaimed();
+ }
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();
}
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
// ... (internal functions, public view functions, getter functions) ...
}
This mitigation ensures that once a user has successfully claimed, any subsequent attempts to claim for the same receiver
address will be immediately rejected, regardless of whether other conditions (like token balance or signature validity) might appear to be met again.