Raisebox Faucet

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

Reentrancy in `claimFaucetTokens` enables a double‑claim within one transaction (cooldown bypass)

Description:
claimFaucetTokens sends Sepolia ETH to msg.sender via a low-level call before updating lastClaimTime and dailyClaimCount. Because the ETH transfer is an external call to the claimer, a malicious contract can reenter claimFaucetTokens from its receive() and perform a second claim in the same transaction, bypassing the 3‑day cooldown and consuming multiple daily claim slots at once. In the reentrant (second) call, no ETH is sent (because hasClaimedEth is already set), but the token transfer still executes.

Impact:

  • A first‑time claimer can receive faucetDrip in a single transaction, ignoring the 3‑day cooldown.

  • The dailyClaimLimit check is performed before incrementing dailyClaimCount, so both calls pass the same limit snapshot and count toward the day after the fact. This lets a single tx consume multiple units of the global daily limit.

Proof of Concept:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console2} from "../lib/lib/forge-std/src/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract RaiseBoxFaucetVulnerabilityTest is Test {
RaiseBoxFaucet public faucet;
address public owner;
address public user1;
address public user2;
function advanceBlockTime(uint256 duration_) internal {
vm.warp(duration_);
}
function setUp() public {
owner = makeAddr("owner");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
vm.prank(owner);
faucet = new RaiseBoxFaucet(
"RaiseBoxToken",
"RBT",
1000 * 10**18, // 1000 tokens per claim
0.005 ether, // 0.005 ETH per first claim
1 ether // 1 ETH daily cap
);
vm.deal(address(faucet), 10 ether);
advanceBlockTime(3 days);
}
function test_ReentrancyErasesDailyEthCap() public {
Reenter attacker = new Reenter(faucet);
// First-time call sends ETH before updating cooldown/limit counters.
// Inside receive(), the reentrant call hits the NOT-first-time branch and executes `dailyDrips = 0`.
vm.prank(address(attacker));
attacker.attack();
// Attacker got exactly one ETH drip (first pass); reentrant pass does not send ETH (already first-time).
// But the cap has been zeroed inside the same transaction, enabling more first-time drips later today.
// We can verify by funding a new first-timer now:
uint256 preUser1 = user1.balance;
vm.prank(user1);
faucet.claimFaucetTokens();
assertEq(user1.balance - preUser1, 0.005 ether, "Cap erased in-tx: user1 gets ETH in same day");
}
}

Mitigation:

  • Apply Checks‑Effects‑Interactions strictly: update lastClaimTime and dailyClaimCount before any external call.

  • Add nonReentrant (OpenZeppelin ReentrancyGuard) to claimFaucetTokens.

  • Prefer a pull pattern for ETH (record entitlement and let user withdraw), avoiding arbitrary external calls in the claim path.

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!