Root + Impact
Description
-
Normal behavior: claimFaucetTokens() should perform all state updates (cooldown, hasClaimedEth, daily counters) before any external calls or token transfers, and be protected from reentrant calls.
-
Problem: External ETH transfer and token transfer occur while critical state is still mutable. An attacker contract can reenter during external call and claim many times, bypassing cooldown and daily limits.
function claimFaucetTokens() public {
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
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 — attacker contracts receive Ether and execute fallback/receive, providing a natural point to reenter while the contract is mid-function.
-
Frequent — any call path that pays ETH or transfers tokens to an external contract triggers this vector.
Impact:
Proof of Concept
contract Attacker {
RaiseBoxFaucet faucet;
constructor(RaiseBoxFaucet _f) { faucet = _f; }
receive() external payable {
faucet.claimFaucetTokens();
}
function exploit() external {
faucet.claimFaucetTokens();
}
}
Explanation: When claimFaucetTokens() sends ETH to this attacker, the fallback receive() function executes before the faucet’s state updates, allowing another call into claimFaucetTokens().
This repeats, letting the attacker claim multiple times in one transaction and bypass all cooldown restrictions.
Recommended Mitigation
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
address claimer = msg.sender;
- // ... existing checks ...
- if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
- // set hasClaimedEth and transfer ETH here (unsafe)
- hasClaimedEth[faucetClaimer] = true;
- (bool success, ) = faucetClaimer.call{ value: sepEthAmountToDrip }("");
- if (!success) revert RaiseBoxFaucet_EthTransferFailed();
- }
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
- _transfer(address(this), faucetClaimer, faucetDrip);
+ // Effects: update all state BEFORE external calls
+ if (!hasClaimedEth[claimer] && !sepEthDripsPaused) {
+ // mark as claimed to prevent reentry-granting multiple ETH
+ hasClaimedEth[claimer] = true;
+ dailyDrips += sepEthAmountToDrip;
+ }
+ lastClaimTime[claimer] = block.timestamp;
+ dailyClaimCount++;
+ // Interactions: external calls/transfers last
+ if ( /* eligible for sepEth */ ) {
+ (bool success, ) = claimer.call{ value: sepEthAmountToDrip }("");
+ if (!success) revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ _transfer(address(this), claimer, faucetDrip);
}
Explanation: Reordering the logic ensures all internal state updates happen before any external calls. Additionally, applying nonReentrant from OpenZeppelin’s ReentrancyGuard adds a second layer of safety. This strictly enforces cooldowns and prevents attackers from reentering the claim function mid-execution.