Reproduced Exploit

LEND H-11: cross-chain borrow ignores per-chain token decimals (~1e12× overborrow)

May 2025Otheruntagged3 min read

Chain

Other

Category

untagged

Date

May 2025

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: 58380-h-11-users-will-lose-funds-due-to-token-decimal-mismatches-a. Standalone Foundry PoC and full write-up: 58380-h-11-users-will-lose-funds-due-to-token-decimal-mismatches_exp in the evm-hack-registry mirror.


Vulnerability classes: cross-chain · token-decimal-normalization · decimal-mismatch · overborrow

Reproduction: a faithful minimal reproduction of CoreRouter.borrowForCrossChain (Sherlock 2025-05-lend-audit-contest, commit 713372a1). The vulnerable function is reproduced verbatim (marked @>); the destination lToken market and underlying token are faithful minimal doubles. Local deploy, no fork (this is an audit finding — there is no historical exploit tx).

Root cause#

LEND is a cross-chain money market. A borrow is validated on the source chain against the user's collateral, then the amount is relayed over LayerZero to the destination chain, where CoreRouter.borrowForCrossChain pays it out:

SOLIDITY
function borrowForCrossChain(address _borrower, uint256 _amount, address _destlToken, address _destUnderlying)
    external
{
    require(crossChainRouter != address(0), "CrossChainRouter not set");
    require(msg.sender == crossChainRouter, "Access Denied");
    require(LErc20Interface(_destlToken).borrow(_amount) == 0, "Borrow failed"); // @> raw source amount
    IERC20(_destUnderlying).transfer(_borrower, _amount);                        // @> unadjusted for dest decimals
}

_amount is expressed in the source token's decimals, but it is used to borrow and transfer the destination token with no re-scaling. The same logical asset can have different decimals per chain (USDC is 6 decimals on Ethereum, but a bridged/native variant can be 18 on another chain). When the source has more decimals than the destination, the destination transfer over-pays by 10^(srcDecimals − destDecimals).

Attack walkthrough#

flowchart TD A["Source chain: borrow validated for 1,000 tokens<br/>src token = 18 decimals → amount = 1_000e18"] --> B["LayerZero relays amount verbatim"] B --> C["Dest chain: borrowForCrossChain(_amount = 1_000e18)"] C --> D["dest token = 6 decimals; NO rescale"] D --> E["borrow(1_000e18) from the dest market"] E --> F["transfer(borrower, 1_000e18) raw units of a 6-dec token"] F --> G["borrower receives 1e15 tokens instead of 1,000<br/>→ 1e12x overborrow, market drained"]

Impact#

  • ~1e12× overborrow when the source token has 18 decimals and the destination has 6: a borrow validated as 1_000e18 delivers 1_000e18 raw units of a 6-dec token = 1,000,000,000,000,000 tokens where 1,000 were owed. The borrower drains the entire destination market against tiny source collateral.
  • The mirror case (source 6-dec, destination 18-dec) delivers 1_000e6 raw units of an 18-dec token ≈ 0.000000001 token — a tiny underborrow that strands the user's collateral for nothing.
  • No special setup; triggers on any cross-chain borrow where the asset's decimals differ between the two chains.

PoC#

Registry (Foundry, local deploy — exploit path + a decimal-normalizing control):

BASH
cd 58380-h-11-users-will-lose-funds-due-to-token-decimal-mismatches_exp
forge test -vv

Expected: test_attacker_overborrowsViaDecimalMismatch PASS (delivered 1_000e18 raw units, overborrow factor 1e12) and test_control_fixedNormalizesDecimals PASS (fixed router delivers exactly 1_000e6 = 1,000 USDC). The browser EVM Playground (opcode-level replay + marked source lines) is served at /hacks/58380-h-11-users-will-lose-funds-due-to-token-decimal-mismatches/.

Remediation#

Normalize the amount from the source token's decimals to the destination token's decimals before borrowing/transferring on the destination chain:

SOLIDITY
uint256 scaled = _amount;
if (srcDecimals > destDecimals) scaled = _amount / (10 ** (srcDecimals - destDecimals));
else if (destDecimals > srcDecimals) scaled = _amount * (10 ** (destDecimals - srcDecimals));
require(LErc20Interface(_destlToken).borrow(scaled) == 0, "Borrow failed");
IERC20(_destUnderlying).transfer(_borrower, scaled);

References#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

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.