Normal behavior: earnSnow() is the free weekly faucet: once per week each user can mint 1 Snow token, which is the amount whitelisted airdrop recipients need to fund their merkle claim. buySnow is the paid purchase path. Neither should ever be able to block the other for third parties.
The issue: earnSnow is gated on a single global timer (s_earnTimer) that every buySnow resets — including buySnow(0), which costs s_buyFee * 0 = 0 in both the ETH and the WETH branch. Any address can therefore call buySnow(0) for free at any time and push s_earnTimer forward. Repeating the call once per week blocks earnSnow() for every user indefinitely (each legit earner reverts with S__Timer because someone else's activity restarted the window). Regular paid purchases by any participant have the same side effect.
Root cause in src/Snow.sol:
Likelihood:
Reason 1 — buySnow(0) is callable by any address, at any time, with zero ETH and zero WETH (the amount = 0 branch executes and mints 0); there is no caller restriction and no cost.
Reason 2 — The attack recurs trivially: one free buySnow(0) per week is enough to keep the timer perpetually fresh, and even non-malicious purchase traffic continuously postpones the free-earn window for the whole user base.
Impact:
Impact 1 — The only free way to obtain Snow is permanently disabled for all users, and whitelisted recipients who rely on earnSnow to reach the amount = 1 merkle balance can never claim their NFT — a permanent exclusion from the airdrop.
Impact 2 — A core advertised protocol feature (weekly free Snow) becomes unusable for the protocol's entire lifetime, at zero cost to the attacker.
Test: forge test --match-contract TestSnowmanPoCs --match-test testPoc3_BuyZeroFreezesEarn -vv (see poc/TestSnowmanPoCs.t.sol).
The test passes: Alice's earnSnow() reverts 6 days after a free buySnow(0) reset.
## 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; } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.