Root + Impact
Description
The claimFaucetTokens function performs an external call to transfer tokens without implementing reentrancy protection. While the function has a cooldown mechanism, it violates the Checks-Effects-Interactions (CEI) pattern by updating state after the external call.
The specific issue is the absence of a ReentrancyGuard modifier combined with state updates occurring after the external token transfer, creating a potential attack vector.
function claimFaucetTokens() external {
uint256 currentDay = block.timestamp / 1 days;
require(
currentDay > lastFaucetDripDay[msg.sender] + 2,
"Cooldown period not met"
);
faucetToken.transfer(msg.sender, faucetDrip);
lastFaucetDripDay[msg.sender] = currentDay;
faucetClaimer = msg.sender;
}
Risk
Likelihood:
-
Requires malicious token contract or callback mechanism
-
Cooldown provides partial mitigation but not complete protection
-
Attack complexity is moderate
-
Violates security best practices
Impact:
-
Potential for reentrancy exploitation
-
State inconsistency during execution
-
Violation of established security patterns
-
Risk increases if token contract is upgradeable or malicious
Proof of Concept
This test demonstrates the CEI pattern violation and lack of reentrancy protection:
Setup: We deploy the faucet with a standard token
Observation: The function performs an external call before state updates
Risk: If the token has a callback, state can be manipulated
The vulnerability exists because:
-
No ReentrancyGuard modifier is present
-
External call happens before state updates
-
Cooldown check alone is insufficient protection
-
Violates Checks-Effects-Interactions pattern
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 ReentrancyRiskTest is Test {
RaiseBoxFaucet faucet;
RaiseBoxToken token;
address owner = makeAddr("owner");
address user = makeAddr("user");
function setUp() public {
vm.startPrank(owner);
token = new RaiseBoxToken();
faucet = new RaiseBoxFaucet(address(token));
token.mintFaucetTokens(address(faucet), 1_000_000 * 10**18);
vm.stopPrank();
}
function testCEIPatternViolation() public {
vm.prank(user);
faucet.claimFaucetTokens();
vm.warp(block.timestamp + 3 days);
uint256 lastDripBefore = faucet.lastFaucetDripDay(user);
vm.prank(user);
faucet.claimFaucetTokens();
uint256 lastDripAfter = faucet.lastFaucetDripDay(user);
assertTrue(lastDripAfter > lastDripBefore);
}
}
Recommended Mitigation
Implement OpenZeppelin's ReentrancyGuard and follow the Checks-Effects-Interactions pattern by moving state updates before external calls. This prevents reentrancy attacks and follows security best practices.
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is Ownable {
+ contract RaiseBoxFaucet is Ownable, ReentrancyGuard {
- function claimFaucetTokens() external {
+ function claimFaucetTokens() external nonReentrant {
uint256 currentDay = block.timestamp / 1 days;
require(
currentDay > lastFaucetDripDay[msg.sender] + 2,
"Cooldown period not met"
);
+ // Effects (before Interaction)
+ lastFaucetDripDay[msg.sender] = currentDay;
+ faucetClaimer = msg.sender;
+ // Interaction (after Effects)
faucetToken.transfer(msg.sender, faucetDrip);
- lastFaucetDripDay[msg.sender] = currentDay;
- faucetClaimer = msg.sender;
}
}