Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Using the recipient's mutable Snow balance allows attackers to deny eligible Snowman claims

Root + Impact

Description

Each Merkle leaf represents a fixed recipient address and a fixed claim amount determined when the Merkle tree is generated.

The claim signature should therefore authorize that fixed amount. Instead, SnowmanAirdrop derives the amount from the recipient's current Snow balance at execution time.

getMessageHash() signs the mutable balance:

function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
@> uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(
abi.encode(
MESSAGE_TYPEHASH,
SnowmanClaim({
receiver: receiver,
amount: amount
})
)
)
);
}

claimSnowman() then reads the balance again and uses it to create the Merkle leaf:

@> uint256 amount = i_snow.balanceOf(receiver);
@> bytes32 leaf =
keccak256(
bytes.concat(
keccak256(
abi.encode(receiver, amount)
)
)
);

Snow is a transferable ERC20 token. An attacker can therefore send even one minimal Snow unit to an eligible recipient after the recipient signs their claim but before the transaction is executed.

This changes the recipient's live balance and causes two failures:

  1. The existing signature becomes invalid because getMessageHash() now includes the modified balance.

  2. A new signature for the modified balance still fails because the Merkle tree contains the recipient's original fixed allocation.

An attacker can monitor the public mempool and repeatedly send Snow to the recipient before each claim attempt, preventing the entitled recipient from claiming.

Risk

Likelihood

  • The attacker must obtain a minimal amount of Snow.

  • Snow is freely transferable through the inherited ERC20 transfer() function.

  • The attacker must identify an eligible recipient and transfer Snow before their claim executes.

  • To maintain the denial after the recipient removes the unwanted balance, the attacker must monitor and repeat the attack.

Because the attack requires preparation and transaction timing, the likelihood is Medium.

Impact

  • An eligible recipient's valid signature is invalidated.

  • Re-signing does not solve the problem because the new amount no longer matches the fixed Merkle leaf.

  • The recipient cannot claim their entitled Snowman NFT while the poisoned balance remains.

  • Repeated front-running can indefinitely censor a targeted recipient's claim.

  • The attacker does not need access to the recipient's private key or approval.

The impact to the affected recipient is High.

Proof of Concept

Create test/ClaimBalanceDoS.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
import {Helper} from "../script/Helper.s.sol";
contract ClaimBalanceDoSTest is Test {
Snow internal snow;
Snowman internal nft;
SnowmanAirdrop internal airdrop;
MockWETH internal weth;
address internal alice;
uint256 internal aliceKey;
address internal bob;
address internal relayer;
bytes32 internal constant AL_PROOF_A =
0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 internal constant AL_PROOF_B =
0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 internal constant AL_PROOF_C =
0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
function setUp() public {
Helper helper = new Helper();
(airdrop, snow, nft, weth) = helper.run();
(alice, aliceKey) = makeAddrAndKey("alice");
bob = makeAddr("bob");
relayer = makeAddr("relayer");
}
function _aliceProof()
internal
pure
returns (bytes32[] memory proof)
{
proof = new bytes32[](3);
proof[0] = AL_PROOF_A;
proof[1] = AL_PROOF_B;
proof[2] = AL_PROOF_C;
}
function testForcedSnowTransferDeniesClaim() public {
// Alice's Merkle allocation is fixed at one Snow.
assertEq(snow.balanceOf(alice), 1);
assertEq(snow.balanceOf(bob), 1);
vm.prank(alice);
snow.approve(address(airdrop), 2);
// Alice signs while her balance and allocation are both one.
bytes32 originalDigest =
airdrop.getMessageHash(alice);
(uint8 originalV, bytes32 originalR, bytes32 originalS) =
vm.sign(aliceKey, originalDigest);
// Bob forcefully changes Alice's balance before the claim.
vm.prank(bob);
snow.transfer(alice, 1);
assertEq(snow.balanceOf(alice), 2);
// Alice's original signature is now invalid because the
// contract recalculates the digest using balance two.
vm.prank(relayer);
vm.expectRevert(
SnowmanAirdrop.SA__InvalidSignature.selector
);
airdrop.claimSnowman(
alice,
_aliceProof(),
originalV,
originalR,
originalS
);
// Signing the new balance does not fix the claim because
// Alice's Merkle leaf was generated using amount one.
bytes32 poisonedDigest =
airdrop.getMessageHash(alice);
(uint8 poisonedV, bytes32 poisonedR, bytes32 poisonedS) =
vm.sign(aliceKey, poisonedDigest);
vm.prank(relayer);
vm.expectRevert(
SnowmanAirdrop.SA__InvalidProof.selector
);
airdrop.claimSnowman(
alice,
_aliceProof(),
poisonedV,
poisonedR,
poisonedS
);
assertEq(nft.balanceOf(alice), 0);
}
}

Run:

forge test --match-test testForcedSnowTransferDeniesClaim -vv

The test demonstrates that an unsolicited Snow transfer invalidates the original signature. Even after Alice signs the new balance, the claim fails because the modified amount does not match the fixed Merkle leaf.

Recommended Mitigation

Pass the fixed claim amount as an explicit parameter instead of deriving it from the recipient's mutable live balance.

The same explicit amount must be included in:

  • The EIP-712 signature

  • The Merkle leaf

  • The token transfer

  • The NFT mint

function claimSnowman(
address receiver,
+ uint256 amount,
bytes32[] calldata merkleProof,
uint8 v,
bytes32 r,
bytes32 s
)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
- if (i_snow.balanceOf(receiver) == 0) {
+ if (amount == 0) {
revert SA__ZeroAmount();
}
- if (!_isValidSignature(
- receiver,
- getMessageHash(receiver),
- v,
- r,
- s
- )) {
+ if (!_isValidSignature(
+ receiver,
+ getMessageHash(receiver, amount),
+ 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
);
}
- function getMessageHash(address receiver)
+ function getMessageHash(address receiver, uint256 amount)
public
view
returns (bytes32)
{
- if (i_snow.balanceOf(receiver) == 0) {
+ if (amount == 0) {
revert SA__ZeroAmount();
}
- uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(
abi.encode(
MESSAGE_TYPEHASH,
SnowmanClaim({
receiver: receiver,
amount: amount
})
)
)
);
}

This prevents unsolicited token transfers from modifying the signed or Merkle-verified claim amount.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] DoS to a user trying to claim a Snowman

# Root + Impact ## Description * Users will approve a specific amount of Snow to the SnowmanAirdrop and also sign a message with their address and that same amount, in order to be able to claim the NFT * Because the current amount of Snow owned by the user is used in the verification, an attacker could forcefully send Snow to the receiver in a front-running attack, to prevent the receiver from claiming the NFT.  ```Solidity function getMessageHash(address receiver) public view returns (bytes32) { ... // @audit HIGH An attacker could send 1 wei of Snow token to the receiver and invalidate the signature, causing the receiver to never be able to claim their Snowman uint256 amount = i_snow.balanceOf(receiver); return _hashTypedDataV4( keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount}))) ); ``` ## Risk **Likelihood**: * The attacker must purchase Snow and forcefully send it to the receiver in a front-running attack, so the likelihood is Medium **Impact**: * The impact is High as it could lock out the receiver from claiming forever ## Proof of Concept The attack consists on Bob sending an extra Snow token to Alice before Satoshi claims the NFT on behalf of Alice. To showcase the risk, the extra Snow is earned for free by Bob. ```Solidity function testDoSClaimSnowman() public { assert(snow.balanceOf(alice) == 1); // Get alice's digest while the amount is still 1 bytes32 alDigest = airdrop.getMessageHash(alice); // alice signs a message (uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest); vm.startPrank(bob); vm.warp(block.timestamp + 1 weeks); snow.earnSnow(); assert(snow.balanceOf(bob) == 2); snow.transfer(alice, 1); // Alice claim test assert(snow.balanceOf(alice) == 2); vm.startPrank(alice); snow.approve(address(airdrop), 1); // satoshi calls claims on behalf of alice using her signed message vm.startPrank(satoshi); vm.expectRevert(); airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS); } ``` ## Recommended Mitigation Include the amount to be claimed in both `getMessageHash` and `claimSnowman` instead of reading it from the Snow contract. Showing only the new code in the section below ```Python function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant { ... bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { revert SA__InvalidProof(); } // @audit LOW Seems like using the ERC20 permit here would allow for both the delegation of the claim and the transfer of the Snow tokens in one transaction i_snow.safeTransferFrom(receiver, address(this), amount); // send ... } ```

Support

FAQs

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

Give us feedback!