Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Invalid

An attacker can potentially DoS fee distribution

Description

The contract permits any user to deposit tokens via collectFee(), which updates the fee balances as soon as the deposit is made. When the fee distributor triggers distributeCollectedFees(), the contract calculates the total fees available and then distributes them among recipients. However, if new fee deposits occur between these operations, the calculated totals do not match, causing the function to revert. This can be exploited to create a denial-of-service (DoS) situation.

Vulnerability Detail

1.Fee Collection:
Any user can call collectFee(), and supply raacToken to the contract.

function collectFee(uint256 amount, uint8 feeType) external override nonReentrant whenNotPaused returns (bool) {
---SNIP---
// Transfer tokens from sender
>> raacToken.safeTransferFrom(msg.sender, address(this), amount);
// Update collected fees
>> _updateCollectedFees(amount, feeType);
emit FeeCollected(feeType, amount);
return true;
}

This internally updates the fee balance via _updateCollectedFees():

function _updateCollectedFees(uint256 amount, uint8 feeType) internal {
if (feeType == 0) collectedFees.protocolFees += amount;
else if (feeType == 1) collectedFees.lendingFees += amount;
else if (feeType == 2) collectedFees.performanceFees += amount;
else if (feeType == 3) collectedFees.insuranceFees += amount;
else if (feeType == 4) collectedFees.mintRedeemFees += amount;
else if (feeType == 5) collectedFees.vaultFees += amount;
else if (feeType == 6) collectedFees.swapTaxes += amount;
else if (feeType == 7) collectedFees.nftRoyalties += amount;
}

2.Fee Distribution Logic:
When distributeCollectedFees() is called, the process starts by reading the current fee balance. It then calculates how these fees should be distributed among the affected parties:

function distributeCollectedFees() external override nonReentrant whenNotPaused {
if (!hasRole(DISTRIBUTOR_ROLE, msg.sender)) revert UnauthorizedCaller();
>> uint256 totalFees = _calculateTotalFees();
if (totalFees == 0) revert InsufficientBalance();
>> uint256[4] memory shares = _calculateDistribution(totalFees);
---SNIP---
}

Here, _calculateTotalFees() aggregates the fee values that were previously recorded, and _calculateDistribution() does the breakdown to different recipients, summing up to totalCollected.

for (uint8 i = 0; i < 8; i++) {
// @audit-info Fee by type retrieved from storage
>> uint256 feeAmount = _getFeeAmountByType(i);
if (feeAmount == 0) continue;
FeeType memory feeType = feeTypes[i];
// @audit-info New fee totals accumilated here
>> totalCollected += feeAmount;
uint256 weight = (feeAmount * BASIS_POINTS) / totalFees;
shares[0] += (weight * feeType.veRAACShare) / BASIS_POINTS;
shares[1] += (weight * feeType.burnShare) / BASIS_POINTS;
shares[2] += (weight * feeType.repairShare) / BASIS_POINTS;
shares[3] += (weight * feeType.treasuryShare) / BASIS_POINTS;
}
// @audit-issue Inequality at this point will cause a revert
>> if (totalCollected != totalFees) revert InvalidFeeAmount();

3.Race Condition and Its Exploit:

The problem arises because anyone can call collectFee() at any time. Imagine the following scenario:

  • The distributor calls distributeCollectedFees() and the function reads totalFees from the current state.

  • Before or as the internal loop in _calculateDistribution() runs, another user manages to call collectFee() and updates one of the fields in collectedFees.

  • Now, when _calculateDistribution() runs, the sum totalCollected will reflect the updated fee amounts, which no longer match the totalFees that was computed at the very beginning of distributeCollectedFees().

  • Because of the inequality between the locally computed totalCollected and the initial totalFees, the contract will hit the following check and revert:

if (totalCollected != totalFees) revert InvalidFeeAmount();

This condition can occur in the following cases:

  1. An unintentional user calls collectFee() during a distribution.

  2. An attacker deliberately calls collectFee() during a distribution.

Attack path:

Users can simply burn their raacTokens via RAACToken::burn() function:

function burn(uint256 amount) external {
uint256 taxAmount = amount.percentMul(burnTaxRate);
_burn(msg.sender, amount - taxAmount);
if (taxAmount > 0 && feeCollector != address(0)) {
_transfer(msg.sender, feeCollector, taxAmount);
}
}

However, Alice may not find this interesting and decide to cause some trouble. She can call collectFee() with miniscule amounts of raacToken to update the fee balance every time she sees a dirtibution tx on the memepool. Even though she doesn't stand to gain anything, this interupts the normal flow within the protocol.

Impact

This check is supposed to act as a safeguard, ensuring that the fee distribution logic works on a consistent set of fee values and that they are not cleared out incorrectly. However, its reliance on a state that can be modified externally means that an attacker (or even an unintentional user) could deliberately call collectFee() during a distribution. Repeatedly doing so would cause this check to fail every time, effectively blocking fee distribution and potentially creating a denial-of-service (DoS) condition.

Tool used

Manual Review

Recommendation

Only distribute fees during contract pause.
This ensures that no new tokens can be deposited during the fee distribution process. This guarantees that the state used for the calculation remains unchanged, thereby ensuring that totalFees and totalCollected are consistent.

- function distributeCollectedFees() external override nonReentrant whenNotPaused {
+ function distributeCollectedFees() external override nonReentrant whenPaused {
---SNIP---
}
Updates

Lead Judging Commences

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.