Snowman Merkle Airdrop

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

Replay Attack Vulnerability in claimSnowman() Allows Infinite NFT Minting via Signature Reuse

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:

    1. Providing a valis EIP-712 signature (v,r,s) proving authorization

    2. Submitting a correct Merklee proof verifying eligibility

    3. 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); // send tokens to contract... akin to burning
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:

  • Attackers can drain the snow token of Victim by minting a NFT without victim's intent

  • User who approved the contract could lose all their tokens without consent

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);
// This is not intended to be called by the eli, but attacker Uses the eli's signature and drain the funds
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);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
5 months ago
yeahchibyke Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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