Reproduced Exploit
Lend V2: cross-chain liquidation reduces debt by the collateral seized
Chain
Other
Category
untagged
Date
Jan 1970
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: 836. Standalone Foundry PoC and full write-up: 58388-lend-cross-chain-liquidation-reduces-debt-by-collateral-seized_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic · vuln/accounting · cross-chain-message
Reproduction: a faithful minimal reproduction of the vulnerable finding — the cross-chain liquidation functions of
CrossChainRouter(_executeLiquidationCore,_handleLiquidationExecute,_handleLiquidationSuccess,repayCrossChainBorrowInternal) are reproduced verbatim (the finding's anchor line marked@>) with faithful minimal doubles and an in-process LayerZero transport; local deploy, no fork.
Root cause#
Cross-chain liquidation reuses a single generic Payload.amount field across every LayerZero hop. _executeLiquidationCore writes seizeTokens (the amount of collateral to seize, in collateral-lToken units) into that field, and the return hop hands the very same value to repayCrossChainBorrowInternal as the repay amount — so the borrower's debt is reduced by the collateral seized, not by the liquidator's actual params.repayAmount. The vulnerable send, reproduced verbatim from the finding's Root Cause:
// Send message to Chain A to execute the seize
_send(
params.srcEid,
@> seizeTokens,
params.storedBorrowIndex,
0,
params.borrower,
lendStorage.crossChainLTokenMap(params.lTokenToSeize, params.srcEid), // Convert to Chain A version before sending
msg.sender,
params.borrowedAsset,
ContractType.CrossChainLiquidationExecute
);
Placing seizeTokens here is correct for the seize step on Chain A. The bug is that the return hop (_handleLiquidationExecute) forwards this same payload.amount back to Chain B, where _handleLiquidationSuccess passes it straight into repayCrossChainBorrowInternal as the debt repay amount.
Why it's exploitable here#
Following the finding's worked example — liquidationIncentive = 1.08, equal borrowed/collateral prices, exchangeRate = 1:
- A liquidator initiates a cross-chain liquidation with
repayAmount = 500e18against a borrower whose Chain B debt principle is1000e18. liquidateCalculateSeizeTokenscomputesseizeTokens = 500e18 * 1.08 = 540e18of collateral to seize.540e18is packed intoPayload.amount, seized on Chain A, then bounced back unchanged and consumed byrepayCrossChainBorrowInternalas the repay amount.- The borrower's debt is reduced by
540e18instead of the500e18actually repaid — a40e18discrepancy of debt erased with no matching repayment, corrupting protocol accounting (over-repayment here; under-repayment wheneverseizeTokens < repayAmount).
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xd3760b84…:
- L58 — Seize amount scaled from repay: The liquidation math scales the 500e18 repayAmount by the 1.08 incentive, computing seizeTokens = 540e18 of collateral to seize.
- L294 — Repay signal reports seize amount: RepaySuccess fires when the borrow is repaid; its reported amount becomes the 540e18 seize value, masking the mis-accounting as a normal repayment.
- L327 — Seize amount reused as repay: Root cause: seizeTokens (540e18 collateral) is packed into the generic Payload.amount and later consumed as the debt repay amount, cutting debt by the seize value.
- L368 — Chain A forwards the seize amount: Chain A executes the seize, emits LiquidateBorrow, then sends the same payload.amount (still 540e18 seizeTokens) back to Chain B as the repay message.
- L448 — Load borrower's debt position: Back on Chain B,
_getBorrowDetailsloads the borrower's cross-chain borrow (principle 1000e18) so the incoming repay message can be applied. - L523 — Same amount reused everywhere: Every hop, even this failure-bounce path, reuses one generic payload.amount, so the 1000e18 debt is cut by 540e18 seized, not the 500e18 repaid.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 58388-lend-cross-chain-liquidation-reduces-debt-by-collateral-seized_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: liquidator repays 500e18, but the borrower's debt is reduced by the 540e18 seize amount — a 40e18 mis-accounting with no matching repayment. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 58388-lend-cross-chain-liquidation-reduces-debt-by-collateral-seized_exp (evm-hack-registry mirror).
- AuditVault finding: 836.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Lend V2: cross-chain liquidation reduces debt by the collateral seized".
- 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.