Core Contracts

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

LendingPool operations that relies on `_withdrawFromVault` will revert or burn the user funds

Summary

A critical vulnerability has been identified in the LendingPool contract where operations relying on _withdrawFromVault can either cause a DoS or, in worse cases, lead to the burning of user funds. The root cause is an incorrect parameter being passed to the Curve vault's withdraw function, where the owner parameter is set to msg.sender instead of address(this).

Vulnerability Details

Root cause: In _withdrawFromVault, the withdrawal call is made with:

function _withdrawFromVault(uint256 amount) internal {
// @audit msg.sender is the owner of the shares that will be burned.
curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
...
}

Notice the third parameter (msg.sender) incorrectly specifies the owner of the funds to be withdrawn, but the LendingPool (address(this)) is the actual depositor and owner of the vault shares, not the end user.

Proof here:

function _depositIntoVault(uint256 amount) internal {
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
@> curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
}

This will cause all the functions that use _withdrawFromVaultto be DoS whenever there is a need to withdraw asset tokens from the Curve Vault:

  1. deposit

  2. withdraw

  3. borrow

As a result of this, we have the following issues:

  • The transaction will fail since the LendingPool lacks authorization to spend the user's vault shares

  • If a user authorizes share spending, their funds are permanently lost as their shares will be burned instead of LendingPool shares.

  • Users with existing deposits cannot withdraw their funds

  • All borrowing functionality is blocked

PoC

Before running the PoC we have to implement another related to the curveVault. Related submission here:

Or by the title "LendingPool deposits do not work with CurveVault due to lack of funds".

In the function _rebalanceLiquidity from LendingPooladd the following line:

function _rebalanceLiquidity() internal {
...
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
+ IRToken(reserve.reserveRTokenAddress).transferAsset(address(this), excess);
// Deposit excess into the Curve vault
_depositIntoVault(excess);
}
  1. Install foundry through:

    • npm i --save-dev @nomicfoundation/hardhat-foundry

    • Add require("@nomicfoundation/hardhat-foundry");on hardhat config file

    • Run npx hardhat init-foundry and forge install foundry-rs/forge-std --no-commit

  2. Create a file called LendingPool.t.solin the test folder

  3. Paste the code below:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/DEToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
contract MockCurveVault is ERC4626 {
constructor(address _asset) ERC4626(IERC20(_asset)) ERC20("Staked crvUSD", "stkCRVUSD") {}
// Function withdraw simulating CurveVault v3:
// https://github.com/curvefi/scrvusd/blob/95a120847c7a2901cea5256ba081494e18ea5315/contracts/yearn/VaultV3.vy#L1827-L1833
// @param assets The amount of asset to withdraw.
// @param receiver The address to receive the assets.
// @param owner The address who's shares are being burnt.
// @param max_loss Optional amount of acceptable loss in Basis Points.
// @param strategies Optional array of strategies to withdraw from.
// @return The amount of shares actually burnt.
function withdraw(
uint256 assets,
address receiver,
address owner,
uint256 maxLoss,
address[] calldata strategies
) external returns (uint256 shares) {
return super.withdraw(assets, receiver, owner);
}
}
contract LendingPoolTest is Test {
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
RToken rToken;
DEToken deToken;
DebtToken debtToken;
RAACMinter raacMinter;
crvUSDToken crvUSD;
RAACToken raacToken;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
MockCurveVault public curveVault;
// users
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
address user3 = address(4);
address[] users = new address[](3);
function setUp() public {
// setup users
users[0] = user1;
users[1] = user2;
users[2] = user3;
vm.label(user1, "USER1");
vm.label(user2, "USER2");
vm.label(user3, "USER3");
// initiate timestamp and block
vm.warp(1738798039); // 2025-02-05
vm.roll(100); // block
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000e18);
}
function test_ensureLiquidityCauseDoS_dueToBurnSharesFromTheUser() public {
// pre conditions
// 1. LendingPool has curve vault set and crvUSD tokens
// 2. Users deposit into the pool, minting RTokens
vm.prank(owner);
lendingPool.setCurveVault(address(curveVault));
_depositCrvUsdIntoLendingPoolForAllUsers(100e18);
// action: user call withdraw 50 crvUSD
// expectation: user has RTokens burned and withdraw 50 crvUSD.
// result: _ensureLiquidity tries to withdraw crvUSD from the CurveVault by burning the user shares instead of the LendingPool shares.
// this causes a DoS as user either doesn't have CurveVault shares or user didn't allow LendingPool to spend his shares.
// ps: could be even worse if user has given allowance of his shares to LendingPool, this would burn user shares causing loss of funds.
vm.prank(user1);
lendingPool.withdraw(50e18);
}
// HELPER FUNCTIONS
function _deployAndSetupContracts() internal {
// Deploy base tokens
crvUSD = new crvUSDToken(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy real oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner); // Set owner as oracle
// Deploy real NFT contract
raacNFT = new RAACNFT(
address(crvUSD),
address(raacHousePrices),
owner
);
// Deploy Curve Vault
curveVault = new MockCurveVault(address(crvUSD));
// Deploy core contracts with proper constructor args
rToken = new RToken(
"RToken",
"RTK",
owner,
address(crvUSD)
);
deToken = new DEToken(
"DEToken",
"DET",
owner,
address(rToken)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
owner
);
// Deploy pools with required constructor parameters
lendingPool = new LendingPool(
address(crvUSD), // reserveAssetAddress
address(rToken), // rTokenAddress
address(debtToken), // debtTokenAddress
address(raacNFT), // raacNFTAddress
address(raacHousePrices), // priceOracleAddress
0.8e27 // initialPrimeRate (RAY)
);
// Deploy RAACMinter with valid constructor args
raacMinter = new RAACMinter(
address(raacToken),
address(0x1234324423), // stability pool
address(lendingPool),
owner
);
stabilityPool = new StabilityPool(owner);
stabilityPool.initialize(
address(rToken), // _rToken
address(deToken), // _deToken
address(raacToken), // _raacToken
address(raacMinter), // _raacMinter
address(crvUSD), // _crvUSDToken
address(lendingPool) // _lendingPool
);
// workaround for another bug found in Stability Pool.
deal(address(crvUSD), address(stabilityPool), 100_000e18);
raacMinter.setStabilityPool(address(stabilityPool));
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));
// setup raacToken's minter and whitelist
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function _depositCrvUsdIntoLendingPoolForAllUsers(uint256 initialDeposit) internal {
// iterate users array and deposit into lending pool
for (uint i = 0; i < users.length; i++) {
vm.prank(users[i]);
lendingPool.deposit(initialDeposit);
}
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
_mintCrvUsdTokenToUser(initialBalance, users[i]);
}
}
function _mintCrvUsdTokenToUser(uint256 initialBalance, address user) internal {
vm.prank(owner);
crvUSD.mint(user, initialBalance);
vm.startPrank(user);
crvUSD.approve(address(raacNFT), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
crvUSD.approve(address(curveVault), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
}

run: forge test --match-test test_ensureLiquidityCauseDoS_dueToBurnSharesFromTheUser -vv

Result: LendingPool tries to burn user shares and revert:

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 4.49ms (1.42ms CPU time)
Ran 1 test suite in 122.53ms (4.49ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/LendingPool.t.sol:LendingPoolTest
[FAIL: ERC4626ExceededMaxWithdraw(0x0000000000000000000000000000000000000002, 40000000000000000000 [4e19], 0)] test_ensureLiquidityCauseDoS_dueToBurnSharesFromTheUser() (gas: 780906)

Impact

DoS

  • Users attempting to withdraw or borrow will have their transactions revert

  • This occurs because users typically don't have Curve vault shares

  • Even if they do have shares, they haven't approved the LendingPool to spend them

Loss of Funds

  • If a user has Curve vault shares AND has approved the LendingPool to spend them

  • The transaction will succeed but burn the user's personal vault shares instead of the pool's shares

  • This results in a direct loss of user funds

Tools Used

Manual Review & Foundry

Related submission "LendingPool deposits do not work with CurveVault due to lack of funds"

Recommendations

In _withdrawFromVault modify the withdraw call to use address(this) as the owner parameter and send the withdrawn funds to the RToken contract. The fix will:

  • Burn the LendingPool shares

  • Send the assetTokenwhich is the crvUSD to RToken.

function _withdrawFromVault(uint256 amount) internal {
- curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
+ curveVault.withdraw(amount, address(this), address(this), 0, new address[](0));
+ IERC20(reserve.reserveAssetAddress).safeTransfer(reserve.reserveRTokenAddress, amount);
}

Run the PoC again.

Result: LendingPool liquidity management works as expected.

Ran 1 test for test/LendingPool.t.sol:LendingPoolTest
[PASS] test_ensureLiquidityCauseDoS_dueToBurnSharesFromTheUser() (gas: 668575)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.68ms (2.30ms CPU time)
Updates

Lead Judging Commences

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

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

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

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

Support

FAQs

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