Reproduced Exploit

Any attempt to liquidate a user will fail — StabilityPool never holds crvUSD

1. Operational deposits route crvUSD into the reserve, never into the StabilityPool. 2. A borrower has outstanding debt and should be liquidatable. 3. liquidateBorrower reads SP's crvUSD balance (always 0) and reverts InsufficientBalance. 4. Debt remains; solvency invariant is broken.

Jan 2025Otheruntagged2 min read

Chain

Other

Category

untagged

Date

Jan 2025

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 57187-any-attempt-to-liquidate-a-user-will-fail-because-stabilityp. Standalone Foundry PoC and full write-up: 57187-any-attempt-to-liquidate-a-user-will-fail-because-stabilityp_exp in the evm-hack-registry mirror.


Vulnerability classes: liquidation-logic · reward-accounting · permanent

Reproduction: self-contained Foundry PoC with only forge-std — no fork. output.txt · test/57187-…_exp.sol.

AuditVault taxonomy: lang/solidity · platform/codehawks · severity/high · genome: liquidation-logic · liquidation-underwater · permanent


Key info#

ImpactHIGH — every liquidateBorrower reverts InsufficientBalance; undercollateralized debt cannot be closed
ProtocolCore contracts (Codehawks) — StabilityPool.liquidateBorrower
Vulnerable codeRequires crvUSDToken.balanceOf(this) >= scaledUserDebt but SP never receives crvUSD
Bug classLiquidity source mismatch / broken liquidation path
FindingCodehawks · #57187 · reporter s4muraii77
ReportN/A (AuditVault capture)
SourceAuditVault
FixHold crvUSD in SP, or pull from reserveRTokenAddress before repaying debt
Compiler^0.8.24 (PoC)

TL;DR#

  1. Operational deposits route crvUSD into the reserve, never into the StabilityPool.
  2. A borrower has outstanding debt and should be liquidatable.
  3. liquidateBorrower reads SP's crvUSD balance (always 0) and reverts InsufficientBalance.
  4. Debt remains; solvency invariant is broken.

Vulnerable code#

SOLIDITY
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
    _update();
    uint256 userDebt = lendingPool.getUserDebt(userAddress);
    uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
    if (userDebt == 0) revert InvalidAmount();
    uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this)); // @> VULN
    if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance(); // @> VULN
    // ...
}

Diagrams#

flowchart LR Deposit["LendingPool.deposit"] --> Reserve["reserveRTokenAddress"] Repay["LendingPool.repay"] --> Reserve SP["StabilityPool"] -->|"balanceOf = 0"| Check{"balance >= debt?"} Check -->|no| Revert["revert InsufficientBalance"] Check -->|never yes in lifecycle| OK["finalizeLiquidation"]

Impact#

Liquidations are permanently bricked under normal operation → bad debt accumulates and protocol solvency breaks.

Sources#

  • AuditVault #57187
  • Reduced StabilityPool.liquidateBorrower from the finding (Codehawks / core contracts)

Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.