Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: low
Valid

Global Timer Enables Denial of Service (DoS) in earnSnow Function

Root + Impact

Description

  • The intended behavior of the earnSnow function is to allow any user to mint one S token. This action is subject to a one-week cooldown period that should be tracked individually for each user, allowing them to claim a token once per week, irrespective of other users' actions. The buySnow function also interacts with this timer, resetting it for the user who makes a purchase.

  • The contract incorrectly implements the cooldown mechanism using a single global state variable, s_earnTimer. Consequently, when any user successfully calls earnSnow or buySnow, this global timer is reset for the entire contract. This design flaw allows a malicious actor to perpetually prevent all other users from claiming tokens by calling earnSnow just before the one-week cooldown expires, effectively creating a permanent Denial of Service (DoS) on a core feature of the protocol.

// Snow.sol
// ... (other variables)
// @> 1. The cooldown timer is declared as a single global variable, shared by all users.
// A mapping (address => uint256) would be required for user-specific tracking.
uint256 private s_earnTimer;
// ... (constructor)
function buySnow(uint256 amount) external payable canFarmSnow {
// ... (payment logic)
_mint(msg.sender, amount);
// @> 3. A purchase by any user resets the single global timer, affecting everyone.
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
function earnSnow() external canFarmSnow {
// @> 2. The check compares the current time against this single global timer,
// blocking all users if just one user has recently acted.
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
// @> 3. Claiming tokens by any user also resets the global timer.
s_earnTimer = block.timestamp;
emit SnowEarned(msg.sender, 1);
}
// ...

The risk is assessed as Critical. The combination of a high likelihood of exploitation and a critical impact on the protocol's core functionality justifies this rating.

Likelihood: High

  • Reason 1: This denial of service occurs whenever a malicious actor (or any user) executes a transaction on the earnSnow function just before the global one-week cooldown expires. The cost to perpetrate this attack is minimal, limited only to the gas fee of a single transaction per week, making it both cheap and simple to execute repeatedly.

  • Reason 2: The exploit requires no special permissions, roles, or specific contract state to be successful. Any standard Ethereum account can execute the attack at any time after the initial cooldown period, making the attack vector permanently and publicly accessible from the moment of deployment.

Impact: Critical

  • Impact 1: The core utility of the earnSnow function is completely neutralized for all legitimate users. This permanently prevents them from minting their weekly tokens, fundamentally breaking the protocol's intended token distribution model and rendering a key feature of the ecosystem useless.

  • Impact 2: The vulnerability causes a severe loss of user confidence and irreparably damages the project's reputation. The inability to secure a fundamental mechanism will deter new participants and likely cause existing users to lose faith in the protocol's integrity, potentially leading to a collapse in token value and community engagement.

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"; // Asegúrate de que la ruta a tu contrato Snow.sol sea correcta
/**
* @title PoC: Global Timer Enables Denial of Service
* @author Your Name/Handle
* @notice This test demonstrates how a malicious actor can prevent any other user
* from using the earnSnow() function by repeatedly resetting the global cooldown timer.
*/
contract SnowPoC is Test {
// === Contracts ===
Snow private snow;
// === Actors ===
address private owner = makeAddr("owner");
address private collector = makeAddr("collector");
address private weth_address = makeAddr("weth"); // Dummy WETH address
address private attacker = makeAddr("attacker");
address private victim = makeAddr("victim");
// === Constants ===
uint256 constant ONE_WEEK = 1 weeks;
function setUp() public {
// Deploy the Snow contract
vm.prank(owner);
snow = new Snow(weth_address, 1 ether, collector);
// Label addresses for clearer test traces
vm.label(owner, "Contract Owner");
vm.label(collector, "Fee Collector");
vm.label(attacker, "Attacker");
vm.label(victim, "Victim");
}
/**
* @notice This function proves the Denial of Service vulnerability.
*
* 1. The attacker makes the first call to `earnSnow()`, starting the global timer.
* 2. Time is advanced by one week, making the function available again.
* 3. Just before the victim can claim, the attacker calls `earnSnow()` again, resetting the timer.
* 4. The victim's subsequent attempt to call `earnSnow()` is blocked (reverts), proving the DoS.
*/
function test_PoC_GlobalTimer_DenialOfService() public {
console.log("--- PoC: Global Timer DoS ---");
// --- Step 1: Attacker starts the global cooldown ---
console.log("Attacker makes the first call to earnSnow(), starting the global timer...");
vm.prank(attacker);
snow.earnSnow();
assertEq(snow.balanceOf(attacker), 1, "Attacker should have 1 S token");
console.log("Attacker's balance: %s", snow.balanceOf(attacker));
// --- Step 2: Time moves forward, making the function available again ---
console.log("\nAdvancing time by 1 week...");
vm.warp(block.timestamp + ONE_WEEK);
// At this point, ANYONE (victim or attacker) could call earnSnow().
// But the attacker acts first to grief the victim.
// --- Step 3: Attacker refreshes the cooldown just to block others ---
console.log("Attacker calls earnSnow() again to reset the global timer...");
vm.prank(attacker);
snow.earnSnow();
assertEq(snow.balanceOf(attacker), 2, "Attacker should now have 2 S tokens");
console.log("Attacker's balance: %s", snow.balanceOf(attacker));
console.log("Global timer has been reset by the attacker.");
// --- Step 4: Victim tries to claim but is blocked ---
console.log("\nVictim now tries to call earnSnow()...");
vm.prank(victim);
// The victim's transaction MUST fail because the attacker just reset the timer.
// We expect the transaction to revert with the custom error S__Timer.
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
console.log("SUCCESS: Victim's transaction was reverted as expected.");
assertEq(snow.balanceOf(victim), 0, "Victim should have 0 S tokens");
console.log("Victim's balance remains 0. The DoS attack was successful.");
}
}

Recommended Mitigation

To resolve this critical vulnerability, the contract must be modified to track each user's cooldown period individually. This is achieved by replacing the single global s_earnTimer state variable with a mapping that associates each user's address with the timestamp of their last claim.

This ensures that one user's action (calling earnSnow or buySnow) does not affect any other user's ability to do the same, completely eliminating the Denial of Service vector.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// ... other imports
contract Snow is ERC20, Ownable {
// ... errors, events, other variables
// MODIFIED: State variable is now a mapping for user-specific cooldowns.
mapping(address => uint256) private s_userLastEarnTimestamp;
// ... constructor ...
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);
}
// MODIFIED: Updates the timer only for msg.sender.
s_userLastEarnTimestamp[msg.sender] = block.timestamp;
emit SnowBought(msg.sender, amount);
}
function earnSnow() external canFarmSnow {
// MODIFIED: Checks the timer specifically for msg.sender.
if (s_userLastEarnTimestamp[msg.sender] != 0 && block.timestamp < (s_userLastEarnTimestamp[msg.sender] + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
// MODIFIED: Updates the timer only for msg.sender.
s_userLastEarnTimestamp[msg.sender] = block.timestamp;
// Emitting '1' is hardcoded in the original, keeping it for consistency.
emit SnowEarned(msg.sender, 1);
}
// ... other functions
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 19 days ago
Submission Judgement Published
Validated
Assigned finding tags:

buying of snow resets global timer thus affecting earning of free snow

When buySnow is successfully called, the global timer is reset. This inadvertently affects the earning of snow as that particular action also depends on the global timer.

Support

FAQs

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