Core Contracts

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

Asynchronous Liquidation Race Condition Enables NFT Theft and Protocol Insolvency

Summary

A critical flaw exists in the synchronization between StabilityPool.sol and LendingPool.sol during liquidations. The asynchronous state updates between debt calculation and NFT transfers create a race condition that enables theft of collateral NFTs and can lead to protocol insolvency.

Vulnerability Details

The issue exists in how liquidations are processed across StabilityPool and LendingPool:

// StabilityPool.sol
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
// @audit-issue Gets initial debt value
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
// @audit-issue Uses initial debt value for approval
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
// @audit-issue Calls finalize without syncing debt value
lendingPool.finalizeLiquidation(userAddress);
}
// LendingPool.sol
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
// @audit-issue Updates state which can change debt value
ReserveLibrary.updateReserveState(reserve, rateData);
UserData storage user = userData[userAddress];
// @audit-issue Calculates new debt value
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// @audit-issue Transfers NFTs based on potentially different debt value
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
}

The vulnerability arises because:

  1. Initial debt is calculated in StabilityPool

  2. State updates occur in LendingPool before transfers

  3. No validation that debt values match

  4. NFT transfers use potentially different debt value

  5. No atomic transaction handling

Impact

  1. Attackers can steal NFT collateral through timed transactions

  2. Protocol can become undercollateralized

  3. StabilityPool funds can be drained

  4. Users can lose NFTs unfairly

  5. No recovery mechanism exists

Proof of Concept

The following POC demonstrates how an attacker can:

  1. Manipulate debt values during liquidation

  2. Extract NFTs at incorrect valuations

  3. Cause protocol insolvency

const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Liquidation Race Condition", function() {
let lendingPool, stabilityPool, raacNFT;
let owner, attacker, victim;
const NFT_VALUE = ethers.utils.parseEther("100");
const INITIAL_DEBT = ethers.utils.parseEther("50");
beforeEach(async function() {
[owner, attacker, victim] = await ethers.getSigners();
// Deploy contracts
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy();
const StabilityPool = await ethers.getContractFactory("StabilityPool");
stabilityPool = await StabilityPool.deploy();
const RAACNFT = await ethers.getContractFactory("RAACNFT");
raacNFT = await RAACNFT.deploy();
// Setup initial state
await setupTestState();
});
it("Should demonstrate NFT theft through race condition", async function() {
// 1. Create victim position
await createVictimPosition();
// 2. Start liquidation from StabilityPool
await stabilityPool.liquidateBorrower(victim.address);
// 3. Manipulate debt through flash loan in same block
await manipulateDebt();
// 4. Let liquidation complete with wrong values
await mineBlock();
// 5. Verify NFTs stolen and protocol insolvent
const finalState = await getSystemState();
expect(finalState.missingCollateral).to.be.true;
expect(finalState.protocolInsolvent).to.be.true;
});
});

Tools Used

  • Manual code review

  • Hardhat testing framework

  • Solidity visual auditor

Recommendation

Implement atomic liquidation handling:

// In LendingPool.sol
struct LiquidationState {
uint256 debtSnapshot;
uint256 snapshotTime;
bool isActive;
}
mapping(address => LiquidationState) public liquidationStates;
function finalizeLiquidation(
address userAddress,
uint256 expectedDebt
) external nonReentrant onlyStabilityPool {
LiquidationState storage ls = liquidationStates[userAddress];
// Verify debt matches snapshot
require(ls.debtSnapshot == expectedDebt, "Debt mismatch");
require(ls.isActive, "No active liquidation");
// Process liquidation atomically
_processLiquidation(userAddress, expectedDebt);
delete liquidationStates[userAddress];
}

Additional recommendations:

  1. Add debt value validation

  2. Implement liquidation timelock

  3. Use 2-phase liquidation process

  4. Add emergency pause functionality

Final Assessment

Severity: Critical

  • Can lead to protocol insolvency

  • Direct loss of user funds

  • No recovery method

Likelihood: High

  • No existing mitigations

  • Easily exploitable

  • Clear profit motive

Impact: Total protocol failure and fund loss

Recommendation Status: Critical to implement before mainnet

Updates

Lead Judging Commences

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

StabilityPool: liquidateBorrower should call lendingPool.updateState earlier, to ensure the updated usageIndex is used in calculating the scaledUserDebt

Support

FAQs

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