Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: high
Likelihood: high

Improper Access Control in earnSnow() Leads to Token Distribution Exploit

Author Revealed upon completion

Root + Impact

Description

  • The Snow ERC20 contract allows users to earn 1 token every week via the earnSnow() function, which is intended to reward each individual user once per week.

  • However, the current implementation uses a single global timestamp variable (s_earnTimerto enforce the cooldown, rather than maintaining a per-user timer. As a result, only the first user to call earnSnow() can successfully mint, and all subsequent users are blocked for one week, even if they have never called the function before.

function earnSnow() external canFarmSnow {
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer(); // @> Timer logic is applied globally
}
_mint(msg.sender, 1);
s_earnTimer = block.timestamp; // @> Global timer is updated after any user mints
}

Risk

Likelihood:

  • The function earnSnow() can be accessed by any user once per week. This vulnerability will always occur after the first user has called the function during the current week.

  • The system is designed for many users to earn tokens individually, making the likelihood high in multi-user environments such as airdrops or public farming events.

Impact:

  • Users who have not previously earned tokens will be unfairly blocked from earning due to another user’s activity.

  • This directly violates the protocol’s intended behavior, resulting in loss of user rewards and trust.

Proof of Concept

function testEarnSnow() public {
// Ashley earns 1 Snow
vm.prank(ashley);
snow.earnSnow();
assertEq(snow.balanceOf(ashley), 1);
// Ashley tries again before 1 week -> should revert
vm.prank(ashley);
vm.expectRevert();
snow.earnSnow();
// Fast-forward 1 week
vm.warp(block.timestamp + 1 weeks);
// Ashley tries again after 1 week -> should succeed
vm.prank(ashley);
snow.earnSnow();
assertEq(snow.balanceOf(ashley), 2);
// Victory tries again immediately -> should revert (BUG)
vm.prank(victory);
vm.expectRevert(); // Unexpected, first-time user should succeed
snow.earnSnow();
// Warp another week, now Victory can earn again
vm.warp(block.timestamp + 2 weeks);
vm.prank(ashley);
snow.earnSnow();
assertEq(snow.balanceOf(ashley), 3);
}
  • Attacker calls earnSnow() first, sets the global s_earnTimer.

  • Waits exactly 1 week, calls again before others can.

  • Repeats this weekly, resetting the timer each time.

A malicious user (the attacker) repeatedly calls earnSnow() as soon as the 1-week timer expires, effectively resetting the global timer every time and denying access to everyone else.

Recommended Mitigation

  • Replace the global s_earnTimer With a per-user mapping to track individual cooldowns

  • Use a mapping(address => uint256) to store the last earned timestamps for each user

  • Ensure each user can call earnSnow() only after 1 week from their last claim

  • Introduce an epoch-based claiming system to allow fair reward distribution

  • Prevent multiple users from being blocked due to a shared timer

  • Emit events with timestamps for off-chain eligibility tracking

  • Add thorough test cases simulating multiple users attempting to earn rewards

  • Validate timestamp logic using msg.sender context only

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

Support

FAQs

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