Reproduced Exploit
Likwid `leverage == 0` borrow — frozen pair reserves re-quote the same BNB on every collateral add
Likwid margin positions can be opened in two modes. leverage > 0 is a real leveraged swap: it writes delta.pairDelta, and the vault's marginBalance moves the pool. leverage == 0 is "post TOKEN collateral, borrow BNB." That path still asks getAmountOut for a quote against poolState.pairReserves, the…
Loss
74.310377807370642618 BNB taken from LikwidVault in this transaction (reported ~74.31 BNB). Attacker BNB befo…
Chain
BNB Chain
Category
logic
Date
Sep 2026
Source
DeFiHackLabs
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. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-09-Likwid_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Likwid_exp.sol.
Vulnerability classes: vuln/logic/missing-state-update · vuln/defi/price-manipulation · vuln/logic/incorrect-accounting Reproduction: the PoC compiles and runs offline in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified
LikwidMarginPositionsource is in sources/LikwidMarginPosition_6bec0c.
Key info#
| Loss | 74.310377807370642618 BNB taken from LikwidVault in this transaction (reported ~74.31 BNB). Attacker BNB before the call is 0 [output.txt:369] |
| Vulnerable contract | LikwidMarginPosition — 0x6bec0c1dc4898484b7f094566ddf8bc82ed7abe8 |
| Victim (BNB custody) | LikwidVault — 0x065d449ec9D139740343990B7E1CF05fA830e4Ba |
| Attacker EOA | 0x90bde1e0Bb16B3DEEb9d638aCf8D01F19fD2F31e |
| Attack contract | 0xC63FB27F52ed8d06673c60c3075B2D3bD26Cf4AA (pre-held 218,584,952.803871107870716658 TOKEN) |
| Attack tx | 0x83cbd07d59aedc2f114c351c568d3386ff5bc7caa87d12bc6494e2dc7bf16f4d |
| Chain / block / date | BSC (chain id 56) / exploit block 122,525,331, fork parent 122,525,330 / 18 September 2026 |
| Compiler | Solidity v0.8.28+commit.7893614a, optimizer on, 200 runs (verified source) |
| Bug class | _executeAddCollateralAndBorrow (the leverage == 0 path) sizes the borrow with SwapMath.getAmountOut(pairReserves, ...) and never writes delta.pairDelta, so the AMM reserves that feed the next quote do not move. Splitting one collateral pile into many positions repeats the first-trade marginal price. |
TL;DR#
Likwid margin positions can be opened in two modes. leverage > 0 is a real leveraged swap: it writes delta.pairDelta, and the vault's marginBalance moves the pool. leverage == 0 is "post TOKEN collateral, borrow BNB." That path still asks getAmountOut for a quote against poolState.pairReserves, then fills lendDelta, mirrorDelta, and marginDelta and leaves pairDelta at its zero default. The quote source never sees the trade.
addMargin is permissionless. It mints a fresh position NFT to the caller and _requireAuth only checks that the caller owns that NFT, so there is no admin or signer. The attacker repeats addMargin 24 times with marginForOne = true, leverage = 0, and borrowAmount = type(uint256).max ("borrow the max the quote allows"). The first 15 calls each pull the identical 4.787036565697387419 BNB out of the vault [output.txt:457] against 13,661,559.550241944241919791 TOKEN of collateral. Later calls shrink only because borrowMaxAmount is also capped at 20% of the shrinking real BNB reserve, not because the marginal price moved. The last call borrows just 0.000019556097259383 BNB [output.txt:2136].
Net of this transaction, from a starting attacker balance of 0: 74.310377807370642618 BNB lands on the test contract [output.txt:370], paid for with pre-held TOKEN and no BNB. [PASS] testExploit() gas 6,230,423 [output.txt:367].
Likwid's own ledger of the wider incident (capital the attacker had already put into the pool, plus a later token sale) books a smaller net for the attacker (+2.19 BNB) and a smaller drop in what they call real pool liquidity. That does not change what this call does: the vault pays the frozen quote, 24 times, and 74.31 BNB leaves.
Background — what Likwid margin does#
LikwidMarginPosition is the position manager for a custom BNB/TOKEN pool held by LikwidVault. The pool key in the attack is currency0 = address(0) (native BNB), currency1 = TOKEN (0x0f12d5048a6bed7ECc572fa7805D03Af7B5FB9d2, an ERC-1967 proxy), fee = 3000, marginFee = 2500.
A user calls addMargin. The contract mints an ERC-721 to params.recipient, stores marginForOne, and enters _margin:
- If
leverage > 0,_executeAddLeveragetreats the position as a swap through the pool and setsdelta.pairDeltaso the vault updates pair reserves. - If
leverage == 0,_executeAddCollateralAndBorrowtreats it as a collateralised borrow. The borrow ceiling is a constant-productgetAmountOuton the current pair reserves, then haircut byminBorrowLevel, then capped at 20% ofrealReservesof the borrowed currency.
Settlement is vault.unlock → unlockCallback → vault.marginBalance(key, delta). The vault takes the borrowed BNB to the position owner and transferFroms the TOKEN collateral in. Whatever pairDelta is on that struct is what the vault is given to apply to the AMM. A zero pairDelta means the pricing reserves stay put.
TOKEN here is only the collateral the attacker already held. The pricing bug is entirely in the position manager.
The vulnerable code#
Verified source: sources/LikwidMarginPosition_6bec0c/src_LikwidMarginPosition.sol.
The leverage path does update the pair#
if (position.marginForOne) {
delta.marginDelta = toBalanceDelta(0, amount);
delta.pairDelta = toBalanceDelta(-borrowAmount.toInt128(), marginWithoutFee.toInt128());
delta.lendDelta = toBalanceDelta(0, lendAmount);
delta.mirrorDelta = toBalanceDelta(-borrowAmount.toInt128(), 0);
} else {
delta.marginDelta = toBalanceDelta(amount, 0);
delta.pairDelta = toBalanceDelta(marginWithoutFee.toInt128(), -borrowAmount.toInt128());
// ...
}
(_executeAddLeverage, lines 221–228.)
The leverage == 0 path never assigns pairDelta#
(uint256 borrowMaxAmount,) = SwapMath.getAmountOut(
poolState.pairReserves, poolState.lpFee, !position.marginForOne, params.marginAmount
);
// ... haircut by minBorrowLevel, then:
borrowMaxAmount = Math.min(borrowMaxAmount, borrowRealReserves * 20 / 100);
if (params.borrowAmount == type(uint256).max) params.borrowAmount = borrowMaxAmount.toUint128();
if (position.marginForOne) {
amount1Delta = amount; // TOKEN collateral in
amount0Delta = borrowAmount.toInt128(); // BNB debt out
delta.lendDelta = toBalanceDelta(0, amount);
delta.mirrorDelta = toBalanceDelta(-borrowAmount.toInt128(), 0);
} else {
// symmetric, still no pairDelta write
}
delta.marginDelta = toBalanceDelta(amount0Delta, amount1Delta);
(_executeAddCollateralAndBorrow, lines 241–289.) MarginBalanceDelta.pairDelta is a struct field (src_types_MarginBalanceDelta.sol line 15) and stays the zero BalanceDelta. _handleMargin forwards that struct unchanged:
(BalanceDelta delta) = vault.marginBalance(key, params);
(line 635.)
Auth does not gate the function#
function addMargin(PoolKey memory key, IMarginPositionManager.CreateParams calldata params)
external
payable
ensure(params.deadline)
returns (uint256 tokenId, uint256 borrowAmount, uint256 swapFeeAmount)
{
tokenId = _mintPosition(key, params.recipient);
// ...
}
_margin then calls _requireAuth(tokenOwner, params.tokenId), and _requireAuth only checks spender == ownerOf(tokenId) (src_base_BasePositionManager.sol lines 55–58). The NFT was minted to params.recipient in the same call, so any EOA that sets recipient to itself passes.
Root cause — why it was possible#
- The pricing input and the state write are different fields.
getAmountOutreadspairReserves. OnlypairDeltais the delta the vault applies back onto those reserves. The borrow branch fills every other delta and skipspairDelta. borrowAmount = type(uint256).maxmeans "give meborrowMaxAmount." There is no external price or slippage parameter. The contract substitutes the static quote, so the caller does not even have to know the number.- The 20% real-reserve cap is not a price-impact cap. It limits one position to a fifth of cash reserves. It does not move
pairReserves, so the next position of the same collateral size is quoted at the same marginal price. Fifteen identical quotes in the trace are the bug, not a coincidence. - Positions are independent. Each
addMarginmints a new NFT. Collateral is not aggregated into one position that would have to clear a single, impact-adjusted quote. - No privilege is required.
addMarginisexternal payable. The only "auth" is ownership of an NFT the function just minted.
Preconditions#
- A pool whose
pairReservesquote, for a chunk of TOKEN the attacker can post, is a large fraction of vault BNB. Here one 13.66M TOKEN chunk quotes 4.787 BNB, and the vault can pay that 15 times before the 20% cash cap bites. - The attacker must already hold the TOKEN. The attack contract's balance at the fork block is 218,584,952.803871107870716658 TOKEN [output.txt:389], present since at least block 122,525,300. The PoC does not
dealor mint it; ittransfers that real balance into the exploit contract test/Likwid_exp.sol. - No pause, per-block position cap, or "reserves must change" invariant on the
leverage == 0path.
Attack walkthrough (with numbers from the trace)#
| # | Step | Effect (from output.txt) |
|---|---|---|
| 1 | Fork parent block 122,525,330. Read TOKEN on the real attack contract and transfer it to a fresh exploit contract | Transfer of 218,584,952.803871107870716658 TOKEN [output.txt:395]. Attacker BNB before: 0 [output.txt:369] |
| 2 | addMargin #1, leverage = 0, marginAmount = 13,661,559.550241944241919791 TOKEN, borrowAmount = type(uint256).max | Vault takes 4.787036565697387419 BNB to the exploit contract [output.txt:457]. Return (tokenId 1045, borrow 4787036565697387419, fee 0) [output.txt:505] |
| 3 | addMargin #2 through #15, same margin amount | Each take is the same 4.787036565697387419 BNB [output.txt:530], [output.txt:603], and the same pattern through token id 1059. Pair reserves are not walking the curve |
| 4 | Calls 16–24, tapering margins (6.83M TOKEN, then 53,365 TOKEN, then halves down to 52.114713860481049506 TOKEN) | Borrow shrinks only with the 20%-of-realReserves cap. Last take is 19,556,097,259,383 wei (0.000019556097259383 BNB) [output.txt:2136]; return [output.txt:2184] |
| 5 | Sweep BNB to the test contract | LikwidExp::receive{value: 74310377807370642618} [output.txt:2185] |
Profit / loss (this transaction):
- Attacker BNB before: 0 [output.txt:369]. After: 74.310377807370642618 BNB [output.txt:371].
- Assertion
assertApproxEqAbs(gained, 74.310377810e18, 0.01e18)passes [output.txt:2189]. The 2 wei-scale gap versus the 74.310377810 figure in the incident write-up is inside that tolerance. - TOKEN posted across the 24 calls is the schedule in test/Likwid_exp.sol (
_margins()), summing to 211,832,397.21 TOKEN. That TOKEN was already the attacker's; this tx spends no BNB to obtain it.
Diagrams#
Remediation#
- Write
pairDeltaon the borrow path, or stop pricing borrows offpairReserves. If aleverage == 0borrow is meant to be a swap, setdelta.pairDeltathe way_executeAddLeveragedoes (lines 223 and 228) so the nextgetAmountOutsees the moved reserves. If it is meant to be a pure money-market borrow, do not callgetAmountOuton AMM reserves at all; price collateral with an oracle and a utilisation curve. - Aggregate collateral against one reserve snapshot per transaction. A caller must not be able to split one balance into N positions that each clear the pre-trade marginal price. Quote the sum, or update reserves before the next position in the same block.
- Keep the 20% cash cap, but do not treat it as the price-impact check. It only slows the drain once real BNB is already leaving. A per-block borrow cap on
pairReservesimpact (or a minimum reserve move) would have made call #2 quote a different number than call #1. - Do not let
borrowAmount = type(uint256).maxsilently become the internal quote. Require the caller to pass a finiteborrowAmountand aborrowAmountMaxthey actually computed, and revert if the quote moved.
How to reproduce#
The PoC runs fully offline from the committed anvil_state.json. _shared/run_poc.sh starts anvil on a free port, rewrites the test's http://127.0.0.1:8546 fork URL, and uses BSC chain id 56:
_shared/run_poc.sh 2026-09-Likwid_exp -vvvvv
Fork block is 122,525,330. Expected tail:
[PASS] testExploit() (gas: 6230423)
Attacker Before exploit BNB Balance: 0.000000000000000000
BNB drained from LikwidVault: 74.310377807370642618
Attacker After exploit BNB Balance: 74.310377807370642618
Suite result: ok. 1 passed; 0 failed; 0 skipped
The full call trace is output.txt.
Reference: Likwid postmortem.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-09-Likwid_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Likwid_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Likwid
leverage == 0borrow". - 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.