Raisebox Faucet

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

Reentrancy Risk Due to State Updates After External ETH Transfer

Root + Impact

Description

  • Smart contracts should follow the Checks-Effects-Interactions (CEI) pattern, updating all state variables before making external calls to prevent reentrancy attacks.

  • The claimFaucetTokens function updates lastClaimTime and dailyClaimCount after making an external ETH transfer, violating the CEI pattern and creating a potential reentrancy vulnerability window.

if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ... checks and day reset logic ...
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true; // ✓ State update
dailyDrips += sepEthAmountToDrip; // ✓ State update
// @> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // ❌ External call
if (success) {
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
}
// ... more code ...
// @> lastClaimTime[faucetClaimer] = block.timestamp; // ❌ State update AFTER external call
// @> dailyClaimCount++; // ❌ State update AFTER external call
// @> _transfer(address(this), faucetClaimer, faucetDrip); // ❌ Another external call

Risk

Likelihood:

  • Requires a malicious contract to be the claimer

  • Malicious contract must implement receive/fallback to attempt reentrancy

  • The cooldown check provides some protection but not complete

  • hasClaimedEth is updated before the call, preventing ETH re-drip

Impact:

  • Potential for reentrancy attacks if a malicious contract is the claimer

  • lastClaimTime not updated could theoretically allow re-entry (though cooldown provides protection)

  • Violates security best practices and fails automated security tool checks

  • Creates uncertainty about reentrancy safety

  • Future code changes might inadvertently enable attacks

Proof of Concept

contract MaliciousReceiver {
RaiseBoxFaucet public faucet;
uint256 public attempts;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
receive() external payable {
attempts++;
// Try to re-enter (will fail due to cooldown and hasClaimedEth)
// But lastClaimTime is not yet updated at this point!
if (attempts < 2) {
try faucet.claimFaucetTokens() {
// If cooldown wasn't there, this could succeed
} catch {}
}
}
function attack() external {
faucet.claimFaucetTokens();
}
}
function testReentrancyAttempt() public {
MaliciousReceiver attacker = new MaliciousReceiver(address(raiseBoxFaucet));
vm.deal(address(attacker), 0);
vm.prank(address(attacker));
raiseBoxFaucet.claimFaucetTokens();
// Attack fails due to hasClaimedEth and cooldown
// But CEI pattern is still violated
assertEq(attacker.attempts(), 1);
}

Recommended Mitigation

function claimFaucetTokens() public {
+ address claimer = msg.sender;
// ========== CHECKS ==========
- faucetClaimer = msg.sender;
- if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
+ if (block.timestamp < (lastClaimTime[claimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
// ... other checks ...
+ // Daily reset logic
+ uint256 currentDay = block.timestamp / 24 hours;
+ if (currentDay > lastDripDay) {
+ lastDripDay = currentDay;
+ dailyDrips = 0;
+ }
+ if (currentDay > lastFaucetDripDay) {
+ lastFaucetDripDay = currentDay;
+ dailyClaimCount = 0;
+ }
// ========== EFFECTS (ALL state changes) ==========
+ lastClaimTime[claimer] = block.timestamp;
+ dailyClaimCount++;
+ bool shouldDripEth = !hasClaimedEth[claimer] &&
+ !sepEthDripsPaused &&
+ dailyDrips + sepEthAmountToDrip <= dailySepEthCap &&
+ address(this).balance >= sepEthAmountToDrip;
+
+ uint256 ethToDrip = 0;
+ if (shouldDripEth) {
+ hasClaimedEth[claimer] = true;
+ dailyDrips += sepEthAmountToDrip;
+ ethToDrip = sepEthAmountToDrip;
+ }
// ========== INTERACTIONS (all external calls) ==========
+ _transfer(address(this), claimer, faucetDrip);
+ emit Claimed(claimer, faucetDrip);
- if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
- // ... logic ...
- hasClaimedEth[faucetClaimer] = true;
- dailyDrips += sepEthAmountToDrip;
-
- (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
-
- if (success) {
- emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
- } else {
- revert RaiseBoxFaucet_EthTransferFailed();
- }
- }
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
-
- _transfer(address(this), faucetClaimer, faucetDrip);
- emit Claimed(msg.sender, faucetDrip);
+ if (ethToDrip > 0) {
+ (bool success,) = claimer.call{value: ethToDrip}("");
+ if (success) {
+ emit SepEthDripped(claimer, ethToDrip);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ }
}
Updates

Lead Judging Commences

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