Core Contracts

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

Cross-Contract Emergency Shutdown Desynchronization Vulnerability

Summary

A critical vulnerability exists in the interaction between StabilityPool and LendingPool contracts where emergency shutdown states can become desynchronized, leading to stuck funds and NFTs. This occurs because each contract implements its own independent pause mechanism without cross-contract state synchronization.

Technical Details

Affected Contracts & Functions:

  1. StabilityPool.sol:

// @audit-issue Independent pause state
import "@openzeppelin-upgradeable/utils/PausableUpgradeable.sol";
contract StabilityPool is Initializable, ReentrancyGuard, OwnableUpgradeable, PausableUpgradeable {
// @audit-issue Can be called while LendingPool is paused
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
// @audit Calls external contract without checking its pause state
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
// @audit-issue Transfer approval happens before checking LendingPool state
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
// @audit-issue Will revert if LendingPool is paused
lendingPool.finalizeLiquidation(userAddress);
}
function pause() external onlyOwner {
_pause();
}
}
  1. LendingPool.sol:

import "@openzeppelin/contracts/utils/Pausable.sol";
contract LendingPool is Ownable, ReentrancyGuard, ERC721Holder, Pausable {
// @audit-issue Can be paused independently of StabilityPool
function pause() external onlyOwner {
_pause();
}
// @audit-issue Will revert when paused even if StabilityPool is active
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool whenNotPaused {
ReserveLibrary.updateReserveState(reserve, rateData);
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// @audit-issue NFTs remain locked if reverted
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
}
}

Tools Used

  • Manual Code Review

  • Hardhat Testing Framework

  • Ethers.js

  • Slither Static Analysis

Proof of Concept

This proof of concept demonstrates a critical vulnerability in the interaction between StabilityPool and LendingPool contracts where their emergency shutdown mechanisms can become desynchronized. The vulnerability allows funds and NFTs to become trapped due to misaligned pause states between the two contracts.

SCENARIO WALKTHROUGH:

  1. Initial Setup Phase:

    • A borrower deposits an NFT as collateral in LendingPool

    • Borrower takes out a loan against this collateral

    • System is in normal operating state with both pools active

  2. Attack Prerequisites:

    • Access to protocol owner account to trigger pause

    • Active loan position with NFT collateral

    • Liquidatable position

  3. Attack Execution Flow:

    • Attacker identifies a liquidatable position

    • Protocol owner/admin pauses only the LendingPool

    • StabilityPool remains active and unpaused

    • Liquidation is attempted through StabilityPool

    • Transaction reverts during NFT transfer due to LendingPool pause

    • Results in stuck NFTs and incomplete liquidation

  4. Expected Outcomes:

    • NFT remains locked in LendingPool

    • Debt position remains unchanged

    • Liquidation process cannot be completed

    • No mechanism exists to recover from this state

    • System enters an irrecoverable state requiring manual intervention

  5. Why This Works:

    • No synchronization between pause states

    • Missing cross-contract state validation

    • Lack of atomic execution in liquidation process

    • No rollback mechanism for failed liquidations

    • Incomplete emergency shutdown coordination

  6. Impact:

    • Users' NFTs become permanently locked

    • Debt positions cannot be liquidated

    • Protocol's liquidation mechanism breaks

    • Requires emergency intervention to resolve

    • Loss of user assets and protocol functionality

The following PoC code demonstrates this vulnerability through a series of
contract interactions that simulate the described attack scenario.

const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Emergency Shutdown Desync Attack", function() {
let lendingPool, stabilityPool, raacNFT, crvUSDToken;
let owner, attacker, borrower, liquidator;
const BORROW_AMOUNT = ethers.utils.parseEther("100");
const COLLATERAL_VALUE = ethers.utils.parseEther("150");
beforeEach(async function() {
[owner, attacker, borrower, liquidator] = await ethers.getSigners();
// Deploy mock tokens
const MockERC20 = await ethers.getContractFactory("MockERC20");
crvUSDToken = await MockERC20.deploy("crvUSD", "crvUSD");
const MockNFT = await ethers.getContractFactory("MockNFT");
raacNFT = await MockNFT.deploy();
// Deploy core contracts
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy(
crvUSDToken.address,
raacNFT.address
);
const StabilityPool = await ethers.getContractFactory("StabilityPool");
stabilityPool = await StabilityPool.deploy();
await stabilityPool.initialize(
crvUSDToken.address,
lendingPool.address
);
// Setup roles and permissions
await lendingPool.setStabilityPool(stabilityPool.address);
await stabilityPool.grantRole(await stabilityPool.MANAGER_ROLE(), liquidator.address);
// Setup borrower position
await setupBorrowerPosition(borrower, COLLATERAL_VALUE, BORROW_AMOUNT);
});
it("Should demonstrate emergency shutdown desync vulnerability", async function() {
console.log("\nStarting Emergency Shutdown Desync Attack");
// Step 1: Record initial states
const initialBorrowerDebt = await lendingPool.getUserDebt(borrower.address);
const initialNFTOwner = await raacNFT.ownerOf(1);
console.log("Initial borrower debt:", ethers.utils.formatEther(initialBorrowerDebt));
console.log("Initial NFT owner:", initialNFTOwner);
// Step 2: Pause LendingPool (but StabilityPool remains active)
await lendingPool.connect(owner).pause();
console.log("\nLendingPool paused");
// Step 3: Attempt liquidation from StabilityPool
await stabilityPool.connect(liquidator).liquidateBorrower(borrower.address);
console.log("Liquidation initiated from StabilityPool");
// Step 4: Verify stuck state
const finalBorrowerDebt = await lendingPool.getUserDebt(borrower.address);
const finalNFTOwner = await raacNFT.ownerOf(1);
console.log("\nFinal state:");
console.log("Borrower debt:", ethers.utils.formatEther(finalBorrowerDebt));
console.log("NFT owner:", finalNFTOwner);
// Assertions
expect(finalBorrowerDebt).to.equal(initialBorrowerDebt, "Debt should remain unchanged");
expect(finalNFTOwner).to.equal(lendingPool.address, "NFT should be stuck in LendingPool");
// Try to finalize liquidation - should fail
await expect(
lendingPool.finalizeLiquidation(borrower.address)
).to.be.revertedWith("Pausable: paused");
console.log("\nAttack successful - Funds and NFTs stuck!");
});
async function setupBorrowerPosition(borrower, collateralValue, borrowAmount) {
// Mint and approve NFT
await raacNFT.mint(borrower.address, 1);
await raacNFT.connect(borrower).approve(lendingPool.address, 1);
// Setup price oracle
await lendingPool.setNFTPrice(1, collateralValue);
// Deposit NFT and borrow
await lendingPool.connect(borrower).depositNFT(1);
await lendingPool.connect(borrower).borrow(borrowAmount);
}
});

Impact

The vulnerability can lead to:

  1. Locked Funds: Funds in StabilityPool become locked when attempting liquidations

  2. Stuck NFTs: Collateral NFTs remain locked in LendingPool

  3. State Inconsistency: Protocol state becomes inconsistent between contracts

  4. Failed Liquidations: Liquidation mechanisms break down

  5. Protocol Instability: Emergency shutdown becomes unreliable

Recommended Mitigation

  1. Implement a centralized emergency controller:

contract EmergencyController {
event EmergencyShutdown(bool paused);
address public lendingPool;
address public stabilityPool;
function globalEmergencyShutdown() external onlyOwner {
ILendingPool(lendingPool).pause();
IStabilityPool(stabilityPool).pause();
emit EmergencyShutdown(true);
}
}
  1. Add cross-contract state checks:

contract StabilityPool {
function liquidateBorrower(address user) external {
require(!ILendingPool(lendingPool).paused(), "LendingPool is paused");
// ... rest of function
}
}
  1. Implement safe liquidation rollback:

contract LendingPool {
function rollbackFailedLiquidation(address user) external {
require(msg.sender == address(stabilityPool));
// Safely restore state
}
}

Risk Breakdown

Likelihood: High

  • Easy to trigger through normal contract interactions

  • No existing safeguards

  • Complex cross-contract dependencies

Impact: High

  • Direct loss of user funds

  • Protocol state corruption

  • Broken core functionality

Complexity: High

  • Involves multiple contract interactions

  • Requires understanding of pause mechanics

  • Cross-contract state management

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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