Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: low
Valid

s_earnTimer can be updated by anyone calling earnSnow(); should be a mapping of address to uint256

Root + Impact

Description

  • The normal behavior of a user earning the Snow token should be that the duration of their earning time be specific to that user's address.

  • The issue is that the Snow contract uses a single number as the state variable uint256 private s_earnTimer. This does not allow for individualized mapping of a user's address to their earning timer, at least not in the expected manner. s_earnTimer is updated anytime the function earnSnow is called. One also must point out that this functionality of an earn timer essentially being reset by earnSnow is not indicated in the README, i.e., the action of earning itself increasing the ability to earn.

// Root cause in the codebase with @> marks to highlight the relevant section
@> 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;
}

Risk

Likelihood:

  • This will occur WHEN any user, even one user, calls earnSnow().

  • When multiple users participate in the protocol, they will update s_earnTimer by calling earnSnow().

Impact:

  • By updating s_earnTimer anytime earnSnow() is called, every other user will now be participating in a protocol which has updated his own conditions of participation without proper understanding, i.e., the protocol does not make it obvious that EVERY user's earn timer is updating when ONE user calls earnSnow(), and the protocol does not wish to have that mechanism.

  • With s_earnTimer getting updated continually, each user can never be sure of their particular earn timer, in fact, the mechanism of them having a particular earn timer has been destroyed.

Proof of Concept

// This testing contract demonstrates that s_earnTimer is updated by earnSnow().
// This contract also includes testing of proposed mitigation, which tests the usage of a mapping.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {console} from "forge-std/console.sol";
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {DeploySnow} from "../script/DeploySnow.s.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract TestSnow is Test {
Snow snow;
DeploySnow deployer;
MockWETH weth;
address collector;
uint256 FEE;
address anonTester;
address icecream;
function setUp() public {
deployer = new DeploySnow();
snow = deployer.run();
weth = deployer.weth();
collector = deployer.collector();
FEE = deployer.FEE();
anonTester = makeAddr("anonTester");
icecream = makeAddr("icrecream");
deal(anonTester, FEE * 2);
deal(icecream, FEE * 2);
}
/**
* This test passes when it asserts that s_earnTimer has been updated by calling earnSnow()
*/
function testEarnTimerIsUpdatedByEarnSnow() public {
// Arrange
uint256 earnTimerBeforeEarnSnow = snow.getEarnTimer();
console.log("earnTimerBeforeEarnSnow: ", earnTimerBeforeEarnSnow);
// Act
vm.startPrank(anonTester);
snow.earnSnow();
vm.stopPrank();
// Assert that s_earnTimer is now different
uint256 earnTimerAfterEarnSnow = snow.getEarnTimer();
console.log("earnTimerAfterEarnSnow: ", earnTimerAfterEarnSnow);
assertNotEq(earnTimerBeforeEarnSnow, earnTimerAfterEarnSnow);
}
// test functionality of using a mapping(address => uint256) for earn timers.
// This test uses our newEarnSnow() which updates mapping s_earnTimerMapping.
function testEarnTimerMappingIsSpecificToEachUser() public {
// Arrange
uint256 anonEarnTimerBefore = snow.getEarnTimerMapping(anonTester);
uint256 icecreamEarnTimerBefore = snow.getEarnTimerMapping(icecream);
console.log("anonEarnTimerBefore: ", anonEarnTimerBefore);
console.log("icecreamEarnTimerBefore: ", icecreamEarnTimerBefore);
// Act
vm.startPrank(anonTester);
snow.newEarnSnow();
vm.stopPrank();
// Assert
uint256 anonEarnTimerAfter = snow.getEarnTimerMapping(anonTester);
uint256 icecreamEarnTimerAfter = snow.getEarnTimerMapping(icecream);
console.log("anonEarnTimerAfter: ", anonEarnTimerAfter);
console.log("icecreamEarnTimerAfter: ", icecreamEarnTimerAfter);
// assert that anon's values differ after our ACT
assertNotEq(anonEarnTimerBefore, anonEarnTimerAfter);
// asert that icecream's values didn't differ after our ACT
assertEq(icecreamEarnTimerBefore, icecreamEarnTimerAfter);
// assert that anon's after value differs from icecream's after value
assertNotEq(anonEarnTimerAfter, icecreamEarnTimerAfter);
}
}

Our function testEarnTimerIsUpdatedByEarnSnow shows that s_earnTimer is updated by a user calling earnSnow(). This function requires that we add a getter function in the Snow contract that returns the corresponding uint256 for a given address for the mapping s_earnTimerMapping:

function getEarnTimerMapping(address _address) external view returns(uint256){
return s_earnTimerMapping[_address];
}

Recommended Mitigation

- // remove this code
- uint256 private s_earnTimer;
+ // add this code
+ mapping(address => uint256) private s_earnTimerMapping;
- // remove this code
- 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; // TODO: this should be updating the corresponding uint256 in a mapping for the msg.sender address
- }
+ // add this code as a replacement to earnSnow()
+ function newEarnSnow() external canFarmSnow {
+ if (
+ s_earnTimerMapping[msg.sender] != 0 &&
+ block.timestamp < (s_earnTimerMapping[msg.sender] + 1 weeks)
+ ) {
+ revert S__Timer();
+ }
+ _mint(msg.sender, 1);
+ s_earnTimerMapping[msg.sender] = block.timestamp;
+ }

Above we have included newEarnSnow() as a proof of concept for updating a mapping instead of a single uint256. Our testing function testEarnTimerMappingIsSpecificToEachUser asserts the functionality of using a mapping to differentiate earn timers between multiple users.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-02] Global Timer Reset in Snow::buySnow Denies Free Claims for All Users

## 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; } ```

Support

FAQs

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

Give us feedback!