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 {
@> faucetClaimer = msg.sender;
@> if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
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;
}
@> 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 {
RaiseBoxFaucet localFaucet = new RaiseBoxFaucet(
"raiseboxtoken",
"RB",
1000 * 10 ** 18,
0.005 ether,
0.01 ether
);
vm.deal(address(localFaucet), 1 ether);
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);
uint256 initialFaucetEth = address(localFaucet).balance;
uint256 pre1 = address(user1).balance;
vm.prank(user1);
localFaucet.claimFaucetTokens();
assertEq(address(user1).balance - pre1, 0.005 ether, "user1 should receive 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");
uint256 afterTwo = address(localFaucet).balance;
assertEq(initialFaucetEth - afterTwo, 0.01 ether, "faucet should have paid 0.01");
uint256 pre3 = address(user3).balance;
vm.prank(user3);
localFaucet.claimFaucetTokens();
assertEq(address(user3).balance - pre3, 0, "user3 should NOT receive ETH (cap reached)");
vm.prank(address(this));
localFaucet.toggleEthDripPause(true);
uint256 beforePauseCall = address(localFaucet).balance;
vm.prank(user4);
localFaucet.claimFaucetTokens();
uint256 afterPauseCall = address(localFaucet).balance;
assertEq(afterPauseCall, beforePauseCall, "while paused faucet should not pay ETH");
vm.prank(address(this));
localFaucet.toggleEthDripPause(false);
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;
bool user5Received = (post5 - pre5) == 0.005 ether;
bool faucetDecreased = (beforeUser5Faucet - afterUser5Faucet) == 0.005 ether;
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"
+ );
+ }
+ }