
Date: September 18, 2026
Chain: BNB Chain
Loss: 74.31 BNB borrowed from LikwidVault and never repaid, listed at about $55.7K by SlowMist
Class: missing state update that froze an AMM's reserves on one borrow path
In the five minutes before the attack, according to Likwid's own post-incident statement, the attacker spent 55 BNB of their own money buying a thinly traded token on Likwid to push its price up. Then a single transaction borrowed against that token again and again, and every borrow came back priced at the same quote, about 4.7857 BNB, as if the pool hadn't moved at all.
When the transaction finished, 74.31 BNB had left LikwidVault as loans that will never be repaid. Likwid prices every borrow off its own pool reserves, and on the zero-leverage borrow path one field was never written, so the reserves the price came from never changed no matter how much was borrowed.
According to Likwid's documentation, Likwid runs spot trading, lending and leverage off a single constant-product curve, the same x times y equals k pricing that classic AMMs use, with no external price feed. Liquidity providers deposit into pools, and other users borrow from those pools for leveraged or short positions. The price of every trade and every borrow comes from the pool's own reserves.
That design has a built-in brake. Each borrow settles against the pool, which moves its reserves, which moves its price, so every additional borrow costs more than the one before. An attacker can pump a pool, but each unit they extract pushes the price back against them, the same way a large market order eats through an order book.
Likwid's margin contract, LikwidMarginPosition, also offered a collateral-borrow mode with leverage set to zero, where a user deposits one token of a pair and borrows the other. In Likwid's statement, the team said each pool keeps its own reserves, lending balance and insurance fund, and a pool can only pay out from its own reserves, which kept the damage inside the one affected pool.
When a margin action completes, LikwidMarginPosition builds a small record of changes, a delta struct with four fields: lendDelta, mirrorDelta, marginDelta and pairDelta, and passes it back to the AMM. According to the DeFiHackLabs reproduction, pairDelta is the only one of the four that flows back into pairReserves, the reserve numbers the AMM prices from.
In _executeAddCollateralAndBorrow, the leverage-zero branch set lendDelta, mirrorDelta and marginDelta, and never set pairDelta. An unset field in Solidity holds zero, which is a perfectly valid number, so nothing reverted. The leveraged path, _executeAddLeverage, did set pairDelta correctly. On the zero-leverage path, though, every borrow left pairReserves exactly where they were, and getAmountOut(pairReserves, ...) kept returning the same quote.
The idea for builders is that every path that moves value has to update every piece of state that prices the next move. A missing write is harder to see than a wrong one, because there's no line of code to look at. A vending machine that forgets to decrement its stock count after each sale would keep promising the same snack at the same price long after the row is empty.
SlowMist's alert describes the attacker repeating the margin and borrow cycle 14 times and settling 211.8 million tokens at the first trade's marginal price, 4.7857 BNB, with no price impact at all. The DeFiHackLabs reproduction breaks the same transaction into 24 addMargin calls with leverage zero, the first 15 of them each borrowing about 4.787 BNB. The later calls borrowed less because Likwid caps each borrow at 20% of the pool's shrinking real reserves, a cap that kept working even though the price never moved.
The attacker's contract already held about 218.58 million of the token before the exploit block, and addMargin only required owning a position NFT, which the caller could mint in the same call. Across the transaction, the attacker deposited 211,832,397.21 tokens and borrowed 74.31 BNB out of LikwidVault.
Likwid's own accounting separates what the attacker took from what the pool lost. The 74.31 BNB borrowed will never be repaid, but 55 BNB of it was the attacker's own money spent pumping the token, and across the whole operation the attacker put in 75.8 BNB and took out 77.99 BNB, a net profit of 2.19 BNB on this pool. Pool liquidity fell from 17.93 BNB to 5.67 BNB, with more than 9 BNB of that going to other token holders. The pool's insurance fund shows a deficit of 61.32 BNB, which the team describes as the bookkeeping trace of the attacker's own deposits being borrowed back out.
The team restricted collateral borrowing on-chain at 16:22 UTC that day, raising the minimum collateral ratio for that mode from 1.4x to 16.78x. It said the leverage-zero mode would be removed in a freshly deployed contract version, and that standalone borrowing and lending would be retired so Likwid Margin focuses on margin trading.
Olympix reads a contract the way an attacker does, as states, transitions and entry points, and it takes in the surrounding design, in this case an oracle-free AMM whose whole pricing model depends on reserves moving with every trade, to infer invariants the code depends on but never states. Here the invariant in one sentence is that any borrow that settles against a pool must move that pool's reserves, so the next borrow is priced off the new state.
The engine maps every path through addMargin and uses signals to decide which ones to attack. The signal is a missing state write on a reachable branch: a delta field that feeds the pricing path is assigned on the leveraged branch and left unassigned on the zero-leverage branch. Olympix's static analysis surfaces that pattern, and Olympix has flagged this class before in other codebases, both the unassigned-variable-in-a-branch shape and pricing driven by reserve state that an action fails to update.
BugPoCer takes that reserves-must-move rule as its target. Counterexample-Guided Invariant Refutation works from what the whole AMM promises outward to the call sequences that might break it, so rather than staring at the missing assignment it asks what a caller can do with it: it executes sequences of addMargin calls across both leverage settings and compares the quote each borrow receives with the one before. The leveraged branch passes, because its reserves move. Two zero-leverage borrows in a row fail, because the second is priced exactly like the first. That counterexample is the concrete call sequence, the minimal conditions that trigger it (leverage set to zero and a pool with borrowable reserves) and a generated Forge test that replays it, which is the September 18 transaction produced in development instead of on BNB Chain.
Because the engine runs during development, at every merge and before release, a branch added later for a new borrowing mode gets checked against the same invariant as the original leveraged path, which is often where a change meets code that was written and reviewed earlier.
Likwid's leveraged path did everything right, and the collateral-borrow mode sat beside it with one field left unwritten. That's the kind of difference a reviewer reading each function on its own can miss, and the kind an engine that checks every branch against the same pricing rule picks up immediately. If you're adding a mode or branch to a protocol that prices from its own state, book a technical walkthrough.
Protocol: Likwid Finance
Chain: BNB Chain
Date: September 18, 2026, 02:27:36 UTC
Loss: 74.31 BNB borrowed from LikwidVault (74.310377810 BNB per DeFiHackLabs); attacker net profit 2.19 BNB per Likwid
Attacker EOA: 0x90bd...f31e
Attack contract: 0xc63f...f4aa
Vulnerable contract (LikwidMarginPosition): 0x6bec...abe8
Victim contract (LikwidVault): 0x065d...e4ba
Token (currency1 of the pool): 0x0f12...B9d2
Exploit tx: 0x83cb...6f4d
Block: 122525331
SlowMist: Likwid exploit alert
Likwid team: post-incident statement (via KuCoin)
DeFiHackLabs: Likwid missing pairDelta update in leverage=0 margin borrow PoC