Raisebox Faucet

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

Token transfer after ETH call enables reentrancy

Root + Impact

ERC20 transfer executed after an external ETH call reintroduces a reentrancy hook that can loop claims and desynchronize faucet accounting.

Description

  • Normally the faucet drips Sepolia ETH and then transfers the ERC20 faucet token to the claimer as the last step of the interaction.

  • Because _transfer runs after the outbound ETH call, a malicious ERC777-style recipient can trigger tokensReceived and re-enter claimFaucetTokens while the faucet still thinks the ETH transfer succeeded only once.

if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
@> if (!success) revert RaiseBoxFaucet_EthTransferFailed();
}
...
@> _transfer(address(this), faucetClaimer, faucetDrip);
@> emit Claimed(faucetClaimer, faucetDrip);

Risk

Likelihood:

  • The vulnerability triggers whenever the claimer is a contract implementing token receiver callbacks that call back into the faucet before the ERC20 state changes.

  • Integration with composable wallets or DeFi protocols that proxy claims through smart contracts increases the probability of encountering a hostile hook.

Impact:

  • Re-entrant callbacks can drain faucet token reserves by forcing repeated _transfer executions inside a single transaction.

  • The faucet’s accounting diverges from reality, allowing bypass of daily token limits and making downstream monitoring unreliable.

Proof of Concept

This PoC leverages an ERC777-style hook that re-enters as soon as _transfer fires, demonstrating how the late token transfer can loop claims.

contract HookedClaimer is IERC777Recipient {
RaiseBoxFaucet faucet;
uint256 loops;
function attack() external {
loops = 0;
faucet.claimFaucetTokens();
}
function tokensReceived(
address, address, address, uint256, bytes calldata, bytes calldata
) external override {
if (loops < 3) {
loops++;
faucet.claimFaucetTokens();
}
}
}

Recommended Mitigation

Apply the diff to move all state updates and the ERC20 transfer ahead of the ETH call while gating the drip with a flag, preventing hook-based reentry.

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
// (lastClaimTime[faucetClaimer] == 0);
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();
}
- // drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
- // still checks
+ // Effects - Update all state variables BEFORE any external calls
+
+ /**
+ *
+ * @param lastFaucetDripDay tracks the last day a claim was made
+ * @notice resets the @param dailyClaimCount every 24 hours
+ */
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+
+ // Determine if ETH drip is needed (still part of checks/effects)
+ bool shouldDripEth = false;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
// dailyClaimCount = 0;
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
+ shouldDripEth = true;
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;
}
- /**
- *
- * @param lastFaucetDripDay tracks the last day a claim was made
- * @notice resets the @param dailyClaimCount every 24 hours
- */
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
-
- // Effects
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
-
- // Interactions
+ // Interactions - ALL external calls happen AFTER state updates
_transfer(address(this), faucetClaimer, faucetDrip);
+
+ // Perform ETH transfer if needed (after token transfer)
+ if (shouldDripEth) {
+ (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+
+ if (success) {
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ }
emit Claimed(msg.sender, faucetDrip);
}
Updates

Lead Judging Commences

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