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.
Chain
Other
Category
untagged
Date
Jan 2025
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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.
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-registrymirror.
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#
| Impact | HIGH — every liquidateBorrower reverts InsufficientBalance; undercollateralized debt cannot be closed |
| Protocol | Core contracts (Codehawks) — StabilityPool.liquidateBorrower |
| Vulnerable code | Requires crvUSDToken.balanceOf(this) >= scaledUserDebt but SP never receives crvUSD |
| Bug class | Liquidity source mismatch / broken liquidation path |
| Finding | Codehawks · #57187 · reporter s4muraii77 |
| Report | N/A (AuditVault capture) |
| Source | AuditVault |
| Fix | Hold crvUSD in SP, or pull from reserveRTokenAddress before repaying debt |
| Compiler | ^0.8.24 (PoC) |
TL;DR#
- Operational deposits route crvUSD into the reserve, never into the StabilityPool.
- A borrower has outstanding debt and should be liquidatable.
liquidateBorrowerreads SP's crvUSD balance (always 0) and revertsInsufficientBalance.- Debt remains; solvency invariant is broken.
Vulnerable code#
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#
Impact#
Liquidations are permanently bricked under normal operation → bad debt accumulates and protocol solvency breaks.
Sources#
- AuditVault #57187
- Reduced
StabilityPool.liquidateBorrowerfrom the finding (Codehawks / core contracts)
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 57187-any-attempt-to-liquidate-a-user-will-fail-because-stabilityp_exp (evm-hack-registry mirror).
- AuditVault finding: 57187-any-attempt-to-liquidate-a-user-will-fail-because-stabilityp.
Alerts & third-party analyses
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.