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.
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();
}
@>
@> 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
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 {
MockSignatureVerifier verifier1 = new MockSignatureVerifier();
MockSignatureVerifier verifier2 = new MockSignatureVerifier();
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);
vm.prank(user);
verifier1.processClaim(user, v, r, s);
assertTrue(verifier1.hasClaimed(user));
vm.prank(user);
verifier2.processClaim(user, v, r, s);
assertTrue(verifier2.hasClaimed(user));
}
}
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");
hasClaimed[user] = true;
}
}
Recommended Mitigation
1. Add a nonce to the signature data:
```solidity
mapping(address => uint256) private s_userNonces;
function getMessageHash(address receiver) public view returns (bytes32) {
uint256 nonce = s_userNonces[receiver];
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, nonce))
);
}
function claimSnowman(...) external {
s_userNonces[receiver]++;
}