Snowman Merkle Airdrop

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

State Update Order Allows Reentrancy Risk if Guard Removed

Description

An external call (safeTransferFrom) is made before updating the internal state (s_hasClaimedSnowman), which is generally discouraged. While the contract uses OpenZeppelin's nonReentrant modifier — which effectively prevents reentrancy in the current implementation — **it is **recommended to update critical state before performing any external calls.

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:

  • This issue will occur when the nonReentrant modifier is removed (e.g., during refactoring or for testing) and a malicious ERC20 token is used that reenters claimSnowman via the transferFrom call.

  • It can also occur during future upgrades, forks, or contract reuse where developers unintentionally remove or bypass the reentrancy protection, or if they rely solely on the OpenZeppelin SafeERC20 wrapper assuming it's safe against all behaviors (which it is not when used with malicious tokens).

Impact:

  • A malicious actor could claim the Snowman NFT multiple times in a single transaction, bypassing the one-time claim restriction and minting more NFTs than allowed.

  • This would lead to a broken distribution model, economic imbalance in the system (over-minting), and a potential loss of trust from users who cannot claim their rightful NFTs due to exhausted supply or unfair advantage.

Proof of Concept

contract MaliciousSnow is ERC20 {
address public airdrop;
bool public reentered;
constructor(address _airdrop) ERC20("Malicious", "MAL") {
airdrop = _airdrop;
_mint(msg.sender, 100 ether);
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
super._transfer(from, to, amount);
if (!reentered) {
reentered = true;
// Try to reenter airdrop
ISnowmanAirdrop(airdrop).claimSnowman(
from,
validMerkleProof, // Replace with actual valid proof
v, r, s // Replace with valid EIP-712 signature
);
}
return true;
}
}
function test_ReentrancyClaim() public {
// Setup: deploy SnowmanAirdrop, Snowman, MaliciousSnow
// Mint tokens to attacker
// Construct valid EIP-712 signature and Merkle proof
vm.prank(attacker);
snow.approve(airdrop.address, amount);
// Call claim once, but expect it to reenter and allow multiple claims
airdrop.claimSnowman(attacker, proof, v, r, s);
assertEq(snowman.balanceOf(attacker), expectedDoubleMint); // Should be 2x if reentrancy worked
}

Recommended Mitigation

Move the state update before the external call, ensuring that even if reentrancy were somehow possible in the future, it wouldn’t result in duplicate claims

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;
// Mitigation: Mark as claimed *before* external call
+ s_hasClaimedSnowman[receiver] = true;
+ i_snow.safeTransferFrom(receiver, address(this), amount);
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 10 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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