Skip to content
Dark abstract extreme close-up of a rack of engraved teal metal cache plates each stamped with a circular approval seal, the foreground plate carrying two mismatched ridge patterns joined by a thin glowing red seam directly under its seal, a gloved hand at the lower right lifting the seal press
exploitsSeptember 9, 20264 min read

Liquid Network's $320M Peg-Out: The Cache Key That Skipped the Range Proof

Dmitry Serdyuk
Dmitry SerdyukCo-Founder & CDO

Updated on September 9, 2026

TL;DR

On September 6, 2026, roughly 4,000 BTC, about $320 million at the time and around 95% of the reserve backing every L-BTC in circulation, left the Liquid Federation wallet through an ordinary peg-out, according to The Block. No key was compromised. The L-BTC that paid for the withdrawal was minted from nothing: a bug in Elements, the node software behind Liquid, let an invalid confidential output ride through on a cached "already verified" result for a different, valid proof. The self-described white hats returned 3,400 BTC on September 7 after Blockstream signed an on-chain message saying its bridge nodes were patched, and kept 598.5 BTC. As of September 9 the reserve backs about 86% of circulating L-BTC, the sidechain is still paused, and there is no technical postmortem. The change that created the hole was merged to the public master branch on September 1, has never appeared in a tagged release, and was cherry-picked into the release branch three and a half hours after the exploit. A fix replaced it there on September 8. The federation's own nodes appear to have been the only ones running the bug on the day it was used.


What Backs an L-BTC, and Who Decides a Peg-Out Is Valid?

Liquid is a federated Bitcoin sidechain. Lock BTC to a federation address on Bitcoin and the same amount appears as L-BTC on Liquid. To leave, burn L-BTC with a peg-out authorization and the federation's functionary nodes pay real BTC out of the reserve. Public peg-outs are gated by a Peg-out Authorization Key, or PAK, held by approved services. SideSwap holds one.

The functionaries do not check whether the L-BTC you burn was ever pegged in. They check that the Liquid chain considers it valid. So the whole reserve is guarded by sidechain consensus, and consensus on Liquid includes confidential transactions.

A confidential transaction hides amounts behind commitments, so every output carries a range proof: a cryptographic proof that the hidden amount is a non-negative number in a sane range. Without it, one output worth minus 4,000 coins and another worth plus 4,000 sum to zero and pass the balance check. The range proof is the only thing between confidential amounts and hidden inflation.

Verifying range proofs is expensive, so Elements caches results. A node hashes the proof and its context into a key, and if that key has passed before, the node skips the check. That cache is where the money left.


How Did the Liquid Network Exploit Work?

The attacker made the node believe it had already verified a proof it had never seen.

mononaut reconstructed the mechanism from the Liquid chain and the Elements source, and the on-chain shape supports his reading.

Until this month the range proof cache key was a hash of the proof and the value commitment. On September 1 a change landed on master extending it to include the asset commitment and the output's scriptPubKey. We read the diff in commit c26d719: the new key is the SHA-256 of proof, value commitment, asset commitment, and scriptPubKey, written back to back into one hasher. No length prefixes. No separators.

That is the entire bug. The proof and the scriptPubKey are both variable length, so the boundary between fields is wherever the attacker says it is. Take a valid proof for a valid amount. Append the amount and asset commitment bytes to the end of the proof, so they sit inside the "proof" field, and shrink the scriptPubKey by the same number of bytes. The hasher cannot tell the difference. Two different outputs, one legitimate and one fraudulent, produce the same key.

Two moves follow. Prime the cache: broadcast an output with a genuine range proof and a carefully shaped scriptPubKey, so the node verifies it and remembers the key. Then broadcast the real thing: an output whose range proof is garbage but whose bytes hash to the primed key. The node finds the key, skips verification, and accepts a hidden negative amount. A second output in the same transaction credits the attacker with the matching positive amount. The transaction balances. The chain now holds L-BTC nobody pegged in.

The two primers, 2711471… and 71c93d4…, confirmed in Liquid block 4,050,335, which the explorer's record timestamps at 13:52:10 UTC. Each carries an OP_RETURN output with a 69-byte scriptPubKey, an explicit L-BTC asset tag, and a blinded value. The exploit transaction, f24a4b1…, confirmed one block later, at 13:53:10 UTC. Its OP_RETURN output has a scriptPubKey exactly one byte long, the bare opcode, with a blinded value and an explicit L-BTC tag. All three spend outputs of the same parent transaction. A 69-byte script shrunk to 1 byte, with a proof stretched by the difference, is the collision described above, drawn on chain.

What happened next was mundane, which is the point. Per SideSwap's statement, at 14:05 UTC a customer sent 4,000 L-BTC to its peg-out service. The service processed the order like any other: the L-BTC was burned with a valid authorization, and at 14:28 UTC the Liquid Federation paid 3,996 BTC to the customer's Bitcoin address. SideSwap's line is exact: "Our service had no way to tell those coins from any other L-BTC."

There was a rehearsal. The attacker's Bitcoin address, bc1ql4mfu6…, received 2.49749857 BTC in block 965,780 at 14:01 UTC, eight minutes after the exploit block and four minutes before the 4,000 L-BTC order. It is the first transaction the address ever received. The Defiant noted the same 2.5 BTC arriving before the order SideSwap describes. Test with 2.5, confirm the payout lands, then send the other 95% of the reserve.

Charles Guillemet, Ledger's CTO, endorsed the same reading, a crafted cache-key collision that let an invalid confidential transaction bypass a range check, per CryptoSlate. Blockstream has not published a postmortem. The nearest thing to an official technical account is the fix, PR #1600, merged September 8, whose description says the hasher was changed so that "distinct argument tuples with byte-identical raw concatenations no longer collide to the same cache key."


The Attack, Step by Step

#StepEvidence
1A cache-key change to Elements' range proof verification is merged to master September 1 in PR #1592GitHub commit c26d719 and PR #1592
2Two primer transactions with genuine range proofs and 69-byte OP_RETURN scripts confirm in block 4,050,335 at 13:52 UTC, Sept 6Liquid explorer, both transactions
3The exploit transaction confirms in block 4,050,336 at 13:53 UTC with a 1-byte OP_RETURN script, an invalid proof on a hidden negative amount, and a positive output to the attackerLiquid explorer; mononaut's analysis
4Nodes on official releases reject block 4,050,336 and stall at 4,050,335; nodes on the master build accept itmononaut; liquid.network pegs endpoint still reports last block 4,050,335
5A test peg-out pays 2.4975 BTC to the attacker's address at 14:01 UTCBitcoin block 965,780
64,000 L-BTC are sent to SideSwap's peg-out service at 14:05 UTC and burned with a valid authorizationSideSwap statement
7The federation pays 3,996 BTC at 14:28 UTC; the SideSwap payout address forwards 3,995.99999857 BTC to the attacker in the same blockBitcoin block 965,783
8The attacker writes "we are whitehats. contact us on chain" in an OP_RETURN to the federation address at 18:30 UTC; Liquid disables bridge nodes and pauses the sidechainBitcoin block 965,818; Liquid Network statement
93,400 BTC returns to the federation address in block 965,950 at 16:09 UTC, Sept 7; 598.49955894 BTC stays with the attackerBitcoin block 965,950
10The buggy commit is cherry-picked into the 23.3.x release branch at 17:21 UTC on Sept 6; the fix replaces it there at 19:06 UTC on Sept 8, version bumped to 23.3.4, no tagGitHub PR #1599 and PR #1600

The path forks at step 4, and the fork is the story.

rendering diagram…

The Root Cause: A Fix That Manufactured the Hole

The vulnerability class is a verification bypass through an ambiguous cache key, and its ancestor was benign.

mononaut's timeline: range proof caching was added in 2016, asset support in 2017, and in 2019 the key was "simplified" to drop the asset and script fields. That created a limited cache-poisoning weakness he calls Bug A, hard to monetize because the amount had to match the primer and the proof had to be genuine. The September 1 change was the fix for Bug A. By concatenating four values without lengths it created Bug B, which is easy to exploit and is what drained the reserve.

Two things about that change are verifiable from the repository.

It was merged inside PR #1592, titled "blind/blindpsbt fixes," one of ten commits from the same author. A rewrite of a consensus-critical cache key shipped alongside PSBT blinding error handling and dynafed header changes. A reviewer reading a PR about blinding fixes is not primed to ask whether a hasher call has domain separation.

And it is in no release. The latest tagged Elements release is 23.3.3, published April 13, 2026, and the GitHub compare endpoint confirms the commit is not an ancestor of that tag. mononaut's reading, relayed by CryptoSlate, is that most of the network ran official releases, none of which contain Bug B, and those nodes rejected the exploit block and stalled at height 4,050,335. The nodes that accepted it were running master. The functionaries, whose signatures release BTC from the reserve, were on a build five days off an unreleased branch (the least conservative software on the network, on the most privileged machines).

The release branch did not stay clean either. At 17:21 UTC on September 6, three and a half hours after the exploit block, the same commit was cherry-picked into the elements-23.3.x branch as one of twelve in PR #1599, whose entire description is "All clean cherry picks from 23.x branch, in preparation for 23.3.4rc2." Whether the maintainers had identified the mechanism by that hour is not public. What the repository shows is that the branch the next release would be cut from carried the bug for two more days.

The fix landed on that branch at 19:06 UTC on September 8, in PR #1600, written by the same developer as the September 1 change. It replaces the raw hasher with a length-prefixed one, salts the cache with a per-process midstate under separate domain tags for range proofs and surjection proofs, adds tests for field-boundary collisions, adds a -norangeproofcache switch for operators who would rather pay for verification than trust the cache, and bumps the version to 23.3.4. As of September 9 there is no 23.3.4 tag, and master, where the bug was born, is unchanged (the branch most people build from is still the one with the collision in it).

How many of them, exactly, is the accountability question. The federation wallet is an 11-of-15 multisig, per Blockstream's help center, so at least eleven functionaries signed the 14:28 payout from a chain that had accepted the exploit block. Whether all fifteen were on the master build, and who decided to deploy it, has not been published.

The attacker knew this. Their 03:30 UTC message to Blockstream, in an OP_RETURN anyone can read, says: "Please fix the bug first. The chain is under risk at latest commit right now. Make sure every node is patched."

The precedent is CVE-2018-17144, the Bitcoin Core inflation bug: an optimization in 0.14 removed a validation check, a partial restoration in 0.15 had insufficient error handling, and a miner could have double-spent an output from a prior block. Same shape, a fix to the fix on the validation path. Bitcoin Core caught its version before anyone used it. Elements did not.

The nearest taxonomy label is CWE-347, Improper Verification of Cryptographic Signature. The more useful rule is older and less formal: never hash a concatenation of variable-length fields without length prefixes.


Do the Numbers Reconcile?

Mostly, and the gaps are explainable.

4,000 versus 3,996. SideSwap says 4,000 L-BTC in, 3,996 BTC out. The federation's payout in block 965,783 sends 3,996.018 BTC to a SideSwap payout address, which forwards 3,995.99999857 BTC to the attacker in the same block. We read the roughly 4 BTC gap as the peg-out service's fee. SideSwap has not itemized it.

$320 million. The Block's figure at the time, 4,000 BTC at a price just under $80,000. Treat it as a snapshot.

4,200 versus 197. The Block puts the pre-incident reserve at approximately 4,200 BTC. CryptoTicker's backing check, taken from the liquid.network API on September 7 at 12:39 UTC, found 197.47 BTC in reserve against 4,205.02 L-BTC in circulation, a 4.70% backing ratio. That circulating figure is frozen, and the reason is instructive: the pegs endpoint reports its last Liquid block as 4,050,335, one block before the exploit, which is what you would expect from a tracker whose node runs a release build and rejected block 4,050,336. It saw neither the mint nor the burn. It is still the right number for honest supply, because the 4,000 fraudulent L-BTC were destroyed in the peg-out. What changed is the reserve.

3,400 returned, 598.5 kept. The return transaction, a6d697a…, confirmed in block 965,950, which mempool.space timestamps at 16:09 UTC on September 7: 3,400 BTC to the federation address, 598.49955894 BTC back to the attacker as change. The Block values the retained amount at $47.3 million. The Defiant makes the point that matters: nothing either side published on chain sets a figure for what the attacker keeps, or calls it a bounty. "White hat" is the attacker's own label. Keeping $47 million that was never agreed is not what the phrase usually means. Guillemet's word for it, per Unchained, was closer to extortion than white-hat hacking.

Where it stands. On September 9 the liquid.network reserves endpoint reports 3,597.47 BTC in the federation wallet against the same 4,205.02 L-BTC in circulation. That is about 85.6% backed, a shortfall of about 607.5 BTC. Who absorbs the gap has not been announced. Blockstream's line, per Unchained, is that updated software has been deployed and federation members are preparing a coordinated restart; JAN3's Samson Mow said a chain split had to be resolved first and told users not to peg in until the restart is confirmed. TRM Labs, which calls this the largest theft of 2026 so far, notes that exchanges have not resumed L-BTC trading.


What Did the On-Chain Negotiation Look Like?

Every message between the two sides is an OP_RETURN on Bitcoin, and we pulled them all. Block heights and times below are from each transaction's explorer record.

The attacker opened at 18:30 UTC on September 6, in block 965,818: "we are whitehats. contact us on chain." Blockstream answered an hour later from a fresh address, in block 965,822, with a dust output and "Please contact security@blockstream.com." Galaxy Research posted that transaction the same evening.

Blockstream's next move was clever. At 01:49 UTC on September 7 it sent a message encrypted to the public key behind the attacker's own address, using Electrum's ECIES scheme. Spending from a segwit address reveals the public key, and that made a channel only the attacker could open. At 02:20 the attacker asked, in plaintext, "sending most back to bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr, is that ok". At 03:30 Blockstream replied "Yes, thank you." as a PGP-signed message, and the attacker posted the "fix the bug first" demand in the same block, with detail encrypted to the key at blockstream.com/pgp.txt. At 11:46 Blockstream signed "Bridge nodes are patched, safe to return the funds."

We imported that key and verified the signatures ourselves. All three of Blockstream's signed messages, at 03:30 and 11:46 on September 7 and at 00:11 on September 8, verify as good signatures from key 1176 542D A98E 71E1 3372 2EF7 4AC8 CC88 6844 A2D6, Blockstream Security Reporting. The channel is real.

The return landed at 16:09. Then the exchange kept going, and this part has not been reported. At 17:54 Blockstream sent four signed, encrypted messages from four fresh addresses, and one of them opens in plaintext with instructions: this is Electrum BIE1 ECIES to your key, Tools > Encrypt/Decrypt Message, decrypt with the private key for the attacker's address, "or any ECIES/BIE1 library." You do not write a how-to for a counterparty who has been reading your ciphertext. At 18:35 the attacker sent two encrypted messages back, spending from the 598.5 BTC, and Blockstream sent one more, then re-sent the identical ciphertext at 19:47. At 21:03 UTC the attacker sent one OP_RETURN to the federation address. The entire plaintext is ":(".

Blockstream kept signing: 00:11, 06:02 and 12:59 UTC on September 8, each a good signature from the same key when we checked. The attacker answered with encrypted messages at 09:41 and 14:25, and in the same block as the second one added a plaintext line: "All messages will be in plaintext." Whether that is a demand or a promise is not clear from outside. Nothing has followed it as of the morning of September 9 UTC, and the 598.5 BTC has not left the address. Nobody outside those two keys knows what was asked or refused.

The channel was public, and it filled up. An hour before the return, an unsigned message to the attacker asked them not to send the coins back to the federation address but to a "clean route address" instead, and added that they "may keep 98 bitcoins." Dozens of dust transactions carried a memecoin contract address. One offered laundering over Telegram. Strangers made the case for signed messages better than any policy could: the only thing separating Blockstream's instructions from an impersonator's was a signature anyone could check.


Why This Generalizes Past Liquid

A cache that decides which proofs not to check is part of the verifier. It has the same security requirements as the verification code and usually gets none of the same review. Key derivation lives in a helper with a boring name, changes to it look like plumbing, and they get merged in PRs about RPC error strings. Every system that caches signature, proof, or state-root checks has this function somewhere.

The second lesson is about where unreleased code runs. For five days the Elements master branch was more dangerous than any release, and the only nodes running it were the ones that could sign BTC out of the reserve.

The third lesson is the one we keep writing about. The federation paid 95% of its reserve on a single order, 23 minutes after the burn and 35 minutes after the exploit block, and no rule stopped it. Compare Verus Bridge, where two node implementations disagreed about a state root and one of them released the funds, or Core DAO, where a supply cap held while the issuance path was replayed underneath it. Each time, the invariant everyone trusted was checked in the wrong place, or only once.

An audit certifies the code you showed it, on the day you showed it. Elements has been reviewed for years, and the code that leaked $320 million was five days old.


What Should Operators Do About This?

  1. Review cache keys as consensus code. Any function that turns a proof or signature plus context into a lookup key is a verifier. Require length-prefixed or tagged serialization, add a test where two distinct inputs must produce distinct keys, and refuse to merge changes to it inside unrelated PRs.
  2. Pin privileged nodes to tagged releases. Anything that signs value out of a reserve should run the most conservative software in the network. If master must be deployed to it, deploy to a canary first, and give the canary nothing to sign.
  3. Run an independent release-version node and alarm on divergence. Nodes on Elements 23.3.3 rejected block 4,050,336 at 13:53 UTC and stopped. An alert that fires when a release-build node's tip falls behind the federation's tip would have fired twelve minutes before the 4,000 L-BTC order and 35 minutes before the payout.
  4. Cap single peg-outs against the reserve, and hold the outliers. One order for 95% of the backing sat for 23 minutes between burn and payout. A rule that holds any peg-out above a few percent of reserve for a second signer, or a human, converts that window into a saved reserve. This is where runtime monitoring, the kind Tripwire does, is the honest fit. It could not have stopped the 13:53 block. The federation's 14:28 payout was a separate signed action, and it could have been held.
  5. Count what your circulating-supply endpoint cannot see. A tracker following the federation's chain and summing explicit pegs would have recorded the 4,000 L-BTC burn and shown roughly 205 L-BTC outstanding against 197 BTC in reserve after the payout, close to fully backed, while the true ratio was 4.70%. A sum of pegs cannot see a mint inside a blinded output. If amounts are hidden, the supply invariant has to be proven, and the proof has to be checked every time.
  6. Say which branch has the fix, then tag it. The fix reached the 23.3.x branch on September 8 at 19:06 UTC, two days after the same bug was cherry-picked into it. As of September 9 there is no 23.3.4 tag and master still carries the September 1 key. An operator building Elements today has to know which of two branches to trust, and nothing on the project's releases page tells them.

Frequently Asked Questions

How much was stolen in the Liquid Network exploit?

About 4,000 BTC, worth roughly $320 million on September 6, 2026, left the Liquid Federation wallet through one peg-out. 3,400 BTC was returned on September 7, and 598.49955894 BTC remains with the attacker, about $47.3 million.

Were Liquid's federation keys or SideSwap's PAK compromised?

No, according to both Liquid and SideSwap. The withdrawal used SideSwap's valid peg-out authorization on L-BTC that had been minted through a bug in Elements. The keys signed exactly what they were designed to sign.

What was the actual vulnerability?

A range proof verification cache in Elements computed its key by hashing the proof, value commitment, asset commitment, and scriptPubKey back to back with no length fields. Because the proof and script are variable length, an invalid output could be shaped to produce the same key as a previously verified valid one, and the node skipped the check. The change was merged September 1, 2026 and is in no tagged release.

Has the bug been fixed?

On the release branch, yes. PR #1600 replaced the cache key with length-prefixed hashing on the elements-23.3.x branch on September 8 and bumped the version to 23.3.4, and Blockstream signed an on-chain message on September 7 saying its bridge nodes were patched. As of September 9 there is no tagged 23.3.4 release and the master branch still carries the September 1 key.

Is L-BTC fully backed now?

No. On September 9 the reserve held about 3,597 BTC against about 4,205 L-BTC in circulation, roughly 85.6% backed. The sidechain remains paused, and no plan for the roughly 607 BTC shortfall has been published.


Sources / References

Dmitry Serdyuk
Dmitry Serdyuk

Co-Founder & CDO

Full-Stack Operator | Building across security, AI, and digital infrastructure.