Reproduced Exploit
LEND: cross-chain liquidation uses the wrong `srcToken`
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: 720. Standalone Foundry PoC and full write-up: 58383-lend-cross-chain-liquidation-uses-the-wrong-srctoken_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic · vuln/dos · vuln/cross-chain
Reproduction: a faithful minimal reproduction of the vulnerable finding — the settlement leg
_handleLiquidationSuccessis reproduced verbatim (marked@>) with faithful minimal doubles; local deploy, no fork.
Root cause#
On the destination chain, _handleLiquidationSuccess looks up the borrow position by passing payload.srcToken to findCrossChainCollateral. But payload.srcToken carries the destination chain's borrowed token (params.borrowedAsset), while crossChainCollaterals[...].srcToken was stored as the source chain's underlying — so even for the "same" asset (e.g. USDC) the addresses differ and the match fails. The vulnerable settlement code, reproduced verbatim:
function _handleLiquidationSuccess(LZPayload memory payload) private {
// Find the borrow position on Chain B to get the correct srcEid
address underlying = lendStorage.lTokenToUnderlying(payload.destlToken);
// Find the specific collateral record
(bool found, uint256 index) = lendStorage.findCrossChainCollateral(
payload.sender,
underlying,
currentEid, // srcEid is current chain
0, // We don't know destEid yet, but we can match on other fields
payload.destlToken,
@> payload.srcToken
);
require(found, "Borrow position not found");
// ...
findCrossChainCollateral matches on all four discriminators (srcEid, destEid, borrowedlToken, srcToken). Every field lines up except srcToken, so found is false, and require(found, "Borrow position not found") reverts the settlement.
Why it's exploitable here#
Following the finding's flow with concrete values (DEBT = 1000e18, SEIZE_AMOUNT = 1000e18, currentEid = 2):
- A borrower has a cross-chain borrow of
1000e18recorded on the settlement chain; the stored record'ssrcTokenis Chain A's USDC (usdcA). - A liquidator triggers the cross-chain liquidation. Chain A seizes
1000e18of the borrower's collateral — this leg is already committed and irreversible. - The settlement leg runs
_handleLiquidationSuccesswithpayload.srcToken = usdcB(Chain B's USDC,params.borrowedAsset), notusdcA. findCrossChainCollateralmatches every field exceptsrcToken(usdcB != usdcA), returns(false, 0), andrequire(found)reverts.- The debt stays at
1000e18while the seized1000e18collateral is gone — the borrower loses collateral with no debt relief and the protocol carries the bad debt. The reproduction parks the seized1000e18atSINKand asserts it stuck; a positive control withsrcToken = usdcAsettles and clears the debt to0, provingsrcTokenis the sole cause.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xe3a787a4…:
- L189 — Finder queried with wrong srcToken: Root cause: findCrossChainCollateral gets payload.srcToken (Chain B's token) as srcToken, yet the stored record holds Chain A's token, so no position matches.
- L199 — Load the matched collateral record: Reads the borrower's collateral array to pull the matched record's srcEid — a line reached only after a position actually matched.
- L204 — Repay the located borrow: On a genuine match, forwards the borrower, amount and srcEid into the internal repay that clears the cross-chain debt.
- L214 — Enter internal repay routine: The repay routine receives the borrower, amount, destlToken and srcEid needed to settle the outstanding cross-chain borrow.
- L218 — Map destlToken to underlying: The destlToken parameter resolves to the destination-chain underlying so the matched record's principle can be reduced to zero.
- L237 — Seized-collateral loss sink: Setup: seized collateral is parked at this SINK; when the buggy settlement reverts it yields no debt relief, giving the measurable loss.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 58383-lend-cross-chain-liquidation-uses-the-wrong-srctoken_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: the buggy settlement reverts on require(found), the debt stays at 1000e18, and the seized 1000e18 collateral is stuck at SINK with no debt relief, while the positive control with the correct srcToken clears the debt to 0. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 58383-lend-cross-chain-liquidation-uses-the-wrong-srctoken_exp (evm-hack-registry mirror).
- AuditVault finding: 720.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "LEND: cross-chain liquidation uses the wrong
srcToken". - 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.