Core Contracts

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

Mismatch of crvUSD approved versus transferred between StabilityPool and LendingPool prevents users from being liquidated

Description

For user liquidation expected behavior is that an Owner or Manager of StabilityPool calls into StabilityPool::liquidateBorrower to finalize the liquidation of an under-collateralized user after the grace period has passed. A mismatch between the approval of the crvUSD given within StabilityPool::liquidateBorrower versus the actual transfer of those tokens from LendingPool::finalizeLiquidation will cause the liquidation though to revert.

Vulnerable Code

LendingPool::finalizeLiquidation:

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool { //
/// *** SNIP *** ///
@> IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}

StabilityPool::liquidateBorrower:

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
uint256 userDebt = lendingPool.getUserDebt(userAddress);
@> uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
lendingPool.updateState();
lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}

On the functions above at the highlight marks you can see the points in which the approval and transfer ratios are calculated. The issue which arises is that LendingPool::finalizeLiquidation uses a different way than StabilityPool::liquidateBorrower, LendingPool factors in accrued interest rates, while the StabilityPool does not, resulting in a mismatched (too small) approval within the StabilityPool::liquidateBorrower function, thus reverting the liquidation of the user.

PoC

Since the PoC is a foundry test I have added a Makefile at the end of this report to simplify installation for your convenience. Otherwise if console commands would be prefered:

First run: npm install --save-dev @nomicfoundation/hardhat-foundry

Second add: require("@nomicfoundation/hardhat-foundry"); on top of the Hardhat.Config file in the projects root directory.

Third run: npx hardhat init-foundry

And lastly, you will encounter one of the mock contracts throwing an error during compilation, this error can be circumvented by commenting out the code in entirety (ReserveLibraryMocks.sol).

And the test should be good to go:

After following above steps copy & paste the following code into ./test/invariant/PoC.t.sol and run forge test --mt test_PocLiquidationNotPossibleDueToAllowanceTransferMismatch -vvvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {CrvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACHousePrices} from "../../contracts/core/oracles/RAACHousePriceOracle.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 {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
contract PoC is Test {
StabilityPool public stabilityPool;
LendingPool public lendingPool;
CrvUSDToken public crvusd;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
RAACToken public raacToken;
RAACMinter public raacMinter;
address owner;
address oracle;
address user1;
address user2;
address user3;
uint256 constant STARTING_TIME = 1641070800;
uint256 public currentBlockTimestamp;
uint256 constant WAD = 1e18;
uint256 constant RAY = 1e27;
function setUp() public {
vm.warp(STARTING_TIME);
currentBlockTimestamp = block.timestamp;
owner = address(this);
oracle = makeAddr("oracle");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
uint256 initialPrimeRate = 0.1e27;
raacHousePrices = new RAACHousePrices(owner);
vm.prank(owner);
raacHousePrices.setOracle(oracle);
crvusd = new CrvUSDToken(owner);
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
vm.prank(owner);
crvusd.setMinter(owner);
vm.prank(owner);
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool = new StabilityPool(address(owner));
deToken.setStabilityPool(address(stabilityPool));
raacToken = new RAACToken(owner, 0, 0);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
stabilityPool.initialize(address(rToken), address(deToken), address(raacToken), address(raacMinter), address(crvusd), address(lendingPool));
vm.prank(owner);
raacToken.setMinter(address(raacMinter));
crvusd.mint(address(attacker), type(uint128).max);
crvusd.mint(user1, type(uint128).max);
crvusd.mint(user2, type(uint128).max);
crvusd.mint(user3, type(uint128).max);
}
function test_PocLiquidationNotPossibleDueToAllowanceTransferMismatch() public {
/// Setting the stage, incl. minting crvUSD to stability pool
vm.prank(owner);
lendingPool.setStabilityPool(address(stabilityPool));
crvusd.mint(address(stabilityPool), type(uint128).max);
vm.startPrank(user2);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(type(uint128).max);
vm.stopPrank();
vm.startPrank(oracle);
raacHousePrices.setHousePrice(1, 10e18);
raacHousePrices.setHousePrice(2, 10e18);
raacHousePrices.setHousePrice(3, 10e18);
raacHousePrices.setHousePrice(4, 10e18);
vm.stopPrank();
/// Acquiring the NFTs, depositing them and borrow against them
vm.startPrank(user1);
crvusd.approve(address(raacNFT), type(uint256).max);
raacNFT.mint(1, 10e18);
raacNFT.mint(2, 10e18);
raacNFT.mint(3, 10e18);
raacNFT.mint(4, 10e18);
raacNFT.approve(address(lendingPool), 1);
raacNFT.approve(address(lendingPool), 2);
raacNFT.approve(address(lendingPool), 3);
lendingPool.depositNFT(1);
lendingPool.depositNFT(2);
lendingPool.depositNFT(3);
lendingPool.borrow(24e18);
/// Reducing the Price of the NFTs, making the user liquidatable
vm.startPrank(oracle);
raacHousePrices.setHousePrice(1, 4e18);
raacHousePrices.setHousePrice(2, 4e18);
raacHousePrices.setHousePrice(3, 4e18);
raacHousePrices.setHousePrice(4, 100e18);
vm.stopPrank();
/// Initiate liquidation and rolling forward to pass grace period
lendingPool.initiateLiquidation(user1);
uint256 time = block.timestamp;
vm.warp(time + 4 * 1 days);
/// Finalizing the Liquidation, which reverts due to ERC20InsufficientAllowance
vm.prank(owner);
stabilityPool.liquidateBorrower(user1);
}

Running above test will produce the following log:

Ran 1 test suite in 1.25s (76.97ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/invariant/PoC.t.sol:PoC
[FAIL: ERC20InsufficientAllowance(0x1d1499e622D69689cdf9004d05Ec547d650Ff211, 24000000000000000000 [2.4e19], 24006576243279862299 [2.4e19])] test_PocLiquidationNotPossibleDueToAllowanceTransferMismatch() (gas: 1943443)

Inspecting the output further with -vvvvv shows us the following 2 lines clearly:

/////// ***SNIP*** //////////
[24780] CrvUSDToken::approve(LendingPool: [0x1d1499e622D69689cdf9004d05Ec547d650Ff211], 24000000000000000000 [2.4e19])
emit Approval(owner: StabilityPool: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], spender: LendingPool: [0x1d1499e622D69689cdf9004d05Ec547d650Ff211], value: 24000000000000000000 [2.4e19])
/////// ***SNIP*** //////////
CrvUSDToken::transferFrom(StabilityPool: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], RToken: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 24006576243279862299 [2.4e19])
│ │ │ └─ ← [Revert] ERC20InsufficientAllowance(0x1d1499e622D69689cdf9004d05Ec547d650Ff211, 24000000000000000000 [2.4e19], 24006576243279862299 [2.4e19])
/////// ***SNIP*** //////////

StabilityPool::liquidateBorrower approves 2.4e19 crvUSD for the Lending Pool to pull, but the transfer actually executed carries dust (accrued interest over the 4 days) above the approved balance (2.40065....) causing a revert in the liquidation process.

Impact

Failure to liquidate users leads to accumulation of bad debt and therefor directly harming the solvency invariant of the protocol to the point at which the protocol is not able to pay outstanding balances to it's users. Therefore the severity is High (critical) by default.

Tools Used

Manual Review & Foundry

Recommended Mitigation

Assure that the approval set in StabilityPool::liquidateBorrower is sufficient for the transaction to succeed, by factoring in accrued interest of the borrower, e.g. like:

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
- bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
+ bool approveSuccess = crvUSDToken.approve(address(lendingPool), type(uint256).max);
if (!approveSuccess) revert ApprovalFailed();
lendingPool.updateState();
lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}

Since the function has strict access control and the lending pool only 1 way to withdraw those funds, a max approval should be okay, to ensure the liquidation will succeed.

Appendix

Copy the following import into your Hardhat.Config file in the projects root dir:
require("@nomicfoundation/hardhat-foundry");

Paste the following into a new file "Makefile" into the projects root directory:

.PHONY: install-foundry init-foundry all
install-foundry:
npm install --save-dev @nomicfoundation/hardhat-foundry
init-foundry: install-foundry
npx hardhat init-foundry
# Default target that runs everything in sequence
all: install-foundry init-foundry

And run make all

Updates

Lead Judging Commences

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

StabilityPool::liquidateBorrower double-scales debt by multiplying already-scaled userDebt with usage index again, causing liquidations to fail

Support

FAQs

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