According to the business requirements, users should be able to:
Earn Snow tokens for free once a week during the 12-week farming period
Buy Snow tokens at anytime during the farming period
Stake their Snow tokens in the SnowmanAirdrop contract to receive Snowman NFTs
However, the SnowmanAirdrop contract uses the current Snow token balance for both signature validation and merkle leaf generation during the claim process. This creates a critical mismatch because:
The merkle tree is generated off-chain with fixed (address, amount) pairs at a specific point in time
Users sign messages with their balance at the time of signing
Claim verification uses the balance at the time of claiming
If a user's Snow token balance changes between merkle tree generation and claim execution (by earning, buying, or transferring tokens), both the signature validation and merkle proof verification will fail, making it impossible to claim their NFTs.
In SnowmanAirdrop.sol - Balance-dependent message hash:
In SnowmanAirdrop.sol:69-98 - Balance-dependent claim verification:
The protocol encourages users to accumulate Snow tokens (earn weekly, buy anytime), but doing so breaks the claiming mechanism entirely.
Likelihood:
High: The vulnerability is guaranteed to occur in normal protocol operation:
12-week farming period means users WILL earn additional Snow tokens (once per week)
Users are incentivized to buy Snow tokens to increase their NFT allocation
Any token transfer changes the balance
The protocol explicitly allows and encourages these actions
Time delay between merkle tree generation and claiming is unavoidable
Impact:
Critical: Complete protocol failure affecting core functionality
Users cannot claim Snowman NFTs if their Snow balance changes after merkle tree generation
The main value proposition (earn/buy Snow → stake for NFTs) is broken
Users must have EXACTLY the same balance as recorded in the merkle tree
No recovery mechanism exists - signatures and merkle proofs become permanently invalid
All users who follow the intended protocol flow (earning/buying Snow) are affected
12-week farming period makes this issue unavoidable for active participants
Protocol becomes non-functional for its stated purpose
The test demonstrates that when a user's balance changes after signing, the claim fails even with valid merkle proof and signature:
Expected behavior: Alice should be able to claim her Snowman NFT after earning additional Snow tokens, as this is the intended protocol flow.
Actual behavior: The claim transaction reverts with SA__InvalidSignature because:
The signature was created when amount = 1
The verification uses current balance where amount = 2
The signature check: keccak256(receiver, 1) ≠ keccak256(receiver, 2)
Even if signature passed, merkle verification would fail for the same reason
Attack scenario / Normal usage scenario:
Pass the amount as a parameter.
Modify the contract to accept the amount as a parameter rather than reading the current balance. This allows users to claim based on their balance at the time of merkle tree generation:
# 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.