Author: Johan & Lisa

Edited by: 77

On September 6 at 13:52:10 UTC, two structurally identical transactions appeared in Liquid block #4,050,335. One minute later, block #4,050,336 confirmed a third transaction: approximately 3,998.5 L-BTC entered the UTXO set, with no peg-in behind it. A few minutes later, these nonexistent assets began to be consolidated and spent, and were then converted into real BTC on the Bitcoin mainnet via the federal peg-out channel.

The next day, 3,400 BTC was returned to the federal peg wallet. The remaining 598.5 BTC stayed in the attacker’s hands, and as of the time of publication it was still being repeatedly self-transferred. Each transaction includes a segment of OP_RETURN plaintext, demanding from the federal authorities a tenth of a "bounty." Negotiations are still publicly taking place on-chain.

We checked the blocks around 4,050,335 one by one, and followed the Bitcoin-side fund flow hop by hop using MistTrack. This article covers two things: what the attacker did technically, and a performance optimization that was already in the codebase in 2016—why it allowed 4,000 bitcoins to be cashed out ten years later.

Attack overview

How is Liquid’s money managed?

Liquid’s issuance has only one proper path: send BTC into the federation-managed multisig wallet; only then does the Liquid side mint an equivalent amount of L-BTC. The reverse peg-out returns BTC. This one-to-one anchoring is guarded by the confidential transactions mechanism. Each output amount isn’t written directly on-chain; it’s hidden inside a Pedersen commitment. Each commitment must be accompanied by a rangeproof proving that the amount is non-negative and out of range violations don’t occur. When validating a transaction, the node sums the commitment points for inputs and outputs separately and checks that both sides match; only then is the accounting considered valid.

As long as one output can be stuffed with a fake rangeproof, the commitment can hide a negative number—or an astronomical one—so that minting out of thin air becomes valid mathematically. That’s why rangeproof must be truly verified every time.

The trouble is that it’s expensive. A rangeproof is usually a little over four thousand bytes, and verification involves real elliptic curve computations—every output must be verified. On July 12, 2016, a submission titled “Rangeproof caching” (66ab07bd1b) entered the Elements codebase, using the same idea as Bitcoin’s signature caching: proofs that have already been verified don’t get re-verified; the result “verification success” is stored in a cache table, and next time the same inputs are allowed through directly.

This kind of optimization is very common in cryptographic code. This time, all the problems also came from it.

What the cache key is missing

First, look at this part of the code in the 23.3.3 release.

The cache key is simply proof and commitment concatenated plainly into the same SHA-256 stream. The proof is a variable-length field, and the longer ones are over four thousand bytes. This key contains no asset, no script, and no field boundary information.

The August 3 fix submission this year (c26d719) added the asset commitment and the locking script, turning it into four fields. The direction was correct: the same proof could indeed be repeatedly “reused” with different assets and scripts before. But the patch still concatenated the four fields plainly into the same stream. The proof and scriptPubKey are variable-length fields, so boundaries between them can be shifted from here to there.

The September 8 hardening submission (94000967f6) switched to a serialized hash with length prefixes. The commit message contained a sentence that was very straightforward.

This serializes each field with a length prefix, so distinct argument tuples with byte-identical raw concatenations no longer collide to the same cache key. A cache entry is a positive verification result; a collision would let an attacker bypass verification.

The cache stores “verification passed.” If the key collides, it’s as if something that was never verified before gets vouched for. The same submission also found that the cache key for surjection proofs didn’t bind the input asset label either, and that was fixed as well.

Hit means no verification is needed.

The pass-through happens at the beginning of CachingRangeProofChecker::VerifyRangeProof.

It immediately returns true. The subsequent secp256k1_rangeproof_verify and min_value checks are never executed—none of the later code runs at all.

Writing to and clearing the cache each have their own rules, and that’s key to the attack timing. When transactions enter the mempool, they are validated in a writeable mode: verified results are stored in the cache for reuse later when entering blocks. During block connection, it’s the opposite: the system only queries and does not write. Once a lookup is found, it marks the entry as recyclable. The comment on line 2340 of validation.cpp makes the second rule very clear: the original text is “Don’t cache results if we're actually connecting blocks (still consult the cache, though)”.

Cache entries are consumables. There’s only one thing the attacker needs to ensure: during the window when the target node still retains the “verified” record, submit the transaction carrying the collision payload.

The cache key for each node also includes a random salt generated at startup. The salt can’t save it. The salt is added at the very beginning of all fields. The two streams themselves are identical, so whether you add salt or not, they’re still the same. Therefore, collisions hold independently on each node.

Eight minutes on-chain

The attack began at 13:52:10 in block 4,050,335. Two setup transactions with identical structure were included in the same block. Each included an OP_RETURN output, and that output itself was completely clean. The assets were plaintext L-BTC (6f0279e9…). The amount commitment C0 (09d6c615…) was accompanied by an honest and valid rangeproof P0, which nodes verified through the normal流程. The payload is hidden inside its locking script.


6a43 ‖ C1 ‖ X ‖ 6a

0x43 is 67, and it exactly pushes the following 67 bytes as data. C1 (086f5d67…) is a fake amount commitment prepared for the attack output. X (0a0a488de489…) is a blinding form of an asset commitment; by looking only at the bytes, you can’t tell which asset it corresponds to. We also computed the plaintext generators for L-BTC using secp256k1 实算 in the repository: the generator starts with 0ab963d4, and it is not the same point as X. These two values sit as literal data in the script, and during verification they’re treated only as ordinary data. After verification, each node’s cache contains an additional record whose key is the plain concatenation stream of these four fields.

There’s also a primer step in between. Block connections consume cache entries, so the attacker repeatedly “seeds” transactions with the same payload in the mempool to refill the caches on each node. Which specific transactions are the primer cannot be deduced from block data; they might not have even made it into blocks. With two setup transactions squeezed into the same block—likely also to replenish immediately after the entries were consumed.

At 13:53:10, block 4,050,336 saw the minting transaction. It has one core output, with four fields filled in like this: the proof is P0 followed by a 68-byte tail. The tail content is C0, A, 6a43, where A is the asset commitment of the setup output itself. The commitment is C1. The asset_commitment is X. The scriptPubKey is left with only one byte: 6a.

The four fields are pairwise different. Concatenate them in the clear into SHA-256 one by one; the two streams are identical byte for byte.

When the node verifies this output, it computes the key, hits the cache, and returns true. The secp256k1_rangeproof_verify function has never touched this proof. No matter how large the amount behind C1 is, it is then offset by the Pedersen sum using the output committed to a negative value—about 3,998.5 L-BTC ends up in the UTXO set like this.

At 13:54:10 and 14:00:10, in blocks 4,050,337 and 4,050,343, two transactions consolidated and spent the related UTXOs. The peg-out request was authorized by the federation as usual; equivalent real BTC was then paid out on the Bitcoin mainnet to the receiving address bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. From the time the first setup got included in the block to when the funds began to be consolidated, it was eight minutes in total.

Return and on-chain negotiation

The next day, 3,400 BTC were transferred back to the federation peg wallet bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr. The remaining 598.5 BTC stayed where they were and were repeatedly self-spent between blocks 966,052 and 966,199 from September 8 to September 10. Each spend included an OP_RETURN plaintext message. One of the lines read, “You SHALL pay 10% using your own money as bug bounty,” and the signature left a Telegram account: @MRBOND_1. In the message, the writer claimed to be a white hat and demanded that the federation pay out of its own pocket a formal bounty amount of 10%. Negotiations are still being conducted publicly on-chain.

MistTrack fund tracking

After establishing the basic sequence of events, we further tracked the attacker’s fund flows across the Liquid and Bitcoin mainnets. We used MistTrack to analyze key Bitcoin-side addresses, then cross-verified with on-chain data.

On the Liquid side, the attacker used the address ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w to collect and spend the relevant assets. This address is involved in 91 transactions with 247 outputs, and all of them have already been spent. Since MistTrack has not yet covered the Liquid chain, on the Liquid side—from L-BTC minting to UTXO spending to the subsequent peg-out—we mainly verify the flow step by step using block explorer data.

https://blockstream.info/liquid/address/ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w

On the Bitcoin mainnet side, roughly 3,998.5 BTC of peg-out funds eventually ended up at address bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. MistTrack’s risk score for this address is 100/100, rated as Severe. The tags include Malicious Address / Involved Theft Activity, and it is directly one hop away from entities related to theft-type illegal activity, with a relatedness of 100%.

https://light.misttrack.io/address/BTC/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

From the perspective of the fund source, the BTC received by this address does not correspond to any normal principal. Its origin can be traced back to the earlier minting of L-BTC and the subsequent peg-out. In practice, the main cost borne by the attacker was the related transaction fees.

After that, this funding was split into two parts. Of those, 3,400 BTC were transferred into the federal peg wallet the next day: bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr. When verifying via MistTrack transaction investigation, this address was also the highest-amount address among the transfer targets, corresponding to a transfer amount of 3,400 BTC, consistent with on-chain data.

https://light.misttrack.io/address/BTC/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

The remaining ~598.5 BTC is still controlled by the attacker. Between September 8 and September 10, this portion of funds continuously performed self-transfers between Bitcoin blocks 966,052 and 966,199, and conducted public negotiation with the federation via OP_RETURN messages. One of the messages said, “You SHALL pay 10% using your own money as bug bounty,” and included a Telegram account: @MRBOND_1. As of now, the negotiation can still be observed on-chain.

https://www.oklink.com/bitcoin/address/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

Based on the current on-chain data, the attacker returned 3,400 BTC from roughly 3,998.5 BTC of peg-out funds. The remaining ~598.5 BTC is still within their control, accounting for about 15% of the peg-out amount. Going forward, we will continue monitoring the relevant addresses and any changes in funds.

Conclusion

This code has been sitting in the repository for ten years and has been run by countless nodes countless times. It was fixed once on August 3, yet it still got exploited on September 6. The first version of the patch filled the missing fields into the hash but missed the field boundaries. Five weeks later, the boundary was precisely exploited. In Elements 23.3.4 released on September 8, besides length-prefixing the key, they also added an emergency stop switch, -norangeproofcache. Operations can then disable any cached results and simply turn off that table.

The lesson from this can be stated very briefly. Cache the positive result along the cryptographic verification path: if it hits, you skip verification, and its key must be treated according to the cryptographic primitives’ standards. Every field and every field boundary read by the verification function must be hashed unambiguously; the length prefix exists for exactly this purpose. Performance optimizations such as caching, memoization, and parallelization—so long as they land on the verification path—should receive the same level of treatment as the cryptographic primitive being optimized during auditing.

The MistFog security team recommends that the project conduct a full external security audit before deployment. In the consensus-layer audit checklist, the completeness of verification cache keys should be listed as a mandatory item.