Reproduced Exploit
LEND: cross-chain repayment wipes the borrower's same-chain debt
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: 782. Standalone Foundry PoC and full write-up: 58385-lend-cross-chain-repayment-updates-same-chain-borrow-balances_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/theft · vuln/logic
Reproduction: a faithful minimal reproduction of the vulnerable finding —
repayBorrowInternaland theLendStorageaccounting helpers it touches are reproduced verbatim (the vulnerable line marked@>) with faithful minimal doubles; local deploy, no fork.
Root cause#
In Lend-V2/src/LayerZero/CoreRouter.sol, repayBorrowInternal runs its // Update same-chain borrow balances block unconditionally — there is no if (_isSameChain) guard. When a cross-chain debt is repaid (_isSameChain == false), borrowedAmount is the cross-chain balance, yet the block still calls removeBorrowBalance(borrower, _lToken), deleting the borrower's unrelated same-chain borrowBalance record for the same lToken. The vulnerable lines, reproduced verbatim:
// Update same-chain borrow balances
if (repayAmountFinal == borrowedAmount) {
@> lendStorage.removeBorrowBalance(borrower, _lToken);
lendStorage.removeUserBorrowedAsset(borrower, _lToken);
} else {
lendStorage.updateBorrowBalance(
borrower, _lToken,
borrowedAmount - repayAmountFinal,
LTokenInterface(_lToken).borrowIndex()
);
}
The same-chain loan and the cross-chain loan live in separate storage slots, but this block only ever addresses the same-chain borrowBalance mapping. So paying off the cross-chain leg silently erases the same-chain leg, and the same-chain lenders who funded it lose their money.
Why it's exploitable here#
Following the finding's vulnerability path with concrete numbers:
- Alice owes a
100e18same-chain loan (recorded inborrowBalance[Alice][lToken]) and a separate30e18cross-chain loan of the same lToken (recorded in the cross-chain records). The two debts occupy independent storage slots. - Alice (or a liquidator) calls
repayBorrow(..., _isSameChain = false)to repay exactly the30e18cross-chain debt. Because_isSameChainis false,borrowedAmount = borrowWithInterest() = 30e18. LErc20Interface(_lToken).repayBorrow(30e18)correctly reduces the on-chain borrows for the cross-chain loan — that part is fine.- Then, because
repayAmountFinal (30e18) == borrowedAmount (30e18), the unconditional block callsremoveBorrowBalance(Alice, lToken), deleting the untouched100e18same-chain record. - Alice paid
30e18and had her100e18same-chain debt erased for free — a direct100e18loss to the same-chain lenders.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xbd4fd5a3…:
- L49 — Router approves the market: The router's safeApprove grants the lToken market an allowance so it can pull the repay underlying from the caller.
- L54 — Market accrues interest first: repayBorrowInternal first calls accrueInterest on the lToken so the debt is measured against the current borrow index.
- L58 — Cross-chain debt repaid on-chain: The router calls repayBorrow on the market, transferring 30e18 and correctly reducing the on-chain borrows for the cross-chain loan.
- L253 — Reset stale allowance: Inside _approveToken, any existing nonzero allowance is first reset to zero before the router re-approves the repay amount.
- L280 — Cross-chain amount validated: With _isSameChain false, borrowedAmount is the 30e18 cross-chain debt from borrowWithInterest, so this nonzero check passes.
- L296 — Same-chain debt wiped unconditionally: Root cause: with no if(_isSameChain) guard, this cross-chain repay calls removeBorrowBalance and deletes Alice's untouched 100e18 same-chain debt for free.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 58385-lend-cross-chain-repayment-updates-same-chain-borrow-balances_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: repay only the 30e18 cross-chain debt and watch Alice's untouched 100e18 same-chain debt get wiped, so the same-chain lenders lose 100e18. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 58385-lend-cross-chain-repayment-updates-same-chain-borrow-balances_exp (evm-hack-registry mirror).
- AuditVault finding: 782.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "LEND: cross-chain repayment wipes the borrower's same-chain debt".
- 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.