Lack of replay protection in SnowmanAirdrop.sol::claimSnowman() allows NFT Minting via Signature reuse
Description
-
The claimSnowman() function in snowmanAirdrop.sol is designed to allow users to claim an NFT by:
Providing a valis EIP-712 signature (v,r,s) proving authorization
Submitting a correct Merklee proof verifying eligibility
Transforming their i_snow tokens to the contract
-
The function lacks replay protection on the EIP-712 signature, An attacker can:
i. Intercept a valid signature
ii. Reuse it repeatedly to call claimSnowman()
iii. Mint multiple NFTs for the same user (if the Merklee proof remains valid) or drain the tokens (if i_snow transfer are not restricted)
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:
-
Once a user signs an EIP-712 message for claimSnowman(), the signature(v,r,s) becomes visible on-chain or in the memepool
-
Attackers can easily extract and reuse signatures from past transactions, as there is no mechanism to invalidate them.
Impact:
Proof of Concept
The below code demonstrates that , how a attacker can use the eli's signature (v,r,s), to mint the NFT without eli's consent and drain snow tokens
function testReplayingSignatureByAttackerToDrainVictimFunds() public {
address attacker = makeAddr("attacker");
vm.prank(eli);
snow.approve(address(airdrop), 1);
bytes32 eliDigest = airdrop.getMessageHash(eli);
vm.prank(attacker);
(uint8 eliV, bytes32 eliR, bytes32 eliS) = vm.sign(eliKey, eliDigest);
airdrop.claimSnowman(eli, ELI_PROOF, eliV, eliR, eliS);
assert(nft.balanceOf(eli) == 1);
}
Recommended Mitigation
Option 1. In `SnowmanAirDrop:claimSnowman` change `i_snow.safeTransferFrom(receiver, address(this), amount)` like `i_snow.safeTransferFrom(msg.sender, address(this), amount)`. so that Attackers must spend their own tokens (WETH/ERC20) for each attempt which acts a security barrier.
Option 2. Include a per-user nonce in the signed payload and contract (e.g., mapping(address => uint256) nonces;) to ensure each signature is unique and can only be replayed once
Option 3. Require the nonce to match before minting and increment it immediately after use to prevent reuse even on failed transactions
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); // send tokens to contract... akin to burning
+ i_snow.safeTransferFrom(msg.sender, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}