Reproduced Exploit

Liquidating inside a transfer hook drains 1,000 cash the sender never deposited

Alice gifts Bob 1,000 cash she does not have. The book applies that gift, then calls out to acceptData, and only then checks that Alice is solvent. The callback bids Alice's account while she is insolvent. Because the bid is in the same transaction, the whole -1,000 moves to Cynthia and Cynthia is…

Feb 2024•Ethereum•reentrancy•4 min read

Loss

Bob withdraws 1,000 cash. The victim who deposited it is left credited and cannot withdraw

Chain

Ethereum

Category

reentrancy

Date

Feb 2024

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: 26267-funds-can-be-drained-from-the-protocol-by-liquidating-an-acc. Standalone Foundry PoC and full write-up: 26267-funds-can-be-drained-from-the-protocol-by-liquidating-an-acc_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/reentrancy/single-function · vuln/logic/incorrect-order-of-operations

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

Key info#

FieldValue
LossBob withdraws 1,000 cash. The victim who deposited it is left credited and cannot withdraw
Vulnerable contractLyraBook26267.submitTransfer (reduction of Lyra StandardManager / DutchAuction.bid)
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2024-02
Compilersolc 0.8.27, cancun, via IR
Bug classExternal managerData runs before the insolvency check, and the same-transaction bid moves the deficit with no risk check

TL;DR#

Alice gifts Bob 1,000 cash she does not have. The book applies that gift, then calls out to acceptData, and only then checks that Alice is solvent. The callback bids Alice's account while she is insolvent. Because the bid is in the same transaction, the whole -1,000 moves to Cynthia and Cynthia is not risk-checked. Alice is empty, the check passes, and Bob withdraws the victim's 1,000. The same gift on a book that checks solvency first reverts, and that victim still withdraws 1,000.

Background#

Sigma Prime's review of Lyra V2 describes a transfer that leaves the sender insolvent. submitTransfer moves the assets before any risk check, then _managerHook reaches StandardManager.handleAdjustment, which calls _processManagerData before the risk check. The attacker's acceptData starts an insolvent auction and dutchAuction.bid takes 100% of the account. stepInsolvent is zero inside the same transaction, so the liability moves with no risk check on the bidder. The sender is then empty and passes. The recipient settles a profit nobody deposited.

The vulnerable code#

SOLIDITY
net[alice] -= int256(gift);
net[bob] += int256(gift);
if (hookBeforeCheck) {
    // @> VULN: managerData runs before the risk check, so the callback can liquidate Alice and move her deficit away.
    IManagerHook26267(hook).acceptData(alice);
}
require(net[alice] >= 0, "insolvent");

bid then does net[from] = 0; net[bidder] += position with no solvency check on the bidder. That is the "stepInsolvent is zero, full liability, no risk check" step from the report.

Root cause#

Effects run before the check, and the check is on the wrong account. The hook can change who holds the deficit. The sender is tested after the deficit has been moved. The bidder who received it is never tested.

Preconditions#

  • A victim has deposited the cash the recipient will withdraw. Here that deposit is 1,000.
  • The caller can attach managerData that the book will call.
  • The liquidation bid in the same transaction does not risk-check the bidder.

Attack walkthrough#

  1. The book is funded with 1,000 (output.txt:9).
  2. Alice's gift to Bob is applied, then acceptData runs (output.txt:8).
  3. Cynthia's balance becomes -1,000 (output.txt:15). Alice is flat and passes the check.
  4. Bob withdraws 1,000 (output.txt:11). The book has 0 left for the victim (output.txt:13).
  5. The victim's own withdrawal reverts.
  6. Control: the same gift is risk-checked first and reverts. The control victim still receives 1,000 (output.txt:17).
  7. [PASS] (output.txt:6).

Diagrams#

flowchart TD seed["Victim deposits 1000 cash"] --> gift["Alice gifts Bob 1000 before any check"] gift --> hook["Hook bids Alice and moves the deficit to Cynthia"] hook --> ok["Alice is empty and the risk check passes"] ok --> pull["Bob withdraws the 1000 reserve"] pull --> ctrl["Control checks Alice first and the gift reverts"]

Impact#

The victim deposited 1,000 and cannot withdraw it. Bob, who deposited nothing, receives 1,000. Cynthia holds a -1,000 position that was never risk-checked. The control book rejects the gift and the depositor withdraws the full 1,000.

Remediation#

Run the external manager call only after every account that lost value has passed its risk check, and risk-check the bidder who receives a liquidated position. A system-wide reentrancy lock around the transfer is the other half of the fix the report asked for.

How to reproduce#

BASH
cd 26267-funds-can-be-drained-from-the-protocol-by-liquidating-an-acc_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.