Snowman Merkle Airdrop

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

B2: Free buySnow(0) Resets the Global earnSnow Timer (DoS)

B2 — Free buySnow(0) Resets the Global earnSnow Timer, Permanently DoS-ing the Weekly Reward

Description

  • Snow is an ERC-20 that grants one token per week through the public earnSnow function. A single global state variable, s_earnTimer, enforces the one-week cooldown: earnSnow succeeds only when block.timestamp >= s_earnTimer + 1 weeks. Users can also purchase tokens through buySnow with ETH or WETH until the 12-week farming window ends.

  • buySnow accepts amount = 0 and unconditionally executes s_earnTimer = block.timestamp. An attacker can call buySnow(0) with zero value (because msg.value == s_buyFee * 0 == 0, taking the _mint(0) branch) and reset the shared cooldown timer for free. Every earnSnow call then reverts with S__Timer() for the following week; repeating the call once per week locks out the reward for the entire farming period.

function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
@>
@> s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
function earnSnow() external canFarmSnow {
@> if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
s_earnTimer = block.timestamp;
}

Risk

Likelihood: High

  • The attack requires only a call to buySnow(0) with no ETH and no token approval — it succeeds the moment it is submitted after a cooldown expiry.

  • It is repeatable once every 7 days, sustaining the denial of service across the entire 12-week farming window at only gas cost.

Impact: Low–Medium

  • earnSnow becomes permanently unavailable, denying every user the weekly token reward (Denial of Service).

  • The farming incentive is broken at zero economic cost to the attacker, undermining the token distribution design.

  • No funds are stolen; the loss is functional availability of the reward mechanism.

Severity: Low–Medium (High likelihood × Low–Medium impact).

Proof of Concept

The PoC establishes the causal link between buySnow(0) and the DoS:

  1. A victim earns the first token (global timer is now active).

  2. After vm.warp(+1 week), earnSnow succeeds again — this baseline proves the function is usable at that moment.

  3. After the cooldown expires a second time, the attacker calls buySnow(0) with zero value, which takes the _mint(0) branch and resets s_earnTimer to the current block.

  4. earnSnow now reverts with S__Timer() where it would otherwise have succeeded, confirming the reset caused the lockout.

The core attack path uses zero vm.* cheatcodes; vm.warp is used only to advance the one-week cooldown for testing (2 uses, with the reason noted in the header, within the ≤3 budget).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../../src/Snow.sol";
import {MockWETH} from "../../src/mock/MockWETH.sol";
contract B2BuySnowZeroTimerDoSTest is Test {
// cheatcode note: vm.warp only advances the one-week cooldown for testing;
// the core attack (buySnow(0) resetting the global s_earnTimer) uses no vm.*.
function test_buySnow_zero_resets_global_earn_timer() public {
MockWETH weth = new MockWETH();
Snow snow = new Snow(address(weth), 5, address(this));
// Victim earns the first token (global timer is now active)
snow.earnSnow();
assertEq(snow.balanceOf(address(this)), 1);
// Baseline: after the cooldown, earnSnow normally succeeds
vm.warp(block.timestamp + 1 weeks);
snow.earnSnow();
assertEq(snow.balanceOf(address(this)), 2);
// Cooldown expires again
vm.warp(block.timestamp + 1 weeks);
// Attacker calls buySnow(0) with zero value: msg.value == s_buyFee * 0 == 0,
// taking the _mint(0) branch and unconditionally resetting s_earnTimer
snow.buySnow(0);
// Timer was reset: earnSnow now reverts (S__Timer) where it would have succeeded
vm.expectRevert();
snow.earnSnow();
}
}

Verification: the PoC was executed in the local WSL2 network-isolated sandbox with double replay — execution_status = succeeded, verdict = supports (artifact 6e0d39e1-bf9a-55de-a346-afdce2e8fdef, toolchain 7694c3aa…), so the denial of service is confirmed to be reproducible.

Recommended Mitigation

Reject zero-amount purchases so a caller can never reset the shared cooldown for free. Optionally, move the timer update so it only happens on a genuine purchase, and consider tracking the cooldown per user instead of globally so one caller cannot affect all others.

function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) {
+ revert S__ZeroValue();
+ }
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

The zero-amount check alone fully neutralizes the free reset described above, since msg.value == s_buyFee * 0 == 0 is no longer reachable; any subsequent purchase with amount > 0 pays the fee and legitimately updates the timer.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-02] Global Timer Reset in Snow::buySnow Denies Free Claims for All Users

## Description: The `Snow::buySnow` function contains a critical flaw where it resets a global timer `(s_earnTimer)` to the current block timestamp on every invocation. This timer controls eligibility for free token claims via `Snow::earnSnow()`, which requires 1 week to pass since the last timer reset. As a result: Any token purchase `(via buySnow)` blocks all free claims for all users for 7 days Malicious actors can permanently suppress free claims with micro-transactions Contradicts protocol documentation promising **"free weekly claims per user"** ## Impact: * **Complete Denial-of-Service:** Free claim mechanism becomes unusable * **Broken Protocol Incentives:** Undermines core user acquisition strategy * **Economic Damage:** Eliminates promised free distribution channel * **Reputation Harm:** Users perceive protocol as dishonest ```solidity function buySnow(uint256 amount) external payable canFarmSnow { if (msg.value == (s_buyFee * amount)) { _mint(msg.sender, amount); } else { i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount)); _mint(msg.sender, amount); } @> s_earnTimer = block.timestamp; emit SnowBought(msg.sender, amount); } ``` ## Risk **Likelihood**: • Triggered by normal protocol usage (any purchase) • Requires only one transaction every 7 days to maintain blockage • Incentivized attack (low-cost disruption) **Impact**: • Permanent suppression of core protocol feature • Loss of user trust and adoption • Violates documented tokenomics ## Proof of Concept **Attack Scenario:** Permanent Free Claim Suppression * Attacker calls **buySnow(1)** with minimum payment * **s\_earnTimer** sets to current timestamp (T0) * All **earnSnow()** calls revert for **next 7 days** * On day 6, attacker repeats **buySnow(1)** * New timer reset (T1 = T0+6 days) * Free claims blocked until **T1+7 days (total 13 days)** * Repeat step **4 every 6 days → permanent blockage** **Test Case:** ```solidity // Day 0: Deploy contract snow = new Snow(...); // s_earnTimer = 0 // UserA claims successfully snow.earnSnow(); // Success (first claim always allowed) // Day 1: UserB buys 1 token snow.buySnow(1); // Resets global timer to day 1 // Day 2: UserA attempts claim snow.earnSnow(); // Reverts! Requires day 1+7 = day 8 // Day 7: UserC buys 1 token (day 7 < day 1+7) snow.buySnow(1); // Resets timer to day 7 // Day 8: UserA retries snow.earnSnow(); // Still reverts! Now requires day 7+7 = day 14 ``` ## Recommended Mitigation **Step 1:** Remove Global Timer Reset from `buySnow` ```diff function buySnow(uint256 amount) external payable canFarmSnow { // ... existing payment logic ... - s_earnTimer = block.timestamp; emit SnowBought(msg.sender, amount); } ``` **Step 2:** Implement Per-User Timer in `earnSnow` ```solidity // Add new state variable mapping(address => uint256) private s_lastClaimTime; function earnSnow() external canFarmSnow { // Check per-user timer instead of global if (s_lastClaimTime[msg.sender] != 0 && block.timestamp < s_lastClaimTime[msg.sender] + 1 weeks ) { revert S__Timer(); } _mint(msg.sender, 1); s_lastClaimTime[msg.sender] = block.timestamp; // Update user-specific timer emit SnowEarned(msg.sender, 1); // Add missing event } ``` **Step 3:** Initialize First Claim (Constructor) ```solidity constructor(...) { // Initialize with current timestamp to prevent immediate claims s_lastClaimTime[address(0)] = block.timestamp; } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!