Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
Submission Details
Impact: medium
Likelihood: medium

Daily ETH cap bypass

Author Revealed upon completion

Logic reset allows daily ETH cap bypass via paused-branch (else { dailyDrips = 0 })

Description

  • Normal behavior: A successful claimFaucetTokens() call should: (1) enforce the 3-day cooldown per claimer, (2) enforce the per-day Sepolia ETH cap, (3) pay the Sepolia ETH drip at most once per first-time claimer, and (4) deliver the configured token drip exactly once per valid claim.

  • Issue behavior: Because the function mixes external calls and state changes in the wrong order and contains a reset inside the else branch, a sequence pause -> claim (paused) -> unpause -> claim results in dailyDrips being reset and an extra ETH drip issued even though the day’s cap was previously consumed. The function also stores the caller in storage and performs external calls before updating cooldown/daily counters, creating a reentrancy window (separately reported) that can amplify impact.

function claimFaucetTokens() public {
// Checks
@> faucetClaimer = msg.sender;
// ... checks omitted ...
// ETH drip logic
@> if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// daily rollover
if (currentDay > lastDripDay) { lastDripDay = currentDay; dailyDrips = 0; }
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap) {
@> hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
// ...
} else {
emit SepEthDripSkipped(...);
}
} else {
@> dailyDrips = 0; // <-- logic bug: resets daily counter in paused/skip branch
}
// Effects (happens AFTER external call)
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
@> _transfer(address(this), faucetClaimer, faucetDrip);
}

Risk

Likelihood:

  • After successful claims are executed in the same day and consume the configured dailySepEthCap (normal user activity or attacker-driven claims), the faucet reaches the daily cap and later code paths depend on the sepEthDripsPaused flag.

  • When the owner (or any account with pause privilege) sets sepEthDripsPaused = true, a user executes a claim while paused, and the owner then unpauses, the contract executes the paused-branch that resets dailyDrips and a subsequent fresh claimer that day becomes eligible again. This sequence is trivial to reproduce on-chain and can be combined with the previously reported reentrancy vulnerability to increase exploitability.

Impact:

  • Extra Sepolia ETH disbursements beyond the configured daily cap, enabling progressive depletion of the faucet ETH balance and increasing the frequency of paid claims.

  • Undermining of rate-limits and expected user fairness — attackers can receive additional funds and exhaust faucet resources faster; when chained with the reentrancy issue, attackers can simultaneously multiply token and ETH extraction, amplifying financial loss and service disruption.

Proof of Concept

Paste follow function to RaiseBoxFaucet.t.sol:

function test_dailyDripsResetAllowsEthAfterPause_fixedCap() public {
// DEPLOY local faucet with small daily cap 0.01 ether -> small cap for PoC
RaiseBoxFaucet localFaucet = new RaiseBoxFaucet(
"raiseboxtoken",
"RB",
1000 * 10 ** 18, // faucetDrip
0.005 ether, // sepEthAmountToDrip
0.01 ether // dailySepEthCap -> small cap for PoC
);
// fund the local faucet with ETH (owner is this contract)
vm.deal(address(localFaucet), 1 ether);
// prepare users (gas)
vm.deal(user1, 1 ether);
vm.deal(user2, 1 ether);
vm.deal(user3, 1 ether);
vm.deal(user4, 1 ether);
vm.deal(user5, 1 ether);
// initial faucet balance
uint256 initialFaucetEth = address(localFaucet).balance;
// 1) user1 claims -> 0.005
uint256 pre1 = address(user1).balance;
vm.prank(user1);
localFaucet.claimFaucetTokens();
assertEq(address(user1).balance - pre1, 0.005 ether, "user1 should receive 0.005");
// 2) user2 claims -> 0.005
uint256 pre2 = address(user2).balance;
vm.prank(user2);
localFaucet.claimFaucetTokens();
assertEq(address(user2).balance - pre2, 0.005 ether, "user2 should receive 0.005");
// verify cap consumed (0.01)
uint256 afterTwo = address(localFaucet).balance;
assertEq(initialFaucetEth - afterTwo, 0.01 ether, "faucet should have paid 0.01");
// 3) user3 tries — should NOT receive ETH (cap reached)
uint256 pre3 = address(user3).balance;
vm.prank(user3);
localFaucet.claimFaucetTokens();
assertEq(address(user3).balance - pre3, 0, "user3 should NOT receive ETH (cap reached)");
// 4) owner pauses ETH drips
vm.prank(address(this)); // owner is this test contract (constructor set owner = msg.sender)
localFaucet.toggleEthDripPause(true);
// 5) user4 calls while paused: faucet must not pay ETH (but code enters else { dailyDrips = 0 })
uint256 beforePauseCall = address(localFaucet).balance;
vm.prank(user4);
localFaucet.claimFaucetTokens(); // paused -> SepEthDripSkipped and else branch
uint256 afterPauseCall = address(localFaucet).balance;
assertEq(afterPauseCall, beforePauseCall, "while paused faucet should not pay ETH");
// 6) owner unpauses
vm.prank(address(this));
localFaucet.toggleEthDripPause(false);
// 7) user5 (new) claims -> if bug exists, user5 will get 0.005 despite cap previously reached
uint256 pre5 = address(user5).balance;
uint256 beforeUser5Faucet = address(localFaucet).balance;
vm.prank(user5);
localFaucet.claimFaucetTokens();
uint256 post5 = address(user5).balance;
uint256 afterUser5Faucet = address(localFaucet).balance;
// Check: if bug present -> user5 received ETH (cap bypass). If bug fixed -> user5Received == false.
bool user5Received = (post5 - pre5) == 0.005 ether;
bool faucetDecreased = (beforeUser5Faucet - afterUser5Faucet) == 0.005 ether;
// ASSERT that demonstrates the bug (test will pass if bug exists).
// assertFalse for check fix
assertTrue(user5Received && faucetDecreased, "user5 should receive ETH if dailyDrips was reset by paused branch");
}

Recommended Mitigation

The fix removes the incorrect reset of dailyDrips in the else branch and moves the daily rollover check (currentDay > lastDripDay) to run once before the branch logic.
Now dailyDrips is incremented only when an ETH payout is actually recorded; pause/skip paths no longer zero the counter, preventing a pause→claim(pause)→unpause→claim sequence from bypassing the daily cap.

- if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
- uint256 currentDay = block.timestamp / 24 hours;
-
- if (currentDay > lastDripDay) {
- lastDripDay = currentDay;
- dailyDrips = 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; // <-- logic bug: resets the daily counter in the paused/skip branch
- }
+ // Compute day rollover once, before any branch decisions.
+ uint256 currentDay = block.timestamp / 24 hours;
+ if (currentDay > lastDripDay) {
+ lastDripDay = currentDay;
+ dailyDrips = 0;
+ }
+
+ // Only update dailyDrips when we actually schedule/pay ETH — do NOT reset it in the 'skip' path.
+ if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
+ if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
+ // mark and schedule actual ETH payment
+ hasClaimedEth[faucetClaimer] = true;
+ dailyDrips += sepEthAmountToDrip;
+ // perform ETH payment later (or here if CEI allows)
+ (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+ if (!success) revert RaiseBoxFaucet_EthTransferFailed();
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ } else {
+ emit SepEthDripSkipped(
+ faucetClaimer,
+ address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
+ );
+ }
+ }

Support

FAQs

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