Raisebox Faucet

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

Reentrancy Vulnerability in `claimFaucetTokens`

Root + Impact

The claimFaucetTokens function makes an external call to transfer ETH before completing all state updates, violating the Checks-Effects-Interactions (CEI) pattern. This creates a reentrancy vulnerability that allows malicious contracts to drain tokens and ETH from the faucet.

Description

  • The normal and secure behavior following the CEI pattern is to complete ALL state changes before making any external calls. This prevents reentrancy attacks where the called contract can call back into the function before state is finalized.

  • The critical issue occurs when the function transfers ETH via .call{value} at line 197, but crucial state variables like lastClaimTime[faucetClaimer] (line 226) and dailyClaimCount (line 227) are only updated AFTER this external call. This means during the external call, the contract state still reflects the old values, allowing a malicious contract's receive/fallback function to reenter claimFaucetTokens and potentially claim multiple times before the cooldown is set.

function claimFaucetTokens() public {
// All previous code
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// .............
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // External call HERE
// .............
}
}
// Effects - AFTER external call (WRONG!)
@> lastClaimTime[faucetClaimer] = block.timestamp; // Updated AFTER external call
@> dailyClaimCount++; // Updated AFTER external call
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
}

Risk

Likelihood:

  • An attacker must deploy a malicious smart contract with a crafted receive() or fallback() function

  • The attacker's contract must be called as a first-time claimer to receive the ETH drip

  • The attack is technically straightforward for anyone with Solidity knowledge

  • Requires some setup but is easily achievable

Impact:

  • Attacker can bypass the 3-day cooldown period through reentrancy

  • Can potentially claim tokens multiple times in a single transaction

  • Daily claim limits could be circumvented

  • Could drain the faucet's entire token supply and ETH balance

  • Complete loss of funds for the protocol

  • Faucet becomes unusable for legitimate users

Proof of Concept

This demonstrates a reentrancy attack that bypasses the cooldown and claims tokens multiple times in one transaction.

it("Should demonstrate reentrancy attack draining tokens", async function () {
const faucetBalanceBefore = await faucet.balanceOf(await faucet.getAddress());
const attackerBalanceBefore = await faucet.balanceOf(await attackerContract.getAddress());
// Execute reentrancy attack
await attackerContract.attack();
const faucetBalanceAfter = await faucet.balanceOf(await faucet.getAddress());
const attackerBalanceAfter = await faucet.balanceOf(await attackerContract.getAddress());
const attackCount = await attackerContract.attackCount();
// Calculate tokens stolen
const tokensStolen = attackerBalanceAfter - attackerBalanceBefore;
const expectedSingleClaim = ethers.parseEther("1000");
console.log("\n⚠️ VULNERABILITY CONFIRMED:");
console.log(`- Attacker claimed ${ethers.formatEther(tokensStolen)} tokens`);
console.log(`- Should have claimed ${ethers.formatEther(expectedSingleClaim)} tokens`);
console.log(`- Exploited ${attackCount + 1n} times in one transaction!`);
console.log(`- Cooldown mechanism bypassed via reentrancy`);
});

Recommended Mitigation

Implement OpenZeppelin's ReentrancyGuard to prevent reentrancy attacks. This adds a mutex lock that prevents a function from being called again while it's still executing.
Alternatively, strictly follow the Checks-Effects-Interactions pattern by moving ALL state updates before external calls.

+ import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// Checks
faucetClaimer = msg.sender;
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// ... rest of function with protection against reentrancy
}
}

Additionally, consider moving state updates before external calls:

function claimFaucetTokens() public nonReentrant {
// ... all checks ...
+ // Effects - Update state BEFORE external calls
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ... ETH drip logic ...
+ // Interactions - External calls LAST
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
// ...
}
- // Effects
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
_transfer(address(this), faucetClaimer, 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.