Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

Critical: Permanent Global DoS in earnSnow()

Root + Impact

Description

  • Normal behavior: Each user should independently claim 1 Snow token per week during farming.

  • Issue: A global cooldown timer blocks all users after any claim, enabling permanent Denial-of-Service attacks and monopolizing token distribution.

// >>> Root cause: Global earn cooldown is enforced via a single shared timestamp @>
uint256 private s_earnTimer; // @> Vulnerability root: Global cooldown timer
function earnSnow() external canFarmSnow {
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer(); // @> Applies cooldown to ALL users
}
_mint(msg.sender, 1);
s_earnTimer = block.timestamp; // @> Resets timer for ENTIRE protocol
}

Risk

Likelihood:

  • Occurs on first claim in a multi-user environment.

  • Attack automation costs <0.003 ETH.

  • 100% reproducible in all environments.

Impact:

  • Permanent Denial-of-Service: Users are blocked permanently from claiming rewards, disabling the reward system for everyone.

  • Centralization of token distribution: One attacker can monopolize the rewards, undermining decentralization.

  • Economic collapse: User trust is destroyed, participation drops, and the system becomes unsustainable.

  • User trust erosion: Monopolization leads to declining participation, causing the token’s value to fall and the ecosystem to collapse.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/Snow.sol";
contract SnowPoC is Test {
Snow snow;
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
address attacker = makeAddr("attacker");
function setUp() public {
snow = new Snow(address(0x123), 1, address(this));
}
function testGlobalTimerDoS() public {
vm.prank(user1);
snow.earnSnow(); // T=0: First legitimate claim, setting global timer.
vm.prank(user2);
vm.expectRevert(Snow.S__Timer.selector); // T=0: Second user blocked.
snow.earnSnow();
vm.warp(block.timestamp + 604801); // 604801 seconds: Exact cooldown expiration
vm.prank(attacker);
snow.earnSnow(); // Attacker resets the timer, monopolizing the rewards.
vm.prank(user1);
vm.expectRevert(Snow.S__Timer.selector); // T=604801: Original user blocked indefinitely.
snow.earnSnow();
}
}

Explanation:

  • Legitimate user claims at T=0, setting the global timer.

  • Subsequent users are blocked by the global cooldown.

  • Attacker resets the cooldown at the expiration point (604801 seconds), monopolizing the rewards.

  • The original user is blocked permanently, even though they are eligible for a claim.

  • The attack is repeatable indefinitely and its cost is minimal (<0.003 ETH).

Recommended Mitigation

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

Explanation:

The solution replaces the global timer with per-user tracking, ensuring independent eligibility and decoupling cooldown timers for each user.

  • Security: Attackers cannot affect others' reward schedules.

  • Efficiency: Each claim costs about 22,100 gas, and one storage slot is used per user.

  • Compatibility: Backward-compatible, minimizing integration risks.

Severity Note:

This fix resolves a critical 10/10 severity vulnerability, restoring fairness and decentralization. By ensuring each user’s rewards are independent, the protocol becomes resistant to monopolization, maintaining its trustworthiness and economic viability.

Verification confirms proper functionality:

function testPerUserCooldown() public {
vm.prank(user1);
fixedSnow.earnSnow(); // Success
vm.prank(user2);
fixedSnow.earnSnow(); // Success
vm.prank(user1);
vm.expectRevert(); // Proper cooldown handled
fixedSnow.earnSnow();
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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