Snowman Merkle Airdrop

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

`SnowmanAirdrop::claimSnowman` derives amount from live SNOW balance instead of the merkle-snapshotted value, causing legitimate claims to revert for any user whose balance has changed since snapshot

`claimSnowman` derives amount from live SNOW balance instead of the merkle-snapshotted value, causing legitimate claims to revert for any user whose balance has changed since snapshot

Description

The merkle tree used by `SnowmanAirdrop` is generated off-chain from a fixed `(address, amount)` snapshot. `SnowMerkle.s.sol` reads `input.json` once and commits those exact values into the tree. However, `claimSnowman` does not use this snapshotted amount. It re-derives amount live from `i_snow.balanceOf(receiver)` at the moment of claiming, then reconstructs a leaf from that live value and checks it against the merkle root.


A snapshot example `input.json` containing the airdrop recipient addresses and snapshotted SNOW aounts:

{
"types": ["address", "uint256"],
"count": 5,
"values": {
"0": { "0": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", "1": "1" },
"1": { "0": "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", "1": "1" },
"2": { "0": "0xE55d6ba4bE0A6E0D87c4cA26B7C80779573Dc674", "1": "1" },
"3": { "0": "0xb72116984E306d834a0ae638688Ef9AF1f7FE2cd", "1": "1" },
"4": { "0": "0xEa61F454C2B4A5A16AB556DBE8DBB176C1D02177", "1": "1" }
}
}

Use of address and snapshotted SNOW amounts in `SnowMerkle.s.sol::run` during Merkle leaf creation:


// Create the hash for the merkle tree leaf node
// abi encode the data array (each element is a bytes32 representation for the address and the amount)
// Helper from Murky (ltrim64) Returns the bytes with the first 64 bytes removed
// ltrim64 removes the offset and length from the encoded bytes. There is an offset because the array
// is declared in memory
// hash the encoded address and amount
// bytes.concat turns from bytes32 to bytes
// hash again because preimage attack
leafs[i] = keccak256(bytes.concat(keccak256(ltrim64(abi.encode(data)))));

Using the receiver's current balance and address to verify against the Merkle proof in `SnowmanAirdrop::claimSnowman`:


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();
}

This also affects the `SnowmanAirdrop::getMessageHash` function in the same manner as this also derives the digest from the current 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})))
);
}


Risk

Likelihood:

  • High. This isn't attacker-triggered. This is the default expected outcome for any claimant who interacts with Snow.sol (earning, buying, or transferring SNOW) at any point between snapshot generation and their claim transaction — an entirely ordinary, non-adversarial sequence of events.

Impact:

  • Since the merkle root only contains leaves for the original snapshotted amounts, any claimant whose SNOW balance has changed at all since the snapshot for any reason will have their live-computed leaf fail `MerkleProof.verify`. This will block them from claiming their legitimately allocated airdrop. A claimant's signed message may not match what their merkle proof was built for, compounding the failure.


  • High impact (permanent denial of legitimate, allocated claims) combined with high likelihood (this is the common case, not a rare precondition) places this at High. This also creates a front-running griefing-style attack where the attacker can send small amounts of SNOW to the claimant addresses just before the balance read to deny the user of their airdrop.


Proof of Concept


Add the following test to `TestSnowmanAirdrop.t.sol`:

function test_LegitimateClaimFailsAfterBalanceChanges() public {
// Alice's snapshotted allocation is 1 SNOW, matching AL_PROOF's leaf.
// She earns additional SNOW before claiming
vm.warp(block.timestamp + 1 weeks); // clear canFarmSnow cooldown
vm.prank(alice);
snow.earnSnow(); // alice's balance is now > 1, drifted from the snapshot
vm.prank(alice);
snow.approve(address(airdrop), snow.balanceOf(alice));
bytes32 alDigest = airdrop.getMessageHash(alice); // computed against NEW, drifted balance
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
// Claim reverts: leaf built from drifted balance was never in the tree.
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}

Run the test in the terminal:


forge test --mt test_LegitimateClaimFailsAfterBalanceChanges -vvvv

If the test passes, the transaction reverted due to current balance and snapshot balance mismatch.


Also for the grief attack PoC, add this test to the test suite:

/// @notice Demonstrates the griefing is REPEATABLE — the attacker can keep
/// doing this indefinitely to permanently deny Alice's claim, not
/// just delay it once.
function test_FrontRunningCanBeRepeatedIndefinitely() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
address griefer = makeAddr("griefer");
// Simulate 3 repeated griefing rounds — each representing a new attempt
// by Alice to claim, each time front-run again before her tx lands.
for (uint256 round = 0; round < 3; round++) {
vm.warp(block.timestamp + 1 weeks); // clear griefer's earnSnow cooldown each round
vm.prank(griefer);
snow.earnSnow();
vm.prank(griefer);
snow.transfer(alice, 1); // griefs Alice's balance again
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}
// After 3 rounds of griefing, Alice still has zero Snowman NFTs,
// demonstrating this isn't a one-time inconvenience but a durable,
// repeatable denial-of-service against a specific claimant.
assertEq(nft.balanceOf(alice), 0, "Alice remains permanently unable to claim while targeted");
}

Run the test in the terminal:

forge test --mt test_FrontRunningCanBeRepeatedIndefinitely -vvvv

If the test passes, the griefing exploit is possible.

Recommended Mitigation


To avoid this outcome, have the snapshot `amount` be explicitly passed into the `SnowmanAirdrop::claimSnowman` function:


function claimSnowman(
address receiver,
+ uint256 amount, // the snapshotted amount, passed explicitly by the claimant/relayer
bytes32[] calldata merkleProof,
uint8 v,
bytes32 r,
bytes32 s
) external nonReentrant {
if (receiver == address(0)) revert SA__ZeroAddress();
if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
- 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();
}
s_hasClaimedSnowman[receiver] = true;
i_snow.safeTransferFrom(receiver, address(this), amount);
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

`SnowmanAirdrop::getMessageHash` needs to also be modified to accept an explicitly provided `amount` as a function input:

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

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!