Core Contracts

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

Unnecessary Vault Withdrawals Due to Unchecked User Withdrawal Amounts

Summary

When a user deposits crvUSD, the contract mints RTokens and calls _rebalanceLiquidity to split the funds between the contract and the Curve vault based on a liquidity buffer ratio.When a user requests a withdrawal, _ensureLiquidity checks if the RToken contract holds enough liquidity. If not, it calculates a requiredAmount and attempts to withdraw that from the Curve vault. After this, the withdrawal process continues in the ReserveLibrary.withdraw function which eventually calls RToken.burn.If a user submits an excessively high withdrawal request (e.g., using uint256.max), the RToken.burn function detects that the requested amount exceeds the actual user balance and adjusts it down accordingly. However, by this point, the LendingPool has already initiated a vault withdrawal based on the unadjusted, inflated amount. This discrepancy can lead to unnecessarily large withdrawals from the vault, wasting gas and causing potential performance issues.

Vulnerability Details

When a user deposits crvUSD and mints RTokens, the _rebalanceLiquidity function calculates, based on the liquidityBufferRatio, the portion of crvUSD that should remain in the RToken contract, while the remaining liquidity is deposited into the Curve vault.

function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// Update the reserve state before the deposit
ReserveLibrary.updateReserveState(reserve, rateData);
// Perform the deposit through ReserveLibrary
uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
// Rebalance liquidity after deposit
_rebalanceLiquidity();
emit Deposit(msg.sender, amount, mintedAmount);
}

So when a user wants to withdraw, the _ensureLiquidity function is first called to ensure that the required amount of liquidity is available. If there is not enough availableLiquidity, the function will withdraw the necessary funds from the vault.

function withdraw(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (withdrawalsPaused) revert WithdrawalsArePaused();
// Update the reserve state before the withdrawal
ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
@>> _ensureLiquidity(amount);
// Perform the withdrawal through ReserveLibrary
@>> (uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) = ReserveLibrary.withdraw(
reserve, // ReserveData storage
rateData, // ReserveRateData storage
amount, // Amount to withdraw
msg.sender // Recipient
);
// Rebalance liquidity after withdrawal
_rebalanceLiquidity();
emit Withdraw(msg.sender, amountWithdrawn);
}
//.....
function withdraw(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address recipient
) internal returns (uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) {
if (amount < 1) revert InvalidAmount();
// Update the reserve interests
updateReserveInterests(reserve, rateData);
// Burn RToken from the recipient - will send underlying asset to the recipient
@>> (uint256 burnedScaledAmount, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).burn(
recipient, // from
recipient, // receiverOfUnderlying
amount, // amount
reserve.liquidityIndex // index
);
amountWithdrawn = burnedScaledAmount;
// Update the total liquidity and interest rates
updateInterestRatesAndLiquidity(reserve, rateData, 0, amountUnderlying);
emit Withdraw(recipient, amountUnderlying, burnedScaledAmount);
return (amountUnderlying, burnedScaledAmount, amountUnderlying);
}
function _ensureLiquidity(uint256 amount) internal {
// if curve vault is not set, do nothing
if (address(curveVault) == address(0)) {
return;
}
uint256 availableLiquidity = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (availableLiquidity < amount) {
uint256 requiredAmount = amount - availableLiquidity;
// Withdraw required amount from the Curve vault
_withdrawFromVault(requiredAmount);
}
}

The problem here is that if a user passes the full amount or sets amount = uint256.max, the logic in the RToken contract checks if the amount is greater than userBalance. If so, it adjusts amount to match the actual userBalance instead of reverting. However, this can cause an issue in the LendingPool, as it will attempt to withdraw the full amount from _withdrawFromVault(requiredAmount);.

function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
@>> uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
@>> if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}

Core Problem

  1. User Input Handling:

    • When a user initiates a withdrawal with an amount exceeding their RToken balance (e.g., uint256.max), the RToken.burn function adjusts the amount to their actual balance. However, this adjustment happens after the LendingPool has already prepared liquidity based on the original (unadjusted) amount.

    • This leads to the LendingPool potentially over-withdrawing from the Curve vault.

Step-by-Step Analysis

1. Withdrawal Flow

  • User Action: Calls LendingPool.withdraw(uint256 amount).

  • Step 1: _ensureLiquidity(amount) is invoked:

    • Checks if the RToken contract has enough liquidity (availableLiquidity).

    • If not, calculates requiredAmount = amount - availableLiquidity and withdraws it from the Curve vault.

  • Step 2: ReserveLibrary.withdraw calls RToken.burn:

    • The burn function checks the user's RToken balance.

    • If amount > userBalance, it reduces amount to userBalance.

Example Scenario

  • User Balance: 100 RTokens.

  • Withdrawal Request: amount = uint256.max.

  • Available Liquidity: 50 crvUSD in RToken contract.

  • Step 1 (_ensureLiquidity):

    • requiredAmount = uint256.max - 50 (a massive value).

    • Attempts to withdraw this from the Curve vault.

  • Step 2 (burn):

    • Adjusts amount to 100 (user's actual balance).

    • Withdraws 100 crvUSD from the RToken contract.

Result:

  • If the vault has sufficient liquidity, the RToken contract ends up with:

    Initial: 50 crvUSD
    After Vault Withdrawal: 50 + (uint256.max - 50) ≈ uint256.max crvUSD
    After User Withdrawal: uint256.max - 100uint256.max crvUSD

Impact

The vault may process large withdrawals/deposits due to incorrect requiredAmount, wasting gas and increasing latency.

Tools Used

Manual Review

Recommendations

In LendingPool.withdraw, query the user's RToken balance upfront:

uint256 userBalance = IERC20(reserve.reserveRTokenAddress).balanceOf(msg.sender);
amount = amount > userBalance ? userBalance : amount;
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::_ensureLiquidity processes uncapped withdraw amounts, allowing temporary yield disruption through excessive Curve vault withdrawals

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::_ensureLiquidity processes uncapped withdraw amounts, allowing temporary yield disruption through excessive Curve vault withdrawals

Appeal created

anonymousjoe Auditor
3 months ago
inallhonesty Lead Judge
3 months ago
inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::_ensureLiquidity processes uncapped withdraw amounts, allowing temporary yield disruption through excessive Curve vault withdrawals

Support

FAQs

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