Core Contracts

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

Incorrect Withdrawal Process From Yearn Vault Leads to Failed Withdrawals in LendingPool

Summary

The LendingPool's withdrawal process contains three flaws in the asset flow when withdrawing from the Curve vault. When users attempt to withdraw their assets, the process fails due to:

  1. Incorrect ownership parameter: The _withdrawFromVault() function uses msg.sender as the owner of vault shares, when it should be using address(this) (LendingPool), which is the actual owner of the shares from the deposit process.

  2. Incorrect asset recipient: The withdrawn assets are sent to the LendingPool contract instead of the RToken contract, which is responsible for transferring the assets to the user.

  3. Zero loss tolerance: The maxLoss parameter is hardcoded to 0, which means any vault losses will cause withdrawals to fail completely, potentially leading to permanent fund lockup [https://docs.yearn.fi/developers/v3/Integrating_v3#maxloss]

Vulnerability Details

Pre-Condition:

  1. The issue in the _depositIntoVault function has been fixed (see PoC below) and described in issue: "Incorrect Asset Transfer Implementation in LendingPool's Vault Deposit Function Leads To Transaction Failure".

  2. The LendingPool contractLendingPool::setCurveVault() was called to set the vault contract address

  3. User called LendingPool::deposit() to deposit crvUSD into the Pool, which calls _depositIntoVault() => this mints the shares of the vault to the LendingPool

The issue occurs in the following sequence:

  1. A user calls withdraw() in the LendingPool contract

  2. The _ensureLiquidity() function is called to ensure sufficient liquidity

  3. If needed, _withdrawFromVault() is called to withdraw assets from the Curve vault

  4. The withdrawn assets are sent to the LendingPool contract (address(this))

  5. The RToken contract attempts to transfer the assets during the burn() function to the user but fails with ERC20InsufficientBalance because it doesn't have the balance

  6. The second issue here is, the LendingPool contract is the owner of the vault shares, the _withdrawFromVault() function should use address(this) as argument for the Owner of the shares, and not msg.sender

  7. The third issue is that the maxLoss parameter is hardcoded to 0. If the Vault made any losses this will cause another transaction failure.

Key problematic code:

When deposits are made via _depositIntoVault(), the LendingPool contract (address(this)) becomes the owner of the vault shares:

function _depositIntoVault(uint256 amount) internal {
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
// address(this) is the receiver of the shares
@> curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
}
// Interface function of the CrvUSDVault (ICurveCrvUSDVault):
/**
* @param receiver Address to receive the shares
*/
function deposit(uint256 assets, address receiver) external returns (uint256 shares);

The Lender wants to withdraw now and calls LendingPool::withdraw() which calls the internal function _ensureLiquidity(). This calls _withdrawFromVault() :

function _withdrawFromVault(uint256 amount) internal {
// 1. receiver = address(this) => should be RToken contract
// 2. owner of the shares = msg.sender => which is wrong, should be address(this)
// 3. maxLoss = 0 => withdrawals will fail if vault made losses
curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
totalVaultDeposits -= amount;
}
// Interface function of the CrvUSDVault (ICurveCrvUSDVault):
/**
@> * @param receiver Address to receive the assets
@> * @param owner Owner of the shares
@> * @param maxLoss Maximum acceptable loss in basis points
*/
function withdraw(uint256 assets, address receiver, address owner, uint256 maxLoss, address[] calldata strategies)
external
returns (uint256 shares);

The assets should instead be sent to the RToken contract which handles the actual transfer to the user:

function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
// ...
if (receiverOfUnderlying != address(this)) {
@> IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
// ...
}

PoC

⚠️ In order to test the withdraw flow we first need to fix the deposit flow which requires a code change in the LendingPool::_depositIntoVault() function:

function _depositIntoVault(uint256 amount) internal {
// First transfer assets from RToken to LendingPool
+ IRToken(reserve.reserveRTokenAddress).transferAsset(address(this), amount);
// Then approve and deposit into vault
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
}

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACMinter, IRAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {PercentageMath} from "../../contracts/libraries/math/PercentageMath.sol";
import {ILendingPool} from "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {IStabilityPool} from "../../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
import {WadRayMath} from "../../contracts/libraries/math/WadRayMath.sol";
import {stdError} from "forge-std/StdError.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockCurveVault {
using SafeERC20 for IERC20;
IERC20 public immutable token;
uint256 public balance;
constructor(address _token) {
token = IERC20(_token);
}
function deposit(uint256 amount, address receiver) external returns (uint256) {
// Transfer tokens from sender to vault
token.safeTransferFrom(msg.sender, address(this), amount);
balance += amount;
return amount;
}
function withdraw(
uint256 amount,
address receiver,
address owner,
uint256, // maxLoss
address[] calldata // strategies
) external returns (uint256) {
// Transfer tokens from vault to receiver
token.safeTransfer(receiver, amount);
balance -= amount;
return amount;
}
}
contract FoundryTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACMinter public raacMinter;
crvUSDToken public crvusd;
RToken public rToken;
DEToken public deToken;
RAACToken public raacToken;
RAACNFT public raacNFT;
DebtToken public debtToken;
RAACHousePrices public raacHousePrices;
MockCurveVault public mockCurveVault;
address public owner;
address public user1;
address public user2;
address public user3;
address public treasury;
uint256 public constant INITIAL_BALANCE = 1000e18;
uint256 public constant INITIAL_PRIME_RATE = 1e27;
uint256 constant INITIAL_BATCH_SIZE = 3;
uint256 constant HOUSE_PRICE = 100e18;
uint256 constant TOKEN_ID = 1;
function setUp() public {
// Setup accounts
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
treasury = makeAddr("treasury");
// Deploy base tokens
crvusd = new crvUSDToken(owner);
crvusd.setMinter(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy price oracle and set oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
// Set initial house prices
raacHousePrices.setHousePrice(TOKEN_ID, HOUSE_PRICE);
// Deploy NFT
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
// Deploy pool tokens
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
mockCurveVault = new MockCurveVault(address(crvusd));
// Deploy pools
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
lendingPool.setCurveVault(address(mockCurveVault));
stabilityPool = new StabilityPool(owner);
// this is needed otherwise lastEmissionUpdateTimestamp will underflow in the RAACMinter constructor
vm.warp(block.timestamp + 2 days);
// Deploy RAAC minter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
// Setup cross-contract references
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
// Initialize Stability Pool
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvusd),
address(lendingPool)
);
// Setup permissions
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function test_CurveVaultWithdraw() public {
assertEq(address(mockCurveVault.token()), address(crvusd));
assertEq(address(lendingPool.curveVault()), address(mockCurveVault));
crvusd.mint(user1, INITIAL_BALANCE);
vm.startPrank(user1);
crvusd.approve(address(lendingPool), INITIAL_BALANCE);
lendingPool.deposit(INITIAL_BALANCE);
// calculate buffer
uint256 totalDeposits = INITIAL_BALANCE;
uint256 desiredBuffer = totalDeposits.percentMul(lendingPool.liquidityBufferRatio());
uint256 excess = totalDeposits - desiredBuffer;
uint256 crvMockCurveVaultBalance = crvusd.balanceOf(address(mockCurveVault));
assertEq(crvMockCurveVaultBalance, excess);
vm.expectRevert();
// This reverts because the internal function _ensureLiquidity()
// transfers the crvUSD from the vault to the lending pool instead of the RToken contract
// which handles the burn and transfer of the crvUSD to the user
lendingPool.withdraw(INITIAL_BALANCE);
vm.stopPrank();
}
}

Impact

  • Users are unable to withdraw their assets from the protocol because the transaction will always revert => Loss of funds for users

  • This effectively locks user funds in the crvUSDVault => The owner of the shares is the LendingPool contract, there is no way to redeem / withdraw from the pool anymore.

  • According to Yearn's v3 documentation, setting maxLoss to 0 means:

    • The withdrawal will revert if there is any loss in the vault

    • Users cannot withdraw their funds even if they would accept some loss

    • Even when there is 0.01% loss in the vault, withdrawals won't work

  • Protocol needs to redeploy everything

Tools Used

  • Foundry

  • Manual Review

Recommendations

Modify the arguments in the _withdrawFromVault() function, to fix issue 1 & 2:

function _withdrawFromVault(uint256 amount) internal {
curveVault.withdraw(
amount,
reserve.reserveRTokenAddress, // Send assets to RToken to handle the transfer during burn()
address(this), // LendingPool is the owner of shares
0, // This requires some more work to fix as described below
new address[](0)
);
totalVaultDeposits -= amount;
}

To fix the issue 3 regarding the maxLoss the protocol could:

  • Let the user pass in an aditional argument for the maxLoss he is willing to take when he calls LendingPool::withdraw() and handle the burn / transfer of the correct token amounts and update all necessary states accordingly

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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

LendingPool::_withdrawFromVault incorrectly uses msg.sender instead of address(this) as the owner parameter, causing vault withdrawals to fail

LendingPool::_withdrawFromVault hardcodes maxLoss to 0, causing reverts when Curve vault applies any fees or slippage to withdrawals

inallhonesty Lead Judge 4 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

LendingPool::_withdrawFromVault incorrectly uses msg.sender instead of address(this) as the owner parameter, causing vault withdrawals to fail

LendingPool::_withdrawFromVault hardcodes maxLoss to 0, causing reverts when Curve vault applies any fees or slippage to withdrawals

Support

FAQs

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