Root + Impact
Description
The claimFaucetTokens function performs an ETH transfer to the caller using a low-level call without reentrancy protection. The function updates the dailyDrips counter after the external call, violating the Checks-Effects-Interactions (CEI) pattern.
The specific issue is that the ETH transfer (external call) happens before the state update of dailyDrips, allowing a malicious contract to re-enter and potentially exploit the state inconsistency.
function claimFaucetTokens() external {
if (dailyDrips < maxDailyDrips) {
(bool success, ) = msg.sender.call{value: faucetDrip}("");
require(success, "ETH transfer failed");
dailyDrips++;
}
}
Risk
Likelihood:
Attacker can deploy malicious contract with receive/fallback function
No special conditions required beyond contract deployment
Single transaction exploit possible
Attack can be repeated until daily limit reached
Impact:
Potential for reentrancy to manipulate state
dailyDrips counter can be bypassed
Multiple claims possible before state update
Violation of CEI pattern creates attack surface
Proof of Concept
This test demonstrates the CEI pattern violation with ETH transfer:
Setup: We deploy a malicious contract that can receive ETH
Attack: The malicious contract calls claimFaucetTokens
Vulnerability: ETH transfer happens before dailyDrips increment
The exploit works because:
External call (ETH transfer) happens first
State update (dailyDrips++) happens after
Malicious contract can execute code during the call
No ReentrancyGuard prevents re-entry
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
import {RaiseBoxToken} from "../src/RaiseBoxToken.sol";
contract MaliciousReceiver {
RaiseBoxFaucet public faucet;
uint256 public callCount;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
receive() external payable {
callCount++;
}
function attack() external {
faucet.claimFaucetTokens();
}
}
contract ReentrancyETHTest is Test {
RaiseBoxFaucet faucet;
RaiseBoxToken token;
MaliciousReceiver attacker;
address owner = makeAddr("owner");
function setUp() public {
vm.startPrank(owner);
token = new RaiseBoxToken();
faucet = new RaiseBoxFaucet(address(token));
token.mintFaucetTokens(address(faucet), 1_000_000 * 10**18);
vm.deal(address(faucet), 100 ether);
vm.stopPrank();
attacker = new MaliciousReceiver(address(faucet));
}
function testCEIViolationWithETHTransfer() public {
uint256 dailyDripsBefore = faucet.dailyDrips();
attacker.attack();
uint256 dailyDripsAfter = faucet.dailyDrips();
assertTrue(address(attacker).balance > 0);
assertEq(attacker.callCount(), 1);
assertEq(dailyDripsAfter, dailyDripsBefore + 1);
}
}
Recommended Mitigation
Implement ReentrancyGuard and follow the Checks-Effects-Interactions pattern by updating dailyDrips before the external ETH transfer. This prevents reentrancy attacks during the ETH transfer callback.
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is Ownable {
+ contract RaiseBoxFaucet is Ownable, ReentrancyGuard {
- function claimFaucetTokens() external {
+ function claimFaucetTokens() external nonReentrant {
// ... token transfer ...
if (dailyDrips < maxDailyDrips) {
+ // Effects (before Interaction)
+ dailyDrips++;
+ // Interaction (after Effects)
(bool success, ) = msg.sender.call{value: faucetDrip}("");
require(success, "ETH transfer failed");
- dailyDrips++;
}
}
}
Alternative mitigation using pull pattern to eliminate external calls entirely:
mapping(address => uint256) public pendingETHWithdrawals;
function claimFaucetTokens() external nonReentrant {
if (dailyDrips < maxDailyDrips) {
dailyDrips++;
pendingETHWithdrawals[msg.sender] += faucetDrip;
}
}
function withdrawETH() external nonReentrant {
uint256 amount = pendingETHWithdrawals[msg.sender];
require(amount > 0, "No ETH to withdraw");
pendingETHWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "ETH transfer failed");
}