Snowman Merkle Airdrop

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

SnowmanAirdrop::claimSnowman() reconstructs the Merkle leaf and signature digest from live balance instead of the snapshotted amount, locking out legitimate claimants

Root + Impact

Description

  • A legitimately allocated claimant should be able to claim their Snowman NFT using the original Merkle proof generated at snapshot time, regardless of any ordinary, protocol-permitted activity (such as buying or earning additional Snow tokens) they engage in between the snapshot and their claim.

  • claimSnowman() reconstructs both the signature digest (via getMessageHash()) and the Merkle leaf using i_snow.balanceOf(receiver), read live at call time, rather than the fixed amount that was originally used to build the Merkle tree. If a claimant's Snow balance changes at all between the snapshot and their claim — including through ordinary, intended use of buySnow() or earnSnow() — the freshly computed leaf and digest no longer match what the claimant's original proof and signature correspond to, and the claim fails entirely.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
...
@> uint256 amount = i_snow.balanceOf(receiver); // live balance, not the snapshotted allocation
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
...
}
function getMessageHash(address receiver) public view returns (bytes32) {
...
@> uint256 amount = i_snow.balanceOf(receiver); // same live-read issue affects the signed digest too
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Risk

Likelihood: High

  • This occurs the moment a legitimately allocated claimant engages in any ordinary, protocol-encouraged activity (buying more Snow, earning free Snow weekly, or transferring tokens) between the time their allocation was snapshotted and the time they submit their claim, which is expected behavior rather than unusual behavior.

  • No attacker or adversarial action is required at all; the vulnerability harms honest users purely through the normal, intended use of the protocol's other core functions.

Impact:

  • Legitimately allocated claimants can be permanently denied access to their rightful Snowman NFT allocation, since neither their original proof (now mismatched against their changed balance) nor any proof for their new balance (which was never part of the snapshot tree) can succeed.

  • This undermines trust in the airdrop mechanism as a whole, since eligible users following completely ordinary usage patterns of the protocol may find themselves unable to claim what they were allocated, with no recourse or workaround available to them.

Proof of Concept

This test demonstrates that alice, whose original snapshot allocation entitles her to claim one Snowman NFT, becomes permanently unable to claim after simply calling earnSnow() before submitting her claim.

Running forge test --mt testBalanceChangeLocksOutLegitimateClaimant confirms this passes: the claim reverts with SA__InvalidProof(), and alice is left holding zero Snowman NFTs despite being genuinely eligible for one, purely because her balance no longer matches the amount baked into her original Merkle leaf.

function testBalanceChangeLocksOutLegitimateClaimant() public {
vm.warp(block.timestamp + 1 weeks); // one week has passed since she was funded via earnSnow()
vm.prank(alice);
snow.earnSnow(); // balance increases as she earns again
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);
vm.prank(satoshi);
// reverts because her current balance is no longer the same as it was at snapshot time, so her proof is no longer valid
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assert(nft.balanceOf(alice) == 0);
}

Recommended Mitigation

This mitigation introduces a mapping(address => uint256) populated once at deployment with each claimant's fixed snapshot allocation, and updates both claimSnowman() and getMessageHash() to read from this fixed mapping instead of i_snow.balanceOf(receiver).

+ mapping(address => uint256) private s_snapshotAmount;
+ constructor(bytes32 _merkleRoot, address _snow, address _snowman, address[] memory _claimants, uint256[] memory _amounts) EIP712("Snowman Airdrop", "1") {
...
+ for (uint256 i = 0; i < _claimants.length; i++) {
+ s_snapshotAmount[_claimants[i]] = _amounts[i];
+ }
}
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
...
- uint256 amount = i_snow.balanceOf(receiver);
+ uint256 amount = s_snapshotAmount[receiver];
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
...
- i_snow.safeTransferFrom(receiver, address(this), amount);
+ i_snow.safeTransferFrom(receiver, address(this), amount); // now transfers only the fixed snapshot amount, not the full live balance
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 8 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.&#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!