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.
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.
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.
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).
# 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 ... } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.