Snowman Merkle Airdrop

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

[M-02] Use of Strict Equality for Balance Checks in SnowmanAirdrop Enables Denial of Service (DoS) Attacks

Root + Impact

Description

  • 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.

// File: src/SnowmanAirdrop.sol
function claimSnowman(address receiver, ...) external {
// @> VULNERABILITY: Strict equality check on balance
// This can be easily manipulated by external actors sending "dust" tokens.
if (i_snow.balanceOf(receiver) == 0) { // Line 76
// ... logic ...
}
}

Risk

Likelihood

  • 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.

Proof of Concept

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.

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.

Recommended Mitigation

Implementation

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.

+ mapping(address => bool) public s_hasClaimed;
function claimSnowman(address receiver, ...) external {
- if (i_snow.balanceOf(receiver) == 0) {
+ if (!s_hasClaimed[receiver]) {
// ... perform claim ...
+ s_hasClaimed[receiver] = true;
}
}

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.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 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.  ```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!