Reproduced Exploit

Capping each partyB loss at the current balance turns a 40 credit into a 1,000 withdrawal

PartyB has a -960 position and a +1,000 position, and an allocated balance of 0. Liquidating the loss first deducts min(960, 0) = 0. Liquidating the profit then adds 1,000. PartyB withdraws 1,000. Netting both positions before applying them leaves 40, and 960 of the 1,000 reserve stays in the book.…

Aug 2023•Ethereum•logic•4 min read

Loss

PartyB withdraws 1,000 of other users' cash. The fair net of the two positions is 40, so 960 is taken

Chain

Ethereum

Category

logic

Date

Aug 2023

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: 26347-h-2-liquidatepositionspartya-limits-partyb-loss-to-partyb-al. Standalone Foundry PoC and full write-up: 26347-h-2-liquidatepositionspartya-limits-partyb-loss-to-partyb-al_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/state-update · vuln/logic/liquidation-logic

Reproduction: self-contained synthetic Foundry reduction; see output.txt.

Key info#

FieldValue
LossPartyB withdraws 1,000 of other users' cash. The fair net of the two positions is 40, so 960 is taken
Vulnerable contractPartyBBook26347.liquidate (reduction of LiquidationFacetImpl.liquidatePositionsPartyA)
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2023-08
Compilersolc 0.8.27, cancun, via IR
Bug classEach partyB loss is capped by partyB's allocated balance at that moment, so loss-then-profit drops the loss

TL;DR#

PartyB has a -960 position and a +1,000 position, and an allocated balance of 0. Liquidating the loss first deducts min(960, 0) = 0. Liquidating the profit then adds 1,000. PartyB withdraws 1,000. Netting both positions before applying them leaves 40, and 960 of the 1,000 reserve stays in the book. The numbers are the ones in the Sherlock report.

Background#

Sherlock 2023-08 SYMMIO, LiquidationFacetImpl.sol around lines 163-166. amountToDeduct is the minimum of the position loss and partyB's current allocated balance, and that minimum is what gets subtracted. The order of the positions changes the result. Profit first would credit the balance and the later loss would be fully deductible. Loss first forgives the loss and then books the profit in full. PartyB can already have deallocated down to 0 because the net uPnL is small. The report's worked example is upnl -960 and +1,000, allocated 0, final balance 1,000 instead of less than 60.

The vulnerable code#

SOLIDITY
uint256 amountToDeduct = amount < bal ? amount : bal;
allocated -= int256(amountToDeduct);
// @> VULN: the loss is capped by partyB's current allocated balance, so a loss that lands before a profit is dropped.
lastDeducted = amountToDeduct;

Root cause#

The cap is applied per position against a balance the same liquidation is about to change. A loss that arrives while the balance is 0 disappears. A later profit does not look back at it.

Preconditions#

  • PartyB has one position at -960 and another at +1,000, and allocated balance 0.
  • PartyA's liquidation processes the loss first.
  • The book holds 1,000 of other users' cash, which is what the inflated balance can withdraw.

Attack walkthrough#

  1. The loss deducts 0 (output.txt:9).
  2. After the profit, partyB's balance is 1,000 (output.txt:11).
  3. PartyB withdraws 1,000 (output.txt:13).
  4. Control nets the two positions. The fair balance is 40 (output.txt:15).
  5. The control book keeps 960 (output.txt:17). The extra taken on the vulnerable path is 960 (output.txt:19).
  6. [PASS] (output.txt:6).

Diagrams#

flowchart TD loss["Liquidate the -960 position while allocated is 0"] --> zero["The cap deducts 0"] zero --> profit["Add the +1000 profit in full"] profit --> withdraw["PartyB withdraws 1000 of other users cash"] withdraw --> ctrl["Control nets both positions and pays 40, leaving 960"]

Impact#

Other users' cash backs a credit the protocol did not receive funds for. PartyB is paid 1,000 instead of 40. The 960 difference is the insolvency the report describes: the book owes more than it holds, and the last withdrawers are the ones who cannot leave.

Remediation#

Sum the signed PnL of every position for a partyB, then apply that one net to the allocated balance. Do not cap each loss against the balance left by the previous position in the same liquidation.

How to reproduce#

BASH
cd 26347-h-2-liquidatepositionspartya-limits-partyb-loss-to-partyb-al_exp
forge test -vv

Sources#


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.