Security · Analysis
Five Days Before Liquid's $320 Million Peg-Out, the Fix Was Already in Git
A four-line commit taught Elements to stop reusing a proof it had checked for a different coin going to a different address. It reached master on September 1 and three release branches by September 3. It has never appeared in a tagged release, and no advisory was ever published.
At 14:05 UTC on September 6, a peg-out request for 4,000 L-BTC arrived at the Liquid federation. Twenty-three minutes later, at 14:28, the federation paid out roughly 3,996 bitcoin. Nothing in that sequence malfunctioned. The peg-out was processed the way every peg-out is processed, through SideSwap, using a key that SideSwap says was never compromised.
The problem was upstream of all of it. The L-BTC being redeemed had never been backed by anything. Per The Crypto Times, SideSwap said the coins "originated from a bug in Elements, the software that underpins Liquid," and that it "could not distinguish those coins from ordinary L-BTC." The federation's reserve stood near 4,200 BTC. Afterward it held 197.
Liquid paused the sidechain — "Bridge nodes have been temporarily disabled, so no new transactions can be submitted to the network," the network said — and the story since has been about the recovery. It should also be about the calendar.
The commit
On September 1 at 09:39 UTC, a commit landed on the Elements master branch with the title fix: range proof cache bind to asset and scriptpubkey. This desk read the diff. It is four changed lines across two files, and it does exactly one thing.
Elements uses confidential transactions, so amounts on Liquid are encrypted. A range proof is the cryptography that establishes an encrypted amount is a sane number rather than a negative one that would conjure coins out of nothing. Verifying those proofs is expensive, so Elements caches the results: check a proof once, remember the answer, skip the work next time.
The cache key is what identifies "next time." Before the fix, the function that computed it — ComputeEntryRangeProof — hashed two things: the proof itself and the value commitment. The fix adds two more: the asset commitment and the destination scriptPubKey.
What makes this pointed is that the calling function already had both. VerifyRangeProof has always received the asset commitment and the script as arguments, and used them in the actual verification. It simply never passed them to the thing that decided whether verification was needed. The data was in hand. It was not in the key.
The consequence is the one independent analysts described this weekend without reference to any commit. Calle, whose explanation CryptoSlate reported, put it as an attacker constructing "an invalid output and proof that matched the cache key associated with a previously valid check," so that "a node finding that cached result would skip the verification that should have rejected the inflationary output." Ledger CTO Charles Guillemet described "a crafted cache-key collision that allowed an invalid confidential transaction to bypass a range check."
Calle cautioned that the account was simplified and could contain errors, and Blockstream has confirmed none of it. But the commit's title is a plain-language description of that same defect, and its diff closes that same door. Blockstream has not said the two are connected. It has also not said they are not.
Three branches, three days
The fix did not travel alone or quickly. Git records the change as authored on August 3 and committed to master on September 1 — a gap of twenty-nine days between the patch existing and the patch landing. Then it moved fast: elements-23.x on September 2, elements-23.3.x on September 3.
That shape — a fix sitting idle for a month, then backported across every live release line on consecutive days — is the signature of a team that has just decided something is serious. It is also, to anyone watching the repository, the most legible signal a project emits.
What the repository shows
| Date (UTC) | Event |
|---|---|
| Jun 14, 2023 | Previous change to sigcache.cpp on the 23.x lines |
| Apr 13, 2026 | elements-23.3.3 released — the most recent tagged release |
| Aug 3, 2026 | Cache fix authored |
| Sep 1, 2026 | Committed to master (09:39), alongside ~15 other hardening commits |
| Sep 2, 2026 | Backported to elements-23.x |
| Sep 3, 2026 | Backported to elements-23.3.x |
| Sep 6, 2026 | 4,000 L-BTC peg-out at 14:05; ~3,996 BTC paid at 14:28 |
| As of Sep 7 | No tagged release contains the fix; no security advisory published; elements-29.x has not received it |
Two further facts sharpen the picture. The most recent tagged Elements release is elements-23.3.3, from April 13 — months before the fix was written. An operator who upgrades only on releases, which is what release branches are for, had no version to upgrade to. And the project has published no security advisories on GitHub at all, so nothing keyed to a CVE or an automated dependency scanner would have flagged anything.
The elements-29.x branch still does not carry the change. Its copy of sigcache.cpp was last touched in March.
A team that knew the hazard
What complicates any easy reading of carelessness is that Elements developers were demonstrably alert to this exact class of bug in the same weeks. On August 28, the project merged pull request #1581, importing a sighash caching optimization from Bitcoin Core with an Elements-specific adaptation: the SIGHASH_RANGEPROOF bit was deliberately added to that cache's key, expanding the lookup table from six entries to sixteen.
The backport commit spells out why. The bit "changes the sighash preimage," it reads, and "is therefore part of the cache key." Then the warning: "Dropping that term is a consensus split with deployed elements-23.x in both directions." A test was added specifically to assert the key includes it.
That is a different cache from the one that failed, and nothing suggests otherwise. But it shows a team that understood, in writing, that a cache key which omits a term that changes the answer is a consensus-level hazard — while an older cache a few files away was keyed on two of the four values it needed.
Where the money is
The party holding the coins says it is returning them. Communication ran through PGP-signed OP_RETURN messages on Bitcoin itself. Per The Block, Blockstream made contact at block 965,822; at block 965,875 the response came back — fix the bug first, and ensure "every node is patched." Blockstream's reply, signed against the security key published on its website, read: "Bridge nodes are patched, safe to return the funds."
The Block reports that 3,400 BTC was returned at block 965,950, leaving 598.5 BTC — about $47.3 million — with the taker. Accounts published earlier the same day, including Crypto Briefing, still showed roughly 3,998 BTC unmoved; those snapshots predate the transfer rather than contradicting it. Readers should treat the split as of Sunday evening and expect it to move.
Update, September 8: it did not move further. The 3,400 BTC return was confirmed on Monday and roughly 598.5 BTC — about $47 million — remains with the takers, with no party describing it as a bounty and no terms disclosed. Blockstream says updated software has been deployed and the federation is preparing a coordinated restart; the sidechain was still frozen on Monday. This desk has covered the unexplained remainder separately in The 598 Bitcoin Nobody Will Call a Bounty.
Blockstream, led by Adam Back, has not published a technical post-mortem. It has not explained how a peg-out of ninety-five percent of the reserve cleared automated controls in twenty-three minutes, and it has not addressed why the cache fix never reached a release.
The Take
Quiet patching is defensible, and this desk has said so before. Publishing an advisory before operators can upgrade tells attackers first. But the technique has one hard requirement: the patch must actually be quiet, and it must actually reach the people running the code. This one achieved neither. It sat in a public repository for five days, wearing a title that describes the vulnerability in plain English, backported across three release lines on three consecutive days in the most conspicuous pattern a maintainer can produce — and at the end of it, the only tagged release available to a cautious operator was five months old and did not contain the fix. The window was open in both directions and only one party was positioned to use it. We do not know that anyone read the commit; nobody has established how the flaw was found, and a researcher could have discovered it independently in code that had gone untouched since 2023. That question is Blockstream's to answer, and the silence is now the second story. The narrower lesson stands on its own: a cache is a promise that two things are the same, and the value of that promise is set entirely by what you put in the key. Here the verifier had the asset and the script in its hands and hashed neither. Four lines fixed it. Five days was the margin.