Raisebox Faucet

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

Reentrancy in claimFaucetTokens() — External ETH call performed before finalizing state and token transfer

Root + Impact

Description

A reentrancy vulnerability exists in RaiseBoxFaucet.claimFaucetTokens() where the contract executes an external faucetClaimer.call{value: ...}("") before finalizing critical state updates and token transfers, allowing a malicious contract to reenter and double-claim tokens.

function claimFaucetTokens() public {
// --- Checks ---
faucetClaimer = msg.sender;
...
// Drip Sepolia ETH to first-time claimers
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
@> // External call to an untrusted address occurs here
@> // State variables and ERC20 transfer happen *after* this call
@> //A malicious fallback can reenter claimFaucetTokens() before cooldown/state updates
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
} else {
emit SepEthDripSkipped(...);
}
}
...
// --- Effects ---
@> lastClaimTime[faucetClaimer] = block.timestamp; // Updated after the external call (too late)
@> dailyClaimCount++; // Incremented after external call
// --- Interactions ---
@> _transfer(address(this), faucetClaimer, faucetDrip); // Token transfer after the unsafe call
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood: HIGH

  • The vulnerability occurs whenever a malicious smart contract interacts with claimFaucetTokens(), since the function performs an external ETH transfer (.call{value: ...}) before updating critical state variables.

  • Attacker only needs to call claimFaucetTokens() through a contract with a fallback function to reenter.

Impact: HIGH / CRITICAL

  • A successful reentrancy allows an attacker to bypass claim limits and cooldowns, repeatedly claiming tokens and ETH within a single transaction.

  • This leads to direct financial loss of faucet assets and broken accounting logic.

Proof of Concept:

A malicious contract’s receive() reenters claimFaucetTokens() while the faucet still holds pre‑update state because the faucet does an external .call{value:...} before finalizing state and the ERC‑20 transfer. Each reentrant call meets the original checks and collects another drip, enabling multiple token/ETH drips in one transaction.

function testReentrancyExploitSucceeds() public {
uint256 faucetDrip = faucet.faucetDrip();
console.log("Faucet drip (tokens):", faucetDrip);
// faucet must have at least one drip amount available
uint256 faucetInitialTokenBalance = faucet.balanceOf(address(faucet));
assertTrue(faucetInitialTokenBalance >= faucetDrip, "faucet has insufficient tokens for drip");
// Configure attacker to attempt 3 reentries (total claims = 1 initial + 3 reentries = 4)
vm.prank(attackerEOA);
attacker.setMaxReentries(3);
// Attacker executes attack (as attackerEOA)
vm.prank(attackerEOA);
attacker.attack{value: 0}(address(faucet));
// Read attacker token balance (contract)
uint256 attackerTokenBal = faucet.balanceOf(address(attacker));
console.log("Attacker token balance after attack:", attackerTokenBal);
// The attacker should have received more than a single drip if reentrancy succeeded
// (i.e., attackerTokenBal > faucetDrip)
assertEq(attackerTokenBal > faucetDrip, "Reentrancy exploit did NOT increase attacker balance above single drip");
// Check that faucet lost corresponding tokens
uint256 faucetFinalTokenBalance = faucet.balanceOf(address(faucet));
console.log("Faucet token balance after attack:", faucetFinalTokenBalance);
assertEq(faucetFinalTokenBalance < faucetInitialTokenBalance - faucetDrip, "Faucet did not lose additional tokens as expected from reentrancy");
}
}

Recommended Mitigation

The recommended code changes eliminate the reentrancy window and provide layered protection:

1.Checks → Effects → Interactions

2.Adding OpenZeppelin’s ReentrancyGuard and marking claimFaucetTokens() nonReentrant

- (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
-
- if (success) {
- emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
- } else {
- revert RaiseBoxFaucet_EthTransferFailed();
- }
+ // Follow the Checks-Effects-Interactions pattern
+ // Move all state updates before any external call to prevent reentrancy.
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+
+ // Use `Address.sendValue` (OpenZeppelin) instead of low-level call
+ // to safely forward ETH without reentrancy risk.
+ (bool success, ) = payable(faucetClaimer).call{value: sepEthAmountToDrip}("");
+ require(success, "RaiseBoxFaucet_EthTransferFailed");
+
+ // After successful transfer, emit the event
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
- _transfer(address(this), faucetClaimer, faucetDrip);
+ // Consider adding a ReentrancyGuard to the contract for layered protection
+ // and ensuring no function performs external calls before updating state.
+ _transfer(address(this), faucetClaimer, faucetDrip);
Updates

Lead Judging Commences

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