Normal Behavior: The SnowmanAirdrop contract likely uses a balance check to determine if a user has already participated or to enforce specific staking rules. It expects the receiver to have a balance of zero before proceeding with the claim.
Specific Issue: The contract uses a strict equality check (== 0) on the result of i_snow.balanceOf(receiver). In the Ethereum Virtual Machine (EVM), any address can receive tokens from any other address at any time. This allows a malicious actor to send a negligible amount of tokens (1 wei) to a target user, making their balance non-zero and permanently blocking them from calling the function.
Reason 1: Exploiting this vulnerability is extremely cheap. An attacker only needs to pay for the gas of a single transfer of 1 wei of Snow tokens to the victim's address.
Reason 2: There is no way for a user to "refuse" incoming ERC20 tokens, making this an unavoidable attack vector if the contract logic relies on a zero-balance state.
Impact:
Impact 1 (Denial of Service): Legitimate users who are eligible for the airdrop can be "griefed" and blocked from claiming their Snowman NFTs.
Impact 2 (Permanent Block): If the protocol does not have a way for users to reduce their balance back to zero (e.g., a burn function), the affected users are permanently excluded from the specific logic tied to this check.
Preparation: Alice is a legitimate user eligible for the Snowman Airdrop.
Attack: Eve (the attacker) monitors the Merkle Tree or waitlist and identifies Alice as a target.
Execution: Eve calls i_snow.transfer(Alice, 1) (sending 1 wei of Snow).
Failure: Alice attempts to call claimSnowman. The contract checks i_snow.balanceOf(Alice).
Outcome: The check returns 1 (which is != 0). The if condition fails or the transaction reverts, preventing Alice from receiving her NFT.
Instead of relying on the token balance (which is external and manipulatable), use an internal state variable (like a mapping) to track whether a user has already claimed their airdrop.
Explanation
By switching to an internal mapping, you move the "Source of Truth" from the public token balance (which anyone can change) to the contract's private state (which only the contract's logic can change). This makes the protocol immune to "Dust Attacks" and ensures that only valid claims can trigger a state change.
# 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.