Snowman Merkle Airdrop

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

Denial-of-Service (DoS) Attack in the earnSnow() Function

Root + Impact

Root Cause:

A shared public timer (s_earnTimer) in the buySnow function is updated, allowing any user to reset the weekly waiting period for all users, thus preventing everyone from calling earnSnow.

Description

  • Normal Behavior:
    The earnSnow function is designed to allow users to earn a reward (one Snow token) once a week. The contract enforces this condition by checking the public timestamp s_earnTimer: if it is non-zero and less than a week has passed since its last update, the call is canceled. After a week, earnSnow can be called again, and s_earnTimer is updated to the current time.s

  • The problem of code:
    The buySnow function also updates s_earnTimer to block.timestamp whenever a user buys Snow tokens. This means that if a malicious user buys even a small amount of Snow (such as a single token), the global timer resets to the current time, forcing a new one-week waiting period on every user. Consequently, no one will be able to call earnSnow for the next seven days, effectively breaking the rewards mechanism.

// Root cause in the codebase (Snow.sol)
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; // @audit - resets global timer for all users
emit SnowBought(msg.sender, amount);
}
function earnSnow() external canFarmSnow {
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer(); // @audit - uses global timer, blocking all users
}
_mint(msg.sender, 1);
s_earnTimer = block.timestamp; // @audit - updates timer only after a successful earn
}

Risk

Likelihood:

  • High – The attack can be performed by any user, even with a negligible amount of Snow purchased.

  • The condition for the DoS is automatically met every time someone calls buySnow; no special permissions or preconditions are required.

  • Once the attack is executed, the DoS lasts for a full week, after which the attacker can repeat the process immediately.

Impact:

  • Loss of functionality – Users cannot earn Snow tokens for extended periods, breaking the core incentive mechanism of the contract.

  • Reputational damage – Users expecting weekly rewards will be frustrated, potentially leading to loss of trust and abandonment of the protocol.

  • Economic harm – If Snow tokens have value, preventing distribution can affect the project’s tokenomics and user participation.

Proof of Concept

Attack steps:

  1. A legitimate user (victim) calls earnSnow() for the first time – succeeds because the global timer s_earnTimer is zero.

  2. The attacker calls buySnow with amount = 1, paying the required fee (1 ETH in this example). Inside buySnow, after minting the tokens, the contract updates s_earnTimer = block.timestamp.

  3. Now any user, including the victim, trying to call earnSnow will have their transaction reverted with S__Timer() because less than a week has passed since the attacker’s purchase.

  4. The victim must wait a full week before being able to earn again.

  5. As soon as the week passes and the victim earns once more, the attacker can repeat step 2, resetting the timer for another week.

The attack is asymmetric: the attacker spends a minimal amount (1 token’s worth) to deny the reward mechanism to every other user. In the provided PoC, the cost is 1 ETH, but the fee could be lower depending on the contract’s s_buyFee. The attack can be automated and repeated indefinitely, effectively rendering the earnSnow feature unusable.

PoC code (Foundry test):
The test below simulates the attack with two actors: attacker and victim. It verifies that after each attacker purchase, the victim’s earnSnow call fails until a week passes, and then the pattern repeats

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/snow.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockWETH is ERC20 {
constructor() ERC20("WETH", "WETH") {}
function deposit() external payable {
_mint(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
_burn(msg.sender, amount);
payable(msg.sender).transfer(amount);
}
}
contract SnowTest is Test {
Snow public snow;
MockWETH public weth;
address public attacker = address(0x123);
address public victim = address(0x456);
address public collector = address(0x789);
uint256 constant BUY_FEE = 1;
uint256 constant TOKEN_AMOUNT = 1;
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), BUY_FEE, collector);
vm.deal(attacker, 10 ether);
vm.deal(victim, 1 ether);
}
function test_DoS_Attack() public {
// 1. The victim is trying to earn for the first time (it must succeed because s_earnTimer = 0)
vm.prank(victim);
snow.earnSnow();
assertEq(snow.balanceOf(victim), 1, "Victim should have 1 Snow");
// 2.The attacker buys 1 Snow for 1 ETH (resets the timer)
vm.prank(attacker);
snow.buySnow{value: 1 ether}(TOKEN_AMOUNT);
// 3. The victim is trying to profit again -> must fail with an S__Timer() error
vm.prank(victim);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
// 4. We pass a time of one week + 1 second
vm.warp(block.timestamp + 1 weeks + 1);
// 5. The victim is trying to profit now -> must succeed (week ends)
vm.prank(victim);
snow.earnSnow();
assertEq(snow.balanceOf(victim), 2, "Victim should have 2 Snow now");
// 6.The attacker buys again (resets).
vm.prank(attacker);
snow.buySnow{value: 1 ether}(TOKEN_AMOUNT);
// 7.The victim tries to profit -> fails again
vm.prank(victim);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
}

Recommended Mitigation

The core issue is the global cooldown mechanism. To fix it, we must replace the single s_earnTimer with a per‑user mapping that tracks the last time each individual called earnSnow. This ensures that purchases by others cannot interfere with a user’s ability to earn.

// In src/Snow.sol
- uint256 private s_earnTimer;
+ mapping(address => uint256) private s_lastEarn;
function earnSnow() external canFarmSnow {
- if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
+ if (s_lastEarn[msg.sender] != 0 && block.timestamp < (s_lastEarn[msg.sender] + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
- s_earnTimer = block.timestamp;
+ s_lastEarn[msg.sender] = block.timestamp;
}
function buySnow(uint256 amount) external payable canFarmSnow {
// ... existing purchase logic ...
- s_earnTimer = block.timestamp; // @remove – this line is no longer needed
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 7 days 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!