Raisebox Faucet

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

Critical — Reentrancy & Checks-Effects-Interactions Broken (claimFaucetTokens)

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: claimFaucetTokens() should perform all state updates (cooldown, hasClaimedEth, daily counters) before any external calls or token transfers, and be protected from reentrant calls.


  • Problem: External ETH transfer and token transfer occur while critical state is still mutable. An attacker contract can reenter during external call and claim many times, bypassing cooldown and daily limits.


// Root cause in the codebase with @> marks to highlight the relevant section
function claimFaucetTokens() public {
// Checks...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ...
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success, ) = faucetClaimer.call{
value: sepEthAmountToDrip
}(""); // @> external call happens BEFORE final effects like lastClaimTime/dailyCount
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
} else {
dailyDrips = 0;
}
// Effects
lastClaimTime[faucetClaimer] = block.timestamp; // @> state updates delayed until here
dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip); // @> token transfer AFTER external call
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood:

  • High — attacker contracts receive Ether and execute fallback/receive, providing a natural point to reenter while the contract is mid-function.


  • Frequent — any call path that pays ETH or transfers tokens to an external contract triggers this vector.


Impact:

  • Drain of faucet tokens and Sepolia ETH beyond intended limits.


  • Bypass of cooldown and daily caps causing unlimited repeated claims.


Proof of Concept

// Pseudocode Foundry-style PoC outline
contract Attacker {
RaiseBoxFaucet faucet;
constructor(RaiseBoxFaucet _f) { faucet = _f; }
receive() external payable {
// reenter: call claimFaucetTokens again before state (lastClaimTime/dailyCount) is set
faucet.claimFaucetTokens();
}
function exploit() external {
// ensure faucet has tokens and ETH, then trigger initial claim
faucet.claimFaucetTokens();
// after exploit, this contract will have > faucetDrip tokens and possibly drained ETH
}
}

Explanation: When claimFaucetTokens() sends ETH to this attacker, the fallback receive() function executes before the faucet’s state updates, allowing another call into claimFaucetTokens().
This repeats, letting the attacker claim multiple times in one transaction and bypass all cooldown restrictions.

Recommended Mitigation

- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
address claimer = msg.sender;
- // ... existing checks ...
- if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
- // set hasClaimedEth and transfer ETH here (unsafe)
- hasClaimedEth[faucetClaimer] = true;
- (bool success, ) = faucetClaimer.call{ value: sepEthAmountToDrip }("");
- if (!success) revert RaiseBoxFaucet_EthTransferFailed();
- }
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
- _transfer(address(this), faucetClaimer, faucetDrip);
+ // Effects: update all state BEFORE external calls
+ if (!hasClaimedEth[claimer] && !sepEthDripsPaused) {
+ // mark as claimed to prevent reentry-granting multiple ETH
+ hasClaimedEth[claimer] = true;
+ dailyDrips += sepEthAmountToDrip;
+ }
+ lastClaimTime[claimer] = block.timestamp;
+ dailyClaimCount++;
+ // Interactions: external calls/transfers last
+ if ( /* eligible for sepEth */ ) {
+ (bool success, ) = claimer.call{ value: sepEthAmountToDrip }("");
+ if (!success) revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ _transfer(address(this), claimer, faucetDrip);
}

Explanation: Reordering the logic ensures all internal state updates happen before any external calls. Additionally, applying nonReentrant from OpenZeppelin’s ReentrancyGuard adds a second layer of safety. This strictly enforces cooldowns and prevents attackers from reentering the claim function mid-execution.

Support

FAQs

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