`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
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:
Use of address and snapshotted SNOW amounts in `SnowMerkle.s.sol::run` during Merkle leaf creation:
Using the receiver's current balance and address to verify against the Merkle proof in `SnowmanAirdrop::claimSnowman`:
This also affects the `SnowmanAirdrop::getMessageHash` function in the same manner as this also derives the digest from the current balance:
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.
Add the following test to `TestSnowmanAirdrop.t.sol`:
Run the test in the terminal:
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:
Run the test in the terminal:
If the test passes, the griefing exploit is possible.
To avoid this outcome, have the snapshot `amount` be explicitly passed into the `SnowmanAirdrop::claimSnowman` function:
`SnowmanAirdrop::getMessageHash` needs to also be modified to accept an explicitly provided `amount` as a function input:
# 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.