Snowman Merkle Airdrop

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

Critical Global Timer Design Flaw Enables Denial of Service

Root + Impact

Description

The Snow token utilises a global timer variable (s_earnTimer) that is shared across all users, creating a severe vulnerability where any user's action blocks all other users from earning tokens. This fundamentally breaks the intended weekly earning mechanism.

The Snow contract implements a token earning mechanism meant to allow users to earn one token per week. However, this functionality is built around a single global timer variable (s_earnTimer) rather than tracking time on a per-user basis. Any user who calls either buySnow() or earnSnow() resets this global timer, preventing all other users from earning tokens for an entire week.

The contract behaves as follows:

  1. When a user calls earnSnow(), the function checks if the global timer plus one week has passed

  2. If the time has passed, the user earns a token and resets the global timer for everyone

  3. The buySnow() function also resets this global timer, regardless of whether tokens are purchased

  4. As a result, only one user across the entire protocol can earn tokens in any given week

This creates a scenario where a malicious user can permanently prevent others from earning tokens by consistently resetting the timer before others can use it, effectively launching a denial of service attack against the token earning mechanism.

// Global timer variable affecting all users
uint256 private s_earnTimer;
function buySnow(uint256 amount) external payable canFarmSnow {
// ... payment logic ...
s_earnTimer = block.timestamp; // Resets global timer for everyone
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; // Resets global timer for everyone
}

Risk

Likelihood: High

  • Triggered by normal contract usage

  • No special conditions or privileges required

  • Will occur whenever multiple users attempt to use the contract as intended

Impact:

Impact: High

  • Completely breaks the core token earning functionality

  • Allows malicious users to prevent others from earning tokens

  • Creates an unfair and unpredictable user experience

  • Only one user per week globally can earn tokens

Proof of Concept

The following test demonstrates how one user's action prevents another user from earning tokens due to the global timer:

function test_POC_GlobalTimerVulnerability() public {
console2.log("=== POC 1: Global Timer Vulnerability ===");
// Step 1: Alice earns Snow tokens
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
console2.log("Alice earned 1 Snow token");
// Step 2: Bob tries to earn immediately (should fail due to timer)
vm.prank(bob);
vm.expectRevert();
snow.earnSnow();
console2.log("Bob correctly cannot earn immediately (timer protection)");
// Step 3: Fast forward 6 days (not a full week yet)
vm.warp(block.timestamp + 6 days);
// Step 4: Charlie buys Snow tokens with ETH
vm.prank(charlie);
snow.buySnow{value: FEE}(1);
assertEq(snow.balanceOf(charlie), 1);
console2.log("Charlie bought 1 Snow token with ETH");
// Step 5: Fast forward 1 more day (total 7 days = 1 week from Alice's earn)
vm.warp(block.timestamp + 1 days);
// Step 6: Bob tries to earn again - this should work as 1 week passed since Alice
// BUT it fails because Charlie's buySnow() reset the global timer!
vm.prank(bob);
vm.expectRevert(); // This demonstrates the bug!
snow.earnSnow();
console2.log("BUG DEMONSTRATED: Bob cannot earn despite 1 week passing since Alice");
console2.log("This is because Charlie's buySnow() reset the global timer");
// Step 7: Prove that we need to wait another week from Charlie's action
vm.warp(block.timestamp + 1 weeks);
vm.prank(bob);
snow.earnSnow(); // Now this works
assertEq(snow.balanceOf(bob), 1);
console2.log("Bob can finally earn after waiting 1 week from Charlie's buySnow()");
console2.log("IMPACT: Only one user globally can earn tokens per week, breaking the intended mechanics");
}

Recommended Mitigation

1. Replace the global timer with a per-user mapping:

// Replace global timer with user-specific timers
mapping(address => uint256) private s_userEarnTimer;
function earnSnow() external canFarmSnow {
if (s_userEarnTimer[msg.sender] != 0 &&
block.timestamp < (s_userEarnTimer[msg.sender] + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
s_userEarnTimer[msg.sender] = block.timestamp; // Only affects this user
emit SnowEarned(msg.sender, 1); // Also add missing event emission
}

2. Remove the timer update from buySnow() entirely:

function buySnow(uint256 amount) external payable canFarmSnow {
if (amount == 0) revert S__ZeroValue(); // Add validation
if (msg.value >= (s_buyFee * amount)) {
// Handle potential excess ETH
uint256 excess = msg.value - (s_buyFee * amount);
if (excess > 0) {
(bool success,) = payable(msg.sender).call{value: excess}("");
require(success, "ETH refund failed");
}
_mint(msg.sender, amount);
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
// s_earnTimer = block.timestamp; // Remove this line!
emit SnowBought(msg.sender, amount);
}

This fix allows each user to earn tokens on their individual weekly schedule without affecting others, restoring the intended functionality of the token earning mechanism.

Updates

Lead Judging Commences

yeahchibyke Lead Judge
3 months ago
yeahchibyke Lead Judge 3 months 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.