earnSnow checks and updates a single global timer, so the "free once a week" faucet serves one address a week in total, to whoever calls firstThe README says Snow "can either be earned for free onece a week, or bought at anytime" (README.md:19), which reads as each holder being able to earn once a week. earnSnow enforces the limit with one uint256 shared by the whole contract (src/Snow.sol:30): it checks that value and then overwrites it for whoever called (src/Snow.sol:93-98).
So after any address earns, every other address reverts with S__Timer for a week, including addresses that have never earned. No purchase is involved. Over the 12-week window at most 12 base units can be earned by all users combined, and each week's unit goes to whoever calls first after the timer expires. A single address that calls at each expiry takes every free unit.
On intent: the repo's own script/Helper.s.sol waits a week between each user's earnSnow, so the author knew one earn blocks the next. That can be read as a protocol-wide rate. But the README's wording is about what a holder can do, and a faucet that gives 12 units to the whole world over the whole farming window, on a list of five accounts, makes "earned for free" close to meaningless for an airdrop. CodeHawks' validity guide lists "design opinions with no clear loss of funds indication" among findings that may be invalid. The effect here isn't a design preference. Listed holders who rely on earning can't reach their allocation without paying, and the first caller each week takes the unit from everyone else.
Likelihood: High. It happens in normal use, with no attacker: the first address to earn each week blocks all the others.
Impact: Medium. The free route to Snow, one of the README's two, is reduced to one unit a week in total. Buying still works, and no funds are taken.
Cap: in the PoC, 50 addresses trying at the start and middle of every week make 1,200 attempts, and exactly 12 succeed, against 600 on the per-user reading (test_F006_oracle_fiftyUsersTwelveWeeksOnlyTwelveUnits). Every revert is S__Timer, and after the window earning reverts with S__SnowFarmingOver, so 12 is the cap.
Capture: one listed address earning at each expiry ends with all 12 units. The other four listed accounts hold 0, and their claims revert with SA__ZeroAmount, so buying is their only route (test_F006_oracle_oneAddressTakesEveryFreeUnit). By arithmetic, not measured as a payment, that's 4 units at the deploy script's 5 ETH fee, 20 ETH.
Block: an address that has never earned is blocked for the whole week after someone else earns (test_F006_oracle_oneUsersEarnBlocksAnotherWhoNeverEarned).
Save the file below as test/F006GlobalEarnTimer.t.sol in the contest repository and run, from the repository root (no fork needed):
It imports only the repository's own src/ files and forge-std. Its setUp deploys the real Snow, Snowman and SnowmanAirdrop the way the repo's deploy scripts do: MockWETH, a fee of 5, and the committed Merkle root. The committed allowlist's names and proofs are copied into the file from script/flakes/output.json, for the capture test. Nothing is mocked or stubbed, and no purchase happens anywhere in the file. Actors:
Attacker: none needed, since ordinary earning triggers it. In the capture test, alice plays the address that calls first after each expiry.
Victim: bob, the 50 users, and the other four listed accounts, who can't earn.
Protocol: Snow, with SnowmanAirdrop for the claim check.
In the capture test, Alice calling first each week is set by the test. On a real chain it depends on transaction ordering, which a determined caller can win often but not with certainty.
Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:
Block: Alice earns, then Bob, who has never earned, reverts with S__Timer, and still does one second before Alice's week ends. On a fresh deployment, Bob's first earn succeeds (the control).
Cap: 1,200 attempts, 12 successful earns, total supply 12, against 600 on the per-user reading. Each week's single success goes to whoever calls first.
Capture: Alice holds all 12 units, and the other four listed accounts hold 0 and can't claim.
Make the timer per address, checked and updated only in earnSnow. Where the fix has to touch buySnow's write to the timer, it uses that separate issue's fix and deletes the line. Converting it to the buyer's own entry instead would keep every purchase delaying the buyer's own earn:
Notes:
Tested: this change was tested on a patched copy of Snow. Bob earns in the same week Alice does. The same 50-user schedule gives 600 successful earns, and every mid-week retry still reverts, so each user is still limited to one earn a week. All five listed accounts earn and claim in the first week for nothing. Buying with ETH and WETH, the weekly limit and collectFee still work.
Related: the line deleted in buySnow is a separate issue, where any purchase resets the earn timer. Its fix is that deletion.
Side effect: per-address earning can be farmed with fresh addresses, one unit each per week, and it makes single Snow units free to obtain.
## 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.