Normal behavior: claimFaucetTokens() should allow a caller to receive one Sepolia ETH drip (one-time per address) and one token drip, and enforce a cooldown so that repeated claims are prevented. The contract intends to perform checks, update state, and then perform external transfers.
Specific issue: the function performs an external ETH transfer to msg.sender while some important per-claimer state (lastClaimTime) is only written after that external call. A malicious caller implemented as a contract can reenter the function during the ETH call and perform additional claims. A direct reentrant call from the same contract cannot receive ETH more than once because hasClaimedEth[attacker] is set before the external call. However the attacker can deploy or use different contracts and have them call into the faucet in a chain (A calls and reenters B, B calls and reenters C, ...), making each call appear as a different msg.sender and therefore receiving the ETH drip again. This requires one contract per extra ETH drip, costing deployment and gas, but allows draining multiple ETH drips until daily caps / contract balance are exhausted.
Likelihood:
Large-scale mempool activity or an attacker who prepares a set of helper contracts will perform this during normal faucet usage windows, when ETH balance and daily cap are sufficient to cover multiple drips.
Automated attackers who can deploy multiple thin helper contracts cheaply on a testnet or low-cost environment will run chained calls to maximize ETH extracted up to caps.
Impact:
Multiple Sepolia ETH drips can be obtained by the attacker by presenting different contract addresses as callers; this can materially deplete the faucet’s ETH balance up to dailySepEthCap or contract balance.
Token drips can also be amplified: because lastClaimTime is set only after the ETH transfer, rapid reentrant calls by contracts may obtain multiple token drip transfers in a single transaction before lastClaimTime prevents further claims for a specific address (the token-drain impact is larger if attack uses reentry loops across contracts).
Explanation:
AttackerLauncher deploys multiple AttackerHelper contracts. Each helper is a distinct contract address and therefore hasClaimedEth[helper] starts as false.
The launcher triggers each helper to call claimFaucetTokens() — the faucet will set hasClaimedEth[helper] = true and then send ETH to that helper.
If the faucet sends ETH via call, the helper’s receive() can reenter the faucet immediately (depending on ordering), but even if immediate reentry is limited, the key point is each helper is a different caller address, so each helper can receive the one-time ETH drip once.
To get multiple ETH drips in a single transaction via reentrancy chaining, the attacker could orchestrate calls so that a helper’s receive triggers a call from the helper to the faucet, and that helper’s receive triggers another helper, etc. Each distinct contract in the chain appears as a distinct msg.sender and can receive an ETH drip before lastClaimTime updates block further claims.
Cost and practical considerations:
Each additional ETH drip requires a separate contract address (helper). Deploying many helpers costs gas and is more expensive than a single-call exploit, but on testnets or low-cost chains this remains feasible.
The attack is bounded by dailySepEthCap and the faucet’s ETH balance.
Lock claim state before external calls
Add the nonReentrant modifier from OpenZeppelin
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.