{"vulnerability": "CVE-2010-5139", "sightings": [{"uuid": "e82d9a10-f7f6-40e7-a0f1-48be891207aa", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2010-5139", "type": "seen", "source": "https://gist.github.com/1440000bytes/211ac92dd4433bb1a2e674bf0ff7db2e", "content": "# Elements rangeproof cache consensus failure \u2014 root cause analysis of the 2026-09-06 Liquid network split and 3,998.67 BTC peg-out theft\n\n---\n\n## 1. Executive summary\n\nOn 2026-09-06 at ~12:40 UTC the Liquid Network suffered a consensus split caused by\ntransaction `f24a4b17\u2026183f` (mined in block 4050336, hash `e1d9a2aa\u2026`) whose output #1\ncarries a rangeproof that is **cryptographically invalid** under its own (asset, script)\ncontext. Part of the network accepted the block anyway; part rejected it and has not\naccepted any block since. Within ~9 minutes the attacker laundered the created value into\ntwo `sendtomainchain` peg-out outputs totalling **3,998.67 BTC**, and at 14:25 UTC the\nLiquid functionaries \u2014 following the accepting side of the fork \u2014 paid both peg-outs from\nthe federation's Bitcoin wallet in batched mainnet tx `8db751a6\u2026b140`. The attacker moved\nthe funds onwards within minutes.\n\nThe acceptance of a tx with an invalid rangeproof is explained by a **consensus bug in\nElements' rangeproof memoization cache** (`CachingRangeProofChecker`, `src/script/sigcache.cpp`):\nthe cache entry key is derived **only** from the rangeproof bytes and the value commitment,\nomitting the **asset generator** and the **scriptPubKey** (both of which are authenticated\nby `secp256k1_rangeproof_verify` as the generator argument and the `extra_commit` entropy\ninput). A proof once verified successfully in one (asset, script) context is thereafter\ntreated as valid for *any* asset/script on that node, for the lifetime of the process.\n\nA fix (\"Fix caching bug in rangeproof caching\", `c26d719c29`, PR #1561) was merged to\n`master` on **2026-09-01** and cherry-picked to `elements-23.x` (`6253d7e103`, 09-02) and\n`elements-23.3.x` (`212c43f475`, 09-03) \u2014 **3\u20135 days before the attack** \u2014 but at the time\nof the attack (and still today) **no release tag contains the fix** (verified:\n`git tag --contains` for all three commits is empty; latest tags `elements-23.3.3`,\n`elements-23.4.0rc3`, `elements-29.4.1rc1` all predate it). The timeline strongly suggests\nthe attacker reverse-engineered the vulnerability from the public fix commits and exploited\nit before any release or deployment.\n\n---\n\n## 2. Root cause: the rangeproof cache key\n\n### 2.1 Pre-fix code (`c26d719c29~1:src/script/sigcache.cpp`)\n\n```cpp\nbool CachingRangeProofChecker::VerifyRangeProof(\n    const std::vector&amp; vchRangeProof,\n    const std::vector&amp; vchValueCommitment,\n    const std::vector&amp; vchAssetCommitment,   // &lt;-- NOT in the key\n    const CScript&amp; scriptPubKey,                             // &lt;-- NOT in the key\n    const secp256k1_context* ...) const\n{\n    uint256 entry;\n    rangeProofCache.ComputeEntryRangeProof(entry, vchRangeProof, vchValueCommitment);\n\n    if (rangeProofCache.Get(entry, !store)) {\n        return true;                       // cache hit: ACCEPT without any verification\n    }\n    ...\n    secp256k1_generator tag;\n    secp256k1_generator_parse(..., &amp;tag, &amp;vchAssetCommitment[0]);\n    if (!secp256k1_rangeproof_verify(..., &amp;commit,\n            vchRangeProof.data(), vchRangeProof.size(),\n            scriptPubKey.size() ? &amp;scriptPubKey.front() : nullptr,\n            scriptPubKey.size(), &amp;tag)) {\n        return false;                      // real verification on miss\n    }\n    ...\n    if (store) {\n        rangeProofCache.Set(entry);        // only successes are cached\n    }\n    return true;\n}\n```\n\n- `ComputeEntryRangeProof(entry, proof, value_commitment)` \u2014 salted SHA-256 over\n  **proof + value commitment only**.\n- `secp256k1_rangeproof_verify` binds the proof to three things: the **commitment**,\n  the **generator** (asset), and the **extra commitment** (the output's scriptPubKey,\n  hashed into the proof's message). Two of the three are missing from the cache key.\n- `Set` is called only on successful verification (no negative caching), and\n  `Get(entry, /*erase=*/!store)` removes the entry on read during block validation\n  (`store == false`) but keeps it during mempool acceptance (`store == true`).\n- The cache is a per-process in-memory `CuckooCache` (`SignatureCache`), salted per\n  process: each node must be primed individually; entries do not survive restarts.\n\n\n### 2.2 Where the checker is used\n\n- **Mempool:** `MemPoolAccept::PreChecks` \u2192 `Consensus::CheckTxInputs`\n  (`src/consensus/tx_verify.cpp`): first `HasValidFee(tx)` (fee outputs must be explicit,\n  non-zero, `MoneyRange`), then `VerifyAmounts(..., cacheStore=true)` \u2192 every successfully\n  verified output rangeproof is **stored** in the cache and survives even if the tx is\n  later evicted from the mempool.\n- **Block validation:** `ConnectBlock` \u2192 `CheckTxInputs` \u2192 `VerifyAmounts` with\n  `cacheStore=false`: the cache is **read** (and the entry erased on hit) but nothing new\n  is stored.\n\nConsequence: any node that ever accepted into its mempool a transaction whose output\n`(P, C)` verified under context `(G_A, S_A)` will later accept \u2014 **in a block, without\nre-verification** \u2014 any output with the same `(P, C)` under a different context\n`(G_B, S_B)` for which the proof is invalid.\n\n### 2.3 The fix (commit `c26d719c29`)\n\n`ComputeEntryRangeProof` now takes the asset commitment and scriptPubKey, and\n`GetVerify`/`StoreVerify` thread both through to the key derivation:\n\n```cpp\nComputeEntryRangeProof(entry, vchRangeProof, vchValueCommitment,\n                       vchAssetCommitment, scriptPubKey);   // context now in the key\n```\n\nThe fix is 6 lines in `src/script/sigcache.{cpp,h}` and touches **only** the rangeproof\nkey. The surjection-proof cache was audited and is **not** affected: its key is\n`salted(wtxid \u2016 proof \u2016 output_generator)` (see `CachingSurjectionProofChecker`,\nunchanged by the fix), and the wtxid commits to every input/output asset commitment\nin the transaction, so the full verification context is transitively bound.\n\n---\n\n## 3. The cryptographic primitive the bug exposes\n\nA Pedersen value commitment `C = v\u00b7G + r\u00b7H` binds a value `v` to a specific asset\ngenerator `G`. The *same 33 bytes* `C` represent **different values under different\ngenerators**. A rangeproof `P` for `(C, G_A, S_A)` proves `0 \u2264 v_A &lt; 2^64` where\n`C = v_A\u00b7G_A + r\u00b7H`. Reused for `(C, G_L, S_B)` where `G_L` is the L-BTC generator and\n`C = v_L\u00b7G_L + r'\u00b7H` with `v_L` **negative** (mod the group order), the same bytes `P`\ncannot verify \u2014 but the buggy cache never runs the verification the second time.\n\nThis turns a per-node performance cache into a network-splitting consensus oracle:\n**primed nodes accept, unprimed nodes reject, and the difference is invisible in the\nblock data itself.**\n\n---\n\n## 4. The attack as observed on-chain\n\n### 4.1 Actors / artifacts\n\n| Item | Value |\n|---|---|\n| Crafted commitment `C1` | `086f5d67160fc4b477954fb09ef321e5b589d7a07740a1a6df494ed2335b1d01d8` |\n| Reused rangeproof `P1` | sha256 prefix `6619fa29\u2026`, 4,234 bytes |\n| Attack tx (mined) `V1` | `f24a4b179b5cc7e88b25a763911f7cbdf2bf45d1d1b5ab611e94461cef0a183f` @ block 4050336 |\n| Double-spend variant `V2` | txid `a1669379f6204f066320974effeefcad2d758fa8ee408c35ae07bc8580c4abe9` (raw bytes recovered from the network; never mined) |\n| Pre-attack marker / dry run | `71c93d43\u2026f411` and `27114710\u20267ec5` @ 4050335; OP_RETURN blob = `[C1][0x0a \u2016 0a488de4\u2026d6d6a]` |\n| Laundering tx | `46f117c9\u2026` @ 4050344 (peg-out 2.65138358 BTC) |\n| Peg-out tx | `ce4caece413cd9d444ce7ed9f54e5b328b3da5e4af301aff59a3571f76e988f2` @ 4050349 (peg-out 3,996.01834922 BTC) |\n| Federation mainnet payout | `8db751a650ae2f12006b7e8c69a75e4df360e8afd6b9e05ae0b9fa6458a7b140`, 2026-09-06 14:25:13 UTC, 83 in / 13 out |\n| Attacker BTC destinations | `bc1qkxwva32eh7mgezq5kladncd3n5wtcjmslh98my` (2.65) / `bc1qgslsydz56d0ed6827hdemfmk5w2f6ldyc6wt7p` (3,996.02) \u2014 both received the exact peg-out amounts and were emptied within ~20 min |\n\n### 4.2 Timeline (UTC)\n\n- **08-03** fix authored (`c26d719c29`)\n- **09-01** merged to `master`; **09-02 / 09-03** cherry-picked to `elements-23.x` / `elements-23.3.x`. **No release ever tags the fix.**\n- **09-06 ~12:30\u201312:39** blocks 4050334\u20134050335 (valid on both future sides). `71c93d43\u2026f411` in 4050335 plants the OP_RETURN blob embedding `C1`; `27114710\u20267ec5` in the same block duplicates the *identical* `(proof, commitment, asset, script)` tuple of `71c93d43`'s OP_RETURN output \u2014 an on-chain dry run of proof/commitment replay (benign because the context was identical).\n- **~12:40** block 4050336 `e1d9a2aa\u2026` mined with `f24a4b17\u2026183f` (`V1`). **Fork.**\n- **~12:44** `46f117c9\u2026` @4050344: 2.65138358 BTC explicit `sendtomainchain` OP_RETURN.\n- **~12:49** `ce4caece\u2026f2` @4050349: 3,996.01834922 BTC explicit `sendtomainchain` OP_RETURN.\n- **14:25:13** functionaries' batched withdrawal `8db751a6\u2026b140` pays both destinations on Bitcoin mainnet (consistent with the ~102-block peg-out maturity counted on the *accepting* chain).\n- **14:25:49 / 14:41:35** attacker forwards the proceeds (`85d2ca15\u2026`, `22c6afb0\u2026`); the 3,996 BTC is split 3,995.99999857 + 0.01834922 to fresh addresses.\n- **09-07** accepting chain (blockstream.info) at 4050420+; rejecting side (liquid.network's backend) still stuck at tip 4050335 \u2248 26 h later. mempool.space's Liquid instance diverged/unreachable during the incident.\n\n### 4.3 Anatomy of `V1` (f24a4b17\u2026183f), from raw bytes\n\n| out | asset | value | scriptPubKey | proof |\n|---|---|---|---|---|\n| 0 | explicit L-BTC | committed `08360f95\u2026` (the **huge positive** \u2248 4.18e18 sats; valid proof) | p2wpkh `f590\u2026` (attacker) | 4,174 B, VALID |\n| 1 | explicit L-BTC | committed **`086f5d67\u2026` = C1** (the **crafted negative** \u2248 \u22124.18e18) | **`6a` (OP_RETURN)** | 4,234 B, **INVALID** under every context tested |\n| 2 | committed asset `0b0957be4c\u2026` | committed `08dc50cf\u2026` | p2wpkh `f590\u2026` | 4,174 B, VALID |\n| 3 | explicit L-BTC | **58 sats** | empty (fee) | \u2013 |\n\nBalance: input `0fbde521:2` (legit, pre-fork) vs `C_out0 + C_out1 + C_out2 + 58\u00b7H_L`.\n`C_out0` commits to a huge **in-range** positive value \u2014 its rangeproof is honestly\ngenerated and *valid* (rangeproofs prove `0 \u2264 v &lt; 2^64`, nothing about inputs). `C1`\ncommits to the corresponding huge **negative** value \u2014 its proof cannot exist, and it is\nthe one that rides the cache. The invalid output is deliberately `OP_RETURN`\n(unspendable), which also exempts it from the `min_value == 0 &amp;&amp; !IsUnspendable()`\nrule; the positive side of the forgery sits in the spendable, validly-proven out0.\n\n`V2` (unmined variant `a1669379\u2026`) is byte-identical except the fee output carries\n**4,179,340,454,199,820,288 sats = `0x3a00000000000000`** instead of out0 hiding the\nvalue. `V2` is unmineable on *any* version: `HasValidFee` (`MoneyRange(fee)`,\n`MAX_MONEY = 21e6 BTC`) has rejected such fees since 2016 \u2014 which is presumably why the\nattacker switched to the `V1` design (value hidden in a *confidential* output, where no\nexplicit-value range check applies). The reused magnitude `0x3a00000000000000` matches\nthe crafted negative commitment in both variants.\n\nEmpirical verification (local libsecp256k1-zkp via ctypes): `P1`/`C1` verifies\n**INVALID** under (L-BTC raw generator, `6a`), (assetcommit `0b0957be4c\u2026`, `6a`), both\ncandidate blob-derived generators \u00d7 {`6a`, both attacker p2wpkh scripts, empty, the blob\nscript}, and three raw-seed interpretations of the blob tail \u00d7 4 scripts \u2014 i.e., invalid\nunder its true context and every plausible recorded context. Its validity under the true\npriming context is inferred (the block was accepted), not yet demonstrated, because the\npriming transaction has not been found on-chain (see \u00a76).\n\n\n### 4.4 Why `HasValidFee` / `MoneyRange` don't save the network\n\n- The fee check only constrains *explicit fee outputs*; `V1`'s fee is 58 sats.\n- `CheckTransaction`'s per-output `MoneyRange` applies to *explicit* values; the forged\n  amounts live entirely inside *commitments*, which no sanity check can range-check \u2014\n  that is precisely the rangeproof's job, and it was cache-bypassed.\n- The huge explicit values only appear *after* laundering, in `sendtomainchain`\n  OP_RETURN peg-out outputs: 2.65 BTC and 3,996 BTC \u2014 both `&lt; MAX_MONEY`, so even those\n  pass.\n\n### 4.5 Node-level behavior matrix (why the split persisted)\n\n| node state | on `V1` in mempool | on block 4050336 |\n|---|---|---|\n| primed cache (accepted priming tx earlier) | accept (cache hit) | **accept** (cache hit, entry erased) |\n| unprimed, vulnerable code | reject (real verify fails) | **reject block** |\n| patched code (fix built from branch) | reject (key mismatch \u2192 real verify fails) | **reject block** |\n\nRejecting nodes never see a valid heavier chain (the functionaries kept building on the\ninvalid chain \u2014 their own nodes were primed), so their tip freezes at 4050335:\nexactly what liquid.network's backend shows a day later.\n\n### 4.6 Pre-fork dry run\n\n`71c93d43\u2026f411` and `27114710\u20267ec5` (both @4050335) contain *identical* OP_RETURN\noutputs (same 4,234-byte proof, same commitment, same asset, same script) \u2014 the second\ntx's output is accepted trivially (it would verify anyway), but the pair exercises\nexactly the replay mechanism, and the blob embeds `C1` plus 34 bytes\n(`0a \u2016 0a488de4\u2026d6d6a`) that appear to document/obfuscate the priming parameters (the\n`0a488de4\u2026` slice does **not** parse as a generator; raw-seed interpretations also fail).\nThis looks like pre-positioning/test traffic in the last block before the attack.\n\n---\n\n## 5. Affected versions\n\n- **Every released Elements version at attack time was vulnerable**: the cache code is\n  ancient (Elements 0.x era) and the fix exists only on `master`, `elements-23.x`,\n  `elements-23.3.x` branches (merged 2026-09-01..03). Verified:\n  `git tag --contains {c26d719c29,6253d7e103,212c43f475}` \u2192 no release tags.\n  Latest releases (`elements-23.3.3`, `23.3.4rc1`, `23.4.0rc3`, `29.4.1rc1`) predate it.\n- Any sidechain based on Elements with confidential assets enabled is consensus-affected\n  the same way; the bug is in shared consensus code, not Liquid-specific config.\n- **Severity: critical.** Remote, unauthenticated, splits the network deterministically\n  per-node-cache-state, and on Liquid enabled direct theft of federation BTC via peg-outs.\n\n\n### 5.1 Adjacent-code audit (2026-09-07): no other critical bugs found\n\nA targeted audit of the surrounding consensus code (master `c7e856fab1` and\n`c26d719c29~1`) found **no additional unfixed vulnerability of the same class**:\n\n- **Only two proof caches exist** (rangeproof, surjection). The surjection cache key\n  was always safe (wtxid-bound, see \u00a72.3). The ECDSA/Schnorr `SignatureCache` uses\n  upstream Bitcoin keying \u2014 sighash commits to all relevant context.\n- **Single priming vector confirmed:** `VerifyAmounts` is called only from\n  `Consensus::CheckTxInputs` (tx_verify.cpp:250) \u2014 mempool acceptance\n  (`cacheStore=true`) and `ConnectBlock` (`cacheStore=false`). `testmempoolaccept`\n  would also store cache entries but requires RPC auth; `CTxMemPool::check` is a\n  local sanity routine. No block-relay or orphan path stores entries.\n- **`HasValidFee`** (confidential_validation.cpp:33): per-fee-output `fee &gt; 0` and\n  `MoneyRange`, plus cumulative per-asset `MoneyRange` \u2014 no overflow possible\n  (output count is weight-bounded). Note: consensus does **not** restrict the fee\n  *asset* (fees may be paid in any asset; only mempool policy requires the policy\n  asset). By design, but worth documenting.\n- **Explicit values:** `CheckTransaction` enforces per-output `&lt; MAX_MONEY` *and* a\n  cumulative `MoneyRange` over all explicit outputs (CVE-2010-5139 lineage).\n  Explicit issuance amounts must be `&gt; 0`, and `MoneyRange` is enforced when the\n  issued asset is the pegged asset (`VerifyIssuanceAmount`). Confidential issuance\n  amounts use rangeproofs that went through the same (formerly buggy) checker \u2014\n  covered by the same fix.\n- **Consequence landmine (not a separate vuln, fixed by the same commit):** the\n  erase-on-read semantics (`Get(entry, /*erase=*/!store)`) mean a node that accepted\n  the invalid block *consumed* its cache entry. On reorg disconnect/reconnect, or on\n  the startup `-checkblocks` re-validation of recent blocks (which re-runs\n  `ConnectBlock`), such a node **fails to re-validate the very block it accepted**\n  and errors out (forcing a reindex onto the honest chain). Restarting also wipes\n  the cache. This explains part of the observed post-attack network behavior and\n  means accepting-side infrastructure is fragile until patched and reindexed.\n\n---\n\n## 6. Open items / limitations\n\n1. **Priming transaction not (yet) located.** It must have been *accepted to mempools*\n   (only mempool acceptance stores cache entries) and may never have been mined.\n   A completed scan of the **1,500 blocks (~25 h) preceding the fork** on the valid\n   chain (`scan_back.py`, 1,500 blocks saved to `backscan/`, `DONE found=[]`) found\n   **no** output/issuance carrying `C1` and no occurrence of the blob asset bytes\n   anywhere except the 4050335 marker tx. This strongly supports mempool-only\n   priming; if so, only node operators' mempool acceptance logs / `debug.log` can\n   identify the priming transaction.\n2. Which exact software the accepting functionaries/explorers run (they must be both\n   unpatched *and* have been primed; patched branch builds reject the block).\n3. Whether `V2` was ever broadcast on the P2P network or only seen by one explorer\n   backend; its purpose is inferred (first-generation design, killed by `HasValidFee`).\n4. mempool.space's Liquid backend was unreachable during analysis; its fork position\n   could not be re-confirmed today.\n5. The cryptographic demonstration is one-sided: `P1`/`C1` is *proven* invalid under\n   its on-chain context and 20+ plausible alternates; the *valid* priming context is\n   inferred from the observed acceptance, not reproduced, pending item 1.\n\n---\n\n## 7. Recommendations\n\n1. **Emergency release** of all maintained branches with `c26d719c29` (and the 23.x /\n   23.3.x cherry-picks), with clear upgrade-now language; until deployed, the network\n   remains one primed-cache away from another split (the attack is repeatable with fresh\n   `(P, C)` pairs).\n2. **Node operators:** restarting clears the primed cache but does not by itself resolve\n   the split; accepting-side nodes must `invalidateblock e1d9a2aa\u2026` (and everything on\n   top) after upgrading to rejoin the valid chain.\n3. **Functionaries / watchmen:** halt peg-out processing while the network is split;\n   the 14:25 payout shows watchmen maturing peg-outs on a chain that honest unprimed\n   nodes reject. Consider requiring N-of-M oracle agreement on best chain before\n   signing withdrawals.\n4. **Defense in depth (code):**\n   - The chosen fix (context in key) is correct; add a consensus regression test:\n     same `(proof, commitment)` under a different asset and script must fail.\n   - `CachingSurjectionProofChecker` was audited and is safe (key includes the wtxid,\n     which commits to all generator context); a dedicated regression test is still\n     worthwhile to lock that property in.\n   - Consider skipping the rangeproof-result cache during block validation entirely\n     (verification is cheap relative to Liquid's 1-minute blocks).\n   - Consider `MoneyRange`-style caps for *peg-out* OP_RETURN explicit values\n     (per-output and per-block) \u2014 that alone would have stopped the 3,996 BTC output\n     even with the cache bypassed.\n5. **Disclosure process:** the fix was public, clearly titled, and easily diffable for\n   3\u20135 days pre-attack (\"Fix caching bug in rangeproof caching\") \u2014 effectively a\n   published exploit recipe. For consensus-critical crypto/cache fixes, consider\n   embargoed or low-visibility landing (merge at release time), as practiced for\n   Bitcoin Core CVEs.\n", "creation_timestamp": "2026-09-06T23:55:15.164773Z"}, {"uuid": "046ffcfc-75c4-4fee-9553-b4d8524c0a7f", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2010-5139", "type": "seen", "source": "https://gist.github.com/1440000bytes/211ac92dd4433bb1a2e674bf0ff7db2e", "content": "# Elements rangeproof cache consensus failure \u2014 root cause analysis of the 2026-09-06 Liquid network split and 3,998.67 BTC peg-out theft\n\n---\n\n## 1. Executive summary\n\nOn 2026-09-06 at ~12:40 UTC the Liquid Network suffered a consensus split caused by\ntransaction `f24a4b17\u2026183f` (mined in block 4050336, hash `e1d9a2aa\u2026`) whose output #1\ncarries a rangeproof that is **cryptographically invalid** under its own (asset, script)\ncontext. Part of the network accepted the block anyway; part rejected it and has not\naccepted any block since. Within ~9 minutes the attacker laundered the created value into\ntwo `sendtomainchain` peg-out outputs totalling **3,998.67 BTC**, and at 14:25 UTC the\nLiquid functionaries \u2014 following the accepting side of the fork \u2014 paid both peg-outs from\nthe federation's Bitcoin wallet in batched mainnet tx `8db751a6\u2026b140`. The attacker moved\nthe funds onwards within minutes.\n\nThe acceptance of a tx with an invalid rangeproof is explained by a **consensus bug in\nElements' rangeproof memoization cache** (`CachingRangeProofChecker`, `src/script/sigcache.cpp`):\nthe cache entry key is derived **only** from the rangeproof bytes and the value commitment,\nomitting the **asset generator** and the **scriptPubKey** (both of which are authenticated\nby `secp256k1_rangeproof_verify` as the generator argument and the `extra_commit` entropy\ninput). A proof once verified successfully in one (asset, script) context is thereafter\ntreated as valid for *any* asset/script on that node, for the lifetime of the process.\n\nA fix (\"Fix caching bug in rangeproof caching\", `c26d719c29`, PR #1561) was merged to\n`master` on **2026-09-01** and cherry-picked to `elements-23.x` (`6253d7e103`, 09-02) and\n`elements-23.3.x` (`212c43f475`, 09-03) \u2014 **3\u20135 days before the attack** \u2014 but at the time\nof the attack (and still today) **no release tag contains the fix** (verified:\n`git tag --contains` for all three commits is empty; latest tags `elements-23.3.3`,\n`elements-23.4.0rc3`, `elements-29.4.1rc1` all predate it). The timeline strongly suggests\nthe attacker reverse-engineered the vulnerability from the public fix commits and exploited\nit before any release or deployment.\n\n---\n\n## 2. Root cause: the rangeproof cache key\n\n### 2.1 Pre-fix code (`c26d719c29~1:src/script/sigcache.cpp`)\n\n```cpp\nbool CachingRangeProofChecker::VerifyRangeProof(\n    const std::vector&amp; vchRangeProof,\n    const std::vector&amp; vchValueCommitment,\n    const std::vector&amp; vchAssetCommitment,   // &lt;-- NOT in the key\n    const CScript&amp; scriptPubKey,                             // &lt;-- NOT in the key\n    const secp256k1_context* ...) const\n{\n    uint256 entry;\n    rangeProofCache.ComputeEntryRangeProof(entry, vchRangeProof, vchValueCommitment);\n\n    if (rangeProofCache.Get(entry, !store)) {\n        return true;                       // cache hit: ACCEPT without any verification\n    }\n    ...\n    secp256k1_generator tag;\n    secp256k1_generator_parse(..., &amp;tag, &amp;vchAssetCommitment[0]);\n    if (!secp256k1_rangeproof_verify(..., &amp;commit,\n            vchRangeProof.data(), vchRangeProof.size(),\n            scriptPubKey.size() ? &amp;scriptPubKey.front() : nullptr,\n            scriptPubKey.size(), &amp;tag)) {\n        return false;                      // real verification on miss\n    }\n    ...\n    if (store) {\n        rangeProofCache.Set(entry);        // only successes are cached\n    }\n    return true;\n}\n```\n\n- `ComputeEntryRangeProof(entry, proof, value_commitment)` \u2014 salted SHA-256 over\n  **proof + value commitment only**.\n- `secp256k1_rangeproof_verify` binds the proof to three things: the **commitment**,\n  the **generator** (asset), and the **extra commitment** (the output's scriptPubKey,\n  hashed into the proof's message). Two of the three are missing from the cache key.\n- `Set` is called only on successful verification (no negative caching), and\n  `Get(entry, /*erase=*/!store)` removes the entry on read during block validation\n  (`store == false`) but keeps it during mempool acceptance (`store == true`).\n- The cache is a per-process in-memory `CuckooCache` (`SignatureCache`), salted per\n  process: each node must be primed individually; entries do not survive restarts.\n\n\n### 2.2 Where the checker is used\n\n- **Mempool:** `MemPoolAccept::PreChecks` \u2192 `Consensus::CheckTxInputs`\n  (`src/consensus/tx_verify.cpp`): first `HasValidFee(tx)` (fee outputs must be explicit,\n  non-zero, `MoneyRange`), then `VerifyAmounts(..., cacheStore=true)` \u2192 every successfully\n  verified output rangeproof is **stored** in the cache and survives even if the tx is\n  later evicted from the mempool.\n- **Block validation:** `ConnectBlock` \u2192 `CheckTxInputs` \u2192 `VerifyAmounts` with\n  `cacheStore=false`: the cache is **read** (and the entry erased on hit) but nothing new\n  is stored.\n\nConsequence: any node that ever accepted into its mempool a transaction whose output\n`(P, C)` verified under context `(G_A, S_A)` will later accept \u2014 **in a block, without\nre-verification** \u2014 any output with the same `(P, C)` under a different context\n`(G_B, S_B)` for which the proof is invalid.\n\n### 2.3 The fix (commit `c26d719c29`)\n\n`ComputeEntryRangeProof` now takes the asset commitment and scriptPubKey, and\n`GetVerify`/`StoreVerify` thread both through to the key derivation:\n\n```cpp\nComputeEntryRangeProof(entry, vchRangeProof, vchValueCommitment,\n                       vchAssetCommitment, scriptPubKey);   // context now in the key\n```\n\nThe fix is 6 lines in `src/script/sigcache.{cpp,h}` and touches **only** the rangeproof\nkey. The surjection-proof cache was audited and is **not** affected: its key is\n`salted(wtxid \u2016 proof \u2016 output_generator)` (see `CachingSurjectionProofChecker`,\nunchanged by the fix), and the wtxid commits to every input/output asset commitment\nin the transaction, so the full verification context is transitively bound.\n\n---\n\n## 3. The cryptographic primitive the bug exposes\n\nA Pedersen value commitment `C = v\u00b7G + r\u00b7H` binds a value `v` to a specific asset\ngenerator `G`. The *same 33 bytes* `C` represent **different values under different\ngenerators**. A rangeproof `P` for `(C, G_A, S_A)` proves `0 \u2264 v_A &lt; 2^64` where\n`C = v_A\u00b7G_A + r\u00b7H`. Reused for `(C, G_L, S_B)` where `G_L` is the L-BTC generator and\n`C = v_L\u00b7G_L + r'\u00b7H` with `v_L` **negative** (mod the group order), the same bytes `P`\ncannot verify \u2014 but the buggy cache never runs the verification the second time.\n\nThis turns a per-node performance cache into a network-splitting consensus oracle:\n**primed nodes accept, unprimed nodes reject, and the difference is invisible in the\nblock data itself.**\n\n---\n\n## 4. The attack as observed on-chain\n\n### 4.1 Actors / artifacts\n\n| Item | Value |\n|---|---|\n| Crafted commitment `C1` | `086f5d67160fc4b477954fb09ef321e5b589d7a07740a1a6df494ed2335b1d01d8` |\n| Reused rangeproof `P1` | sha256 prefix `6619fa29\u2026`, 4,234 bytes |\n| Attack tx (mined) `V1` | `f24a4b179b5cc7e88b25a763911f7cbdf2bf45d1d1b5ab611e94461cef0a183f` @ block 4050336 |\n| Double-spend variant `V2` | txid `a1669379f6204f066320974effeefcad2d758fa8ee408c35ae07bc8580c4abe9` (raw bytes recovered from the network; never mined) |\n| Pre-attack marker / dry run | `71c93d43\u2026f411` and `27114710\u20267ec5` @ 4050335; OP_RETURN blob = `[C1][0x0a \u2016 0a488de4\u2026d6d6a]` |\n| Laundering tx | `46f117c9\u2026` @ 4050344 (peg-out 2.65138358 BTC) |\n| Peg-out tx | `ce4caece413cd9d444ce7ed9f54e5b328b3da5e4af301aff59a3571f76e988f2` @ 4050349 (peg-out 3,996.01834922 BTC) |\n| Federation mainnet payout | `8db751a650ae2f12006b7e8c69a75e4df360e8afd6b9e05ae0b9fa6458a7b140`, 2026-09-06 14:25:13 UTC, 83 in / 13 out |\n| Attacker BTC destinations | `bc1qkxwva32eh7mgezq5kladncd3n5wtcjmslh98my` (2.65) / `bc1qgslsydz56d0ed6827hdemfmk5w2f6ldyc6wt7p` (3,996.02) \u2014 both received the exact peg-out amounts and were emptied within ~20 min |\n\n### 4.2 Timeline (UTC)\n\n- **08-03** fix authored (`c26d719c29`)\n- **09-01** merged to `master`; **09-02 / 09-03** cherry-picked to `elements-23.x` / `elements-23.3.x`. **No release ever tags the fix.**\n- **09-06 ~12:30\u201312:39** blocks 4050334\u20134050335 (valid on both future sides). `71c93d43\u2026f411` in 4050335 plants the OP_RETURN blob embedding `C1`; `27114710\u20267ec5` in the same block duplicates the *identical* `(proof, commitment, asset, script)` tuple of `71c93d43`'s OP_RETURN output \u2014 an on-chain dry run of proof/commitment replay (benign because the context was identical).\n- **~12:40** block 4050336 `e1d9a2aa\u2026` mined with `f24a4b17\u2026183f` (`V1`). **Fork.**\n- **~12:44** `46f117c9\u2026` @4050344: 2.65138358 BTC explicit `sendtomainchain` OP_RETURN.\n- **~12:49** `ce4caece\u2026f2` @4050349: 3,996.01834922 BTC explicit `sendtomainchain` OP_RETURN.\n- **14:25:13** functionaries' batched withdrawal `8db751a6\u2026b140` pays both destinations on Bitcoin mainnet (consistent with the ~102-block peg-out maturity counted on the *accepting* chain).\n- **14:25:49 / 14:41:35** attacker forwards the proceeds (`85d2ca15\u2026`, `22c6afb0\u2026`); the 3,996 BTC is split 3,995.99999857 + 0.01834922 to fresh addresses.\n- **09-07** accepting chain (blockstream.info) at 4050420+; rejecting side (liquid.network's backend) still stuck at tip 4050335 \u2248 26 h later. mempool.space's Liquid instance diverged/unreachable during the incident.\n\n### 4.3 Anatomy of `V1` (f24a4b17\u2026183f), from raw bytes\n\n| out | asset | value | scriptPubKey | proof |\n|---|---|---|---|---|\n| 0 | explicit L-BTC | committed `08360f95\u2026` (the **huge positive** \u2248 4.18e18 sats; valid proof) | p2wpkh `f590\u2026` (attacker) | 4,174 B, VALID |\n| 1 | explicit L-BTC | committed **`086f5d67\u2026` = C1** (the **crafted negative** \u2248 \u22124.18e18) | **`6a` (OP_RETURN)** | 4,234 B, **INVALID** under every context tested |\n| 2 | committed asset `0b0957be4c\u2026` | committed `08dc50cf\u2026` | p2wpkh `f590\u2026` | 4,174 B, VALID |\n| 3 | explicit L-BTC | **58 sats** | empty (fee) | \u2013 |\n\nBalance: input `0fbde521:2` (legit, pre-fork) vs `C_out0 + C_out1 + C_out2 + 58\u00b7H_L`.\n`C_out0` commits to a huge **in-range** positive value \u2014 its rangeproof is honestly\ngenerated and *valid* (rangeproofs prove `0 \u2264 v &lt; 2^64`, nothing about inputs). `C1`\ncommits to the corresponding huge **negative** value \u2014 its proof cannot exist, and it is\nthe one that rides the cache. The invalid output is deliberately `OP_RETURN`\n(unspendable), which also exempts it from the `min_value == 0 &amp;&amp; !IsUnspendable()`\nrule; the positive side of the forgery sits in the spendable, validly-proven out0.\n\n`V2` (unmined variant `a1669379\u2026`) is byte-identical except the fee output carries\n**4,179,340,454,199,820,288 sats = `0x3a00000000000000`** instead of out0 hiding the\nvalue. `V2` is unmineable on *any* version: `HasValidFee` (`MoneyRange(fee)`,\n`MAX_MONEY = 21e6 BTC`) has rejected such fees since 2016 \u2014 which is presumably why the\nattacker switched to the `V1` design (value hidden in a *confidential* output, where no\nexplicit-value range check applies). The reused magnitude `0x3a00000000000000` matches\nthe crafted negative commitment in both variants.\n\nEmpirical verification (local libsecp256k1-zkp via ctypes): `P1`/`C1` verifies\n**INVALID** under (L-BTC raw generator, `6a`), (assetcommit `0b0957be4c\u2026`, `6a`), both\ncandidate blob-derived generators \u00d7 {`6a`, both attacker p2wpkh scripts, empty, the blob\nscript}, and three raw-seed interpretations of the blob tail \u00d7 4 scripts \u2014 i.e., invalid\nunder its true context and every plausible recorded context. Its validity under the true\npriming context is inferred (the block was accepted), not yet demonstrated, because the\npriming transaction has not been found on-chain (see \u00a76).\n\n\n### 4.4 Why `HasValidFee` / `MoneyRange` don't save the network\n\n- The fee check only constrains *explicit fee outputs*; `V1`'s fee is 58 sats.\n- `CheckTransaction`'s per-output `MoneyRange` applies to *explicit* values; the forged\n  amounts live entirely inside *commitments*, which no sanity check can range-check \u2014\n  that is precisely the rangeproof's job, and it was cache-bypassed.\n- The huge explicit values only appear *after* laundering, in `sendtomainchain`\n  OP_RETURN peg-out outputs: 2.65 BTC and 3,996 BTC \u2014 both `&lt; MAX_MONEY`, so even those\n  pass.\n\n### 4.5 Node-level behavior matrix (why the split persisted)\n\n| node state | on `V1` in mempool | on block 4050336 |\n|---|---|---|\n| primed cache (accepted priming tx earlier) | accept (cache hit) | **accept** (cache hit, entry erased) |\n| unprimed, vulnerable code | reject (real verify fails) | **reject block** |\n| patched code (fix built from branch) | reject (key mismatch \u2192 real verify fails) | **reject block** |\n\nRejecting nodes never see a valid heavier chain (the functionaries kept building on the\ninvalid chain \u2014 their own nodes were primed), so their tip freezes at 4050335:\nexactly what liquid.network's backend shows a day later.\n\n### 4.6 Pre-fork dry run\n\n`71c93d43\u2026f411` and `27114710\u20267ec5` (both @4050335) contain *identical* OP_RETURN\noutputs (same 4,234-byte proof, same commitment, same asset, same script) \u2014 the second\ntx's output is accepted trivially (it would verify anyway), but the pair exercises\nexactly the replay mechanism, and the blob embeds `C1` plus 34 bytes\n(`0a \u2016 0a488de4\u2026d6d6a`) that appear to document/obfuscate the priming parameters (the\n`0a488de4\u2026` slice does **not** parse as a generator; raw-seed interpretations also fail).\nThis looks like pre-positioning/test traffic in the last block before the attack.\n\n---\n\n## 5. Affected versions\n\n- **Every released Elements version at attack time was vulnerable**: the cache code is\n  ancient (Elements 0.x era) and the fix exists only on `master`, `elements-23.x`,\n  `elements-23.3.x` branches (merged 2026-09-01..03). Verified:\n  `git tag --contains {c26d719c29,6253d7e103,212c43f475}` \u2192 no release tags.\n  Latest releases (`elements-23.3.3`, `23.3.4rc1`, `23.4.0rc3`, `29.4.1rc1`) predate it.\n- Any sidechain based on Elements with confidential assets enabled is consensus-affected\n  the same way; the bug is in shared consensus code, not Liquid-specific config.\n- **Severity: critical.** Remote, unauthenticated, splits the network deterministically\n  per-node-cache-state, and on Liquid enabled direct theft of federation BTC via peg-outs.\n\n\n### 5.1 Adjacent-code audit (2026-09-07): no other critical bugs found\n\nA targeted audit of the surrounding consensus code (master `c7e856fab1` and\n`c26d719c29~1`) found **no additional unfixed vulnerability of the same class**:\n\n- **Only two proof caches exist** (rangeproof, surjection). The surjection cache key\n  was always safe (wtxid-bound, see \u00a72.3). The ECDSA/Schnorr `SignatureCache` uses\n  upstream Bitcoin keying \u2014 sighash commits to all relevant context.\n- **Single priming vector confirmed:** `VerifyAmounts` is called only from\n  `Consensus::CheckTxInputs` (tx_verify.cpp:250) \u2014 mempool acceptance\n  (`cacheStore=true`) and `ConnectBlock` (`cacheStore=false`). `testmempoolaccept`\n  would also store cache entries but requires RPC auth; `CTxMemPool::check` is a\n  local sanity routine. No block-relay or orphan path stores entries.\n- **`HasValidFee`** (confidential_validation.cpp:33): per-fee-output `fee &gt; 0` and\n  `MoneyRange`, plus cumulative per-asset `MoneyRange` \u2014 no overflow possible\n  (output count is weight-bounded). Note: consensus does **not** restrict the fee\n  *asset* (fees may be paid in any asset; only mempool policy requires the policy\n  asset). By design, but worth documenting.\n- **Explicit values:** `CheckTransaction` enforces per-output `&lt; MAX_MONEY` *and* a\n  cumulative `MoneyRange` over all explicit outputs (CVE-2010-5139 lineage).\n  Explicit issuance amounts must be `&gt; 0`, and `MoneyRange` is enforced when the\n  issued asset is the pegged asset (`VerifyIssuanceAmount`). Confidential issuance\n  amounts use rangeproofs that went through the same (formerly buggy) checker \u2014\n  covered by the same fix.\n- **Consequence landmine (not a separate vuln, fixed by the same commit):** the\n  erase-on-read semantics (`Get(entry, /*erase=*/!store)`) mean a node that accepted\n  the invalid block *consumed* its cache entry. On reorg disconnect/reconnect, or on\n  the startup `-checkblocks` re-validation of recent blocks (which re-runs\n  `ConnectBlock`), such a node **fails to re-validate the very block it accepted**\n  and errors out (forcing a reindex onto the honest chain). Restarting also wipes\n  the cache. This explains part of the observed post-attack network behavior and\n  means accepting-side infrastructure is fragile until patched and reindexed.\n\n---\n\n## 6. Open items / limitations\n\n1. **Priming transaction not (yet) located.** It must have been *accepted to mempools*\n   (only mempool acceptance stores cache entries) and may never have been mined.\n   A completed scan of the **1,500 blocks (~25 h) preceding the fork** on the valid\n   chain (`scan_back.py`, 1,500 blocks saved to `backscan/`, `DONE found=[]`) found\n   **no** output/issuance carrying `C1` and no occurrence of the blob asset bytes\n   anywhere except the 4050335 marker tx. This strongly supports mempool-only\n   priming; if so, only node operators' mempool acceptance logs / `debug.log` can\n   identify the priming transaction.\n2. Which exact software the accepting functionaries/explorers run (they must be both\n   unpatched *and* have been primed; patched branch builds reject the block).\n3. Whether `V2` was ever broadcast on the P2P network or only seen by one explorer\n   backend; its purpose is inferred (first-generation design, killed by `HasValidFee`).\n4. mempool.space's Liquid backend was unreachable during analysis; its fork position\n   could not be re-confirmed today.\n5. The cryptographic demonstration is one-sided: `P1`/`C1` is *proven* invalid under\n   its on-chain context and 20+ plausible alternates; the *valid* priming context is\n   inferred from the observed acceptance, not reproduced, pending item 1.\n\n---\n\n## 7. Recommendations\n\n1. **Emergency release** of all maintained branches with `c26d719c29` (and the 23.x /\n   23.3.x cherry-picks), with clear upgrade-now language; until deployed, the network\n   remains one primed-cache away from another split (the attack is repeatable with fresh\n   `(P, C)` pairs).\n2. **Node operators:** restarting clears the primed cache but does not by itself resolve\n   the split; accepting-side nodes must `invalidateblock e1d9a2aa\u2026` (and everything on\n   top) after upgrading to rejoin the valid chain.\n3. **Functionaries / watchmen:** halt peg-out processing while the network is split;\n   the 14:25 payout shows watchmen maturing peg-outs on a chain that honest unprimed\n   nodes reject. Consider requiring N-of-M oracle agreement on best chain before\n   signing withdrawals.\n4. **Defense in depth (code):**\n   - The chosen fix (context in key) is correct; add a consensus regression test:\n     same `(proof, commitment)` under a different asset and script must fail.\n   - `CachingSurjectionProofChecker` was audited and is safe (key includes the wtxid,\n     which commits to all generator context); a dedicated regression test is still\n     worthwhile to lock that property in.\n   - Consider skipping the rangeproof-result cache during block validation entirely\n     (verification is cheap relative to Liquid's 1-minute blocks).\n   - Consider `MoneyRange`-style caps for *peg-out* OP_RETURN explicit values\n     (per-output and per-block) \u2014 that alone would have stopped the 3,996 BTC output\n     even with the cache bypassed.\n5. **Disclosure process:** the fix was public, clearly titled, and easily diffable for\n   3\u20135 days pre-attack (\"Fix caching bug in rangeproof caching\") \u2014 effectively a\n   published exploit recipe. For consensus-critical crypto/cache fixes, consider\n   embargoed or low-visibility landing (merge at release time), as practiced for\n   Bitcoin Core CVEs.\n", "creation_timestamp": "2026-09-07T00:00:43.792840Z"}, {"uuid": "3f9a9201-42f4-4b45-8ecd-3aba1286f785", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2010-5139", "type": "seen", "source": "https://gist.github.com/farseer285/9b8bf6450945bcb6de9116ff15ec9105", "content": "# Elements rangeproof cache consensus failure \u2014 root cause analysis of the 2026-09-06 Liquid network split and 3,998.67 BTC peg-out theft\n\n---\n\n## 1. Executive summary\n\nOn 2026-09-06 at ~12:40 UTC the Liquid Network suffered a consensus split caused by\ntransaction `f24a4b17\u2026183f` (mined in block 4050336, hash `e1d9a2aa\u2026`) whose output #1\ncarries a rangeproof that is **cryptographically invalid** under its own (asset, script)\ncontext. Part of the network accepted the block anyway; part rejected it and has not\naccepted any block since. Within ~9 minutes the attacker laundered the created value into\ntwo `sendtomainchain` peg-out outputs totalling **3,998.67 BTC**, and at 14:25 UTC the\nLiquid functionaries \u2014 following the accepting side of the fork \u2014 paid both peg-outs from\nthe federation's Bitcoin wallet in batched mainnet tx `8db751a6\u2026b140`. The attacker moved\nthe funds onwards within minutes.\n\nThe acceptance of a tx with an invalid rangeproof is explained by a **consensus bug in\nElements' rangeproof memoization cache** (`CachingRangeProofChecker`, `src/script/sigcache.cpp`)\n\u2014 but there are **two** bugs in the same cache key, and the one exploited is not the\none that was being fixed:\n\n- **Bug A \u2014 context omission (2019-03-19 \u2192 every release \u2264 `elements-23.3.3`):** the\n  cache key is derived only from the rangeproof bytes and the value commitment,\n  omitting the **asset generator** and the **scriptPubKey** (both authenticated by\n  `secp256k1_rangeproof_verify` as the generator argument and the `extra_commit`\n  entropy input). A proof once verified in one (asset, script) context is thereafter\n  treated as valid for *any* context on that node. Real, critical, shipped in 99\n  release tags \u2014 **but not the exploited mechanism** (\u00a72.4).\n- **Bug B \u2014 ambiguous key encoding (introduced by the fix itself):** the fix\n  (\"Fix caching bug in rangeproof caching\", `c26d719c29`, PR #1561 \u2014 authored\n  **2026-08-03** per its git author date) computes the key as a salted hash over the\n  **raw concatenation** `proof \u2016 commitment \u2016 asset-generator \u2016 scriptPubKey` with\n  **no length delimiters**. The proof and the script are both variable-length and sit\n  at opposite ends of the stream, so the field boundary between them can be shifted:\n  distinct `(proof, commitment, asset, script)` tuples hash to **byte-identical**\n  streams. This is the exploited bug \u2014 reproduced byte-for-byte from the on-chain\n  data in \u00a72.4.\n\nThe fork-side attribution is therefore the *reverse* of the obvious reading: **the\naccepting side \u2014 including the signing functionaries and Blockstream's own\ninfrastructure \u2014 was running the raced, unreleased fixed code** (23.3.4rc2-era builds)\nwith attacker-primed caches, while **the rejecting side ran pre-fix releases** whose\nkeying cannot collide for the attack tuple (\u00a72.4) and which therefore ran the real \u2014\nfailing \u2014 verification.\n\nTimeline: the fix was merged to `master` on **2026-09-01**, cherry-picked to\n`elements-23.x` (`6253d7e103`, 09-02) and prepared for `elements-23.3.x` as\n`212c43f475` (09-03; backport PR #1599 opened 09-04 \"in preparation for 23.3.4rc2\") \u2014\n**2\u20135 days before the attack** \u2014 and deployed to the functionaries ahead of any\nrelease. At the time of the attack (and still today) **no release tag contains the\nfix** (verified: `git tag --contains` for all three commits is empty; latest tags\n`elements-23.3.3`, `elements-23.4.0rc3`, `elements-29.4.1rc1` all predate it). The\n23.3.x backport merged attack-day evening (`3b3f01eac9`, 19:20 +0200; \u00a75.2). The\nattacker reverse-engineered the public fix diff, found the boundary ambiguity the\nfix had introduced, dry-ran the full mechanism on the functionaries' own mempools\n~10 minutes before the attack (\u00a74.6), and exploited it at ~12:40 UTC. **The deployed\nfix does not close the exploited hole**: Bug B is unfixed in every branch as of this\nwriting (no delimiting follow-up exists), so any node running the current patch \u2014\nincluding the functionaries \u2014 is exploitable again today by re-priming (\u00a76, \u00a77).\n\n---\n\n## 2. Root cause: one cache, two key bugs\n\n### 2.1 Bug A \u2014 context omission (pre-fix code, `c26d719c29~1:src/script/sigcache.cpp`)\n\n```cpp\nbool CachingRangeProofChecker::VerifyRangeProof(\n    const std::vector&amp; vchRangeProof,\n    const std::vector&amp; vchValueCommitment,\n    const std::vector&amp; vchAssetCommitment,   // &lt;-- NOT in the key\n    const CScript&amp; scriptPubKey,                             // &lt;-- NOT in the key\n    const secp256k1_context* ...) const\n{\n    uint256 entry;\n    rangeProofCache.ComputeEntryRangeProof(entry, vchRangeProof, vchValueCommitment);\n\n    if (rangeProofCache.Get(entry, !store)) {\n        return true;                       // cache hit: ACCEPT without any verification\n    }\n    ...\n    secp256k1_generator tag;\n    secp256k1_generator_parse(..., &amp;tag, &amp;vchAssetCommitment[0]);\n    if (!secp256k1_rangeproof_verify(..., &amp;commit,\n            vchRangeProof.data(), vchRangeProof.size(),\n            scriptPubKey.size() ? &amp;scriptPubKey.front() : nullptr,\n            scriptPubKey.size(), &amp;tag)) {\n        return false;                      // real verification on miss\n    }\n    ...\n    if (store) {\n        rangeProofCache.Set(entry);        // only successes are cached\n    }\n    return true;\n}\n```\n\n- `ComputeEntryRangeProof(entry, proof, value_commitment)` \u2014 salted SHA-256 over\n  **proof + value commitment only**.\n- `secp256k1_rangeproof_verify` binds the proof to three things: the **commitment**,\n  the **generator** (asset), and the **extra commitment** (the output's scriptPubKey,\n  hashed into the proof's message). Two of the three are missing from the cache key.\n- `Set` is called only on successful verification (no negative caching), and\n  `Get(entry, /*erase=*/!store)` removes the entry on read during block validation\n  (`store == false`) but keeps it during mempool acceptance (`store == true`).\n- The cache is a per-process in-memory `CuckooCache` (`SignatureCache`), salted per\n  process: each node must be primed individually; entries do not survive restarts.\n\n\n### 2.2 The raced fix \u2014 and Bug B, the ambiguous key encoding it shipped\n\nThe fix (`c26d719c29` on `master`, 09-01; `6253d7e103` on `elements-23.x`, 09-02;\n`212c43f475` on `elements-23.3.x`, 09-03) threads the asset and script into the key \u2014\na few lines in `src/script/sigcache.{cpp,h}`, touching only the rangeproof cache.\nVerbatim post-fix key derivation:\n\n```cpp\nvoid ComputeEntryRangeProof(uint256&amp; entry, ..., const std::vector&amp; proof,\n    const std::vector&amp; commitment,\n    const std::vector&amp; asset_commitment, const CScript&amp; scriptPubKey) {\n    CSHA256 hasher = m_salted_hasher_range_proof;                 // per-process salt\n    hasher.Write(proof.data(), proof.size())                      // VARIABLE length\n          .Write(commitment.data(), commitment.size())            // fixed 33 B\n          .Write(asset_commitment.data(), asset_commitment.size())// fixed 33 B\n          .Write(scriptPubKey.data(), scriptPubKey.size())        // VARIABLE length\n          .Finalize(entry.begin());\n}\n```\n\nWhat actually enters the third field (`src/confidential_validation.cpp:381-388`): for\noutputs with an **explicit** asset, the caller runs\n`secp256k1_generator_generate(&amp;gen, assetID)` and passes the **33-byte serialized\ngenerator**; for confidential assets it passes the 33-byte asset commitment. Either\nway a fixed 33 bytes \u2014 but *which* 33 bytes is fully determined by the output's asset\nfield, which the attacker controls.\n\nTwo properties combine into Bug B:\n\n1. **No length delimiters.** The key input is the raw concatenation\n   `proof \u2016 commitment \u2016 generator \u2016 script`; any partition of the same byte stream\n   into these four fields yields the same key.\n2. **The two variable-length fields sit at the ends.** The proof leads, the script\n   trails, and the script can be anything \u2014 including a 69-byte OP_RETURN data push.\n   A `k`-byte shift of the proof/script boundary collides iff\n\n   ```\n   P1 = P0 \u2016 C0 \u2016 A0 \u2016 S0[:k]     C1 = (33 bytes at that offset inside S0)\n   A1 = A0                         S1 = S0[k:]\n   ```\n\n   i.e. the attacker **relocates existing bytes across the boundary** rather than\n   grinding any elliptic-curve values: the middle 66 bytes (`C \u2016 A`) are kept\n   byte-identical by *embedding the attack tuple's commitment `C1` and generator `X`\n   inside the primer's script*. The per-process salt does not help: primer and attack\n   keys are computed in the same process.\n\nThe surjection-proof cache was audited and is **not** affected: its key is\n`salted(wtxid \u2016 proof \u2016 output_generator)` (`CachingSurjectionProofChecker`,\nunchanged by the fix); the wtxid commits to every input/output asset commitment in\nthe transaction, and the variable-length proof is *interior* (fixed-width wtxid\nahead, fixed-width generator behind), so no end-to-end boundary shift exists.\n\n### 2.3 Cache-era semantics (why priming works, and when)\n\n- **Mempool:** `MemPoolAccept::PreChecks` \u2192 `Consensus::CheckTxInputs`\n  (`src/consensus/tx_verify.cpp:250`, called from `validation.cpp:1100` with\n  `cacheStore=true`): every successfully verified output rangeproof is **stored** and\n  survives even if the tx is later evicted from the mempool; reads are\n  `Get(entry, erase=false)` \u2014 hits do not consume.\n- **Block validation:** `ConnectBlock` \u2192 `CheckTxInputs` (`validation.cpp:3041`,\n  `fCacheResults = fJustCheck`, i.e. false when actually connecting): the cache is\n  read with `Get(entry, /*erase=*/true)` \u2014 **a hit is consumed** \u2014 and nothing new is\n  stored.\n\nConsequences: (i) only mempool acceptance primes; (ii) a primed entry survives until\na block-connect read erases it, so the primer must be **mempool-live in the same\ninter-block era** as the attack block's connection; (iii) after the accepting side\nconnected 4050336 the entry was consumed \u2014 those nodes cannot re-validate the very\nblock they accepted (reorg disconnect/reconnect, or startup `-checkblocks`\nre-validation, fails), which explains part of the observed post-attack network\nfragility; (iv) `testmempoolaccept` would also store entries but requires RPC auth;\nno block-relay or orphan path stores.\n\n### 2.4 The attacker's byte-level construction (reproduced from on-chain data)\n\nAll four alignment identities hold **byte-exactly** (`collision_test.py`, 2026-09-07):\n\n```\nX  = 0a \u2016 0a488de4899d0ae757f6cf8368663184d164106111ed9eaecf510e35282ddc6d\n   = secp256k1_generator_generate(L-BTC asset id 6f0279e9\u2026526d), byte-exact \u2014\n     independently reproduced via the Shallue\u2013van de Woestijne map with the two\n     tagged-hash candidates (\"1st/2nd generation: \") \u2192 MATCH (gen_check.py)\nS0 = 6a 43 \u2016 C1 \u2016 X \u2016 6a            (69 B = OP_RETURN + push opcode 0x43 + 67 B payload)\nP1 = P0 \u2016 C0 \u2016 X \u2016 6a 43            (4,234 B = the 4,166 B dry-run proof + 68 B tail)\nS1 = 6a                             (1 B, bare OP_RETURN)\n```\n\n- **Primer tuple** `(P0, C0, X, S0)` \u2014 the explicit-L-BTC OP_RETURN output of the\n  dry-run txs (\u00a74.6): `P0` verifies **VALID** against `(C0, X, S0)`\n  (`prime_verify_test.py`: VALID, min/max `0/4503599627370495`), so ordinary\n  verification stores `K = salted-hash(P0\u2016C0\u2016X\u2016S0)` on every fixed-code node that\n  accepts the tx to its mempool.\n- **Attack tuple** `(P1, C1, X, S1)` \u2014 attack tx out1: the fixed-key input streams are\n  **byte-identical: 4,301 B, sha256 `82b0b8cc\u20269c01a` for both** (pre-salt) \u2192 cache hit,\n  verification of the invalid `P1` skipped (`prime_verify_test.py`: `(P1, C1, X, S1)`\n  \u2192 INVALID, as are both crossed contexts; the `min_value == 0 &amp;&amp; !IsUnspendable()`\n  check is likewise skipped \u2014 the bare-`6a` script is unspendable anyway).\n  **Keying subtlety:** the key's asset field is the expanded 33-byte *generator* `X`,\n  not the on-wire `01\u2016asset_id` (\u00a72.2); keyed naively on the on-wire serialization\n  the streams diverge (`collision_test.py` TEST 2b) \u2014 the attacker embedded exactly\n  the generator the code computes.\n- **Pre-fix keys cannot collide here:** `P0\u2016C0` (4,199 B) \u2260 `P1\u2016C1` (4,267 B) \u2014 Bug A\n  *cannot* accept this tuple. **The attack requires the fixed code.**\n- The 69-byte script is the *minimum* that hides the 68-byte pivot inside a single\n  push: 1 push-opcode byte + 67 payload bytes \u2265 2 + 33 + 33; the final `6a` carries the\n  69th byte across the boundary to become `S1`.\n- `P0` doubles as a parseable prefix for `P1` (same ring size; the 68-byte tail lands\n  in this proof format's trailing message/padding field) \u2014 belt-and-braces, since `P1`\n  is never parsed on the attack path at all.\n\n---\n\n## 3. The cryptographic primitive the bug exposes\n\nA Pedersen value commitment `C = v\u00b7G + r\u00b7H` binds a value `v` to a specific asset\ngenerator `G`; a rangeproof `P` for `(C, G, S)` additionally binds the output's\nscriptPubKey `S` as message entropy. The attack tx's out1 commits (under the L-BTC\ngenerator `X`) to a huge **negative** value \u2014 a rangeproof for it cannot exist. The\ncache bug lets the attacker substitute the *verification result* of a genuinely valid\nprimer output `(P0, C0, X, S0)` for the forged one `(P1, C1, X, S1)`: under the fixed\nkey the two tuples hash identically (\u00a72.4), so the second verification is never run.\nThe generator is the *same* in both contexts \u2014 no commitment/generator confusion is\nrequired; the ambiguity lives purely in the cache-key encoding.\n\nThis turns a per-node performance cache into a network-splitting consensus oracle:\n**primed nodes accept, unprimed nodes reject, and the difference is invisible in the\nblock data itself.**\n\n---\n\n## 4. The attack as observed on-chain\n\n### 4.1 Actors / artifacts\n\n| Item | Value |\n|---|---|\n| Crafted commitment `C1` | `086f5d67160fc4b477954fb09ef321e5b589d7a07740a1a6df494ed2335b1d01d8` |\n| Crafted rangeproof `P1` | sha256 prefix `6619fa29\u2026`, 4,234 bytes (= `P0 \u2016 C0 \u2016 X \u2016 6a 43`; \u00a72.4) |\n| Attack tx (mined) `V1` | `f24a4b179b5cc7e88b25a763911f7cbdf2bf45d1d1b5ab611e94461cef0a183f` @ block 4050336 |\n| Double-spend variant `V2` | txid `a1669379f6204f066320974effeefcad2d758fa8ee408c35ae07bc8580c4abe9` (raw bytes recovered from the network; never mined) |\n| Priming dry-run pair | `71c93d43\u2026f411` and `27114710\u20267ec5` @ 4050335; identical explicit-L-BTC OP_RETURN outputs `(P0, C0, X, S0)`, `S0 = 6a 43 \u2016 C1 \u2016 X \u2016 6a`, `X` = L-BTC generator serialization (\u00a72.4) |\n| Laundering tx | `46f117c9\u2026` @ 4050344 (peg-out 2.65138358 BTC) |\n| Peg-out tx | `ce4caece413cd9d444ce7ed9f54e5b328b3da5e4af301aff59a3571f76e988f2` @ 4050349 (peg-out 3,996.01834922 BTC) |\n| Federation mainnet payout | `8db751a650ae2f12006b7e8c69a75e4df360e8afd6b9e05ae0b9fa6458a7b140`, first seen 2026-09-06 14:25:13 UTC, confirmed BTC block 965783 (14:28:56 UTC), 83 in / 13 out, 4,019.44 BTC total \u2014 **verified via blockstream.info 2026-09-07**: out0 pays `bc1qgsl\u2026wt7p` exactly 3,996.01834922 BTC, out1 pays `bc1qkxwv\u2026h98my` exactly 2.65138358 BTC (both attack destinations, exact peg-out amounts); no OP_RETURN output |\n| Attacker BTC destinations | `bc1qkxwva32eh7mgezq5kladncd3n5wtcjmslh98my` (2.65) / `bc1qgslsydz56d0ed6827hdemfmk5w2f6ldyc6wt7p` (3,996.02) \u2014 both received the exact peg-out amounts and were emptied within ~20 min |\n\n### 4.2 Timeline (UTC)\n\n- **08-03** fix authored (`c26d719c29`)\n- **08-03** fix commit authored (`c26d719c29` git author date) \u2014 internal knowledge ~4 weeks pre-attack; **09-01** merged to `master`; **09-02** cherry-picked to `elements-23.x` (`6253d7e103`); **09-03/09-04** the `elements-23.3.x` cherry-pick (`212c43f475`) and its backport PR #1599 become public. **No release ever tags the fix.**\n- **09-06 ~12:30\u201312:39** blocks 4050334\u20134050335 (valid on both future sides). `71c93d43\u2026f411` and `27114710\u20267ec5` in 4050335 carry *identical* explicit-L-BTC OP_RETURN outputs `(P0, C0, X, S0)` \u2014 the primer tuple (\u00a72.4): the first copy stores cache entry `K` on every fixed-code mempool that verifies it, and the duplicate reads `K` back \u2014 a live end-to-end dry run of the cache-hit path on the functionaries' own nodes, minutes before the attack.\n- **~12:40** block 4050336 `e1d9a2aa\u2026` mined with `f24a4b17\u2026183f` (`V1`). **Fork.** Acceptance by the signing functionaries identifies their builds as **unreleased fixed code with primed caches** \u2014 pre-fix code cannot accept this tuple (\u00a72.4, \u00a74.5).\n- **~12:44** `46f117c9\u2026` @4050344: 2.65138358 BTC explicit `sendtomainchain` OP_RETURN.\n- **~12:49** `ce4caece\u2026f2` @4050349: 3,996.01834922 BTC explicit `sendtomainchain` OP_RETURN.\n- **~13:11** invalid side still growing: `0b4505c7\u2026` @4050367 (header nTime 14:24:10Z), ~31 blocks past the fork \u2014 independent node log (\u00a74.5, \u00a78).\n- **14:25:13** functionaries' batched withdrawal `8db751a6\u2026b140` first seen on Bitcoin mainnet (confirmed block 965783, 14:28:56 UTC) \u2014 pays both destinations (consistent with the ~102-block peg-out maturity counted on the *accepting* chain).\n- **14:25:49 / 14:41:35** attacker forwards the proceeds (`85d2ca15\u2026`, `22c6afb0\u2026`); the 3,996 BTC is split 3,995.99999857 + 0.01834922 to fresh addresses.\n- **09-07** accepting chain (blockstream.info) at 4050420+; rejecting side (liquid.network's backend) still stuck at tip 4050335 \u2248 26 h later. mempool.space's Liquid instance diverged/unreachable during the incident.\n\n*Note on times:* the timeline above is wall clock; Liquid header nTimes run ~73 min\n**ahead** of wall clock (signer clock skew, within the +2 h consensus bound \u2014 an\nindependent node log records headers 4050335 = 13:52:10Z, 4050336 = 13:53:10Z,\n4050367 = 14:24:10Z). The wall-clock anchor is the federation's Bitcoin payout at\n14:25:13Z, which requires the peg-outs to be ~100+ accepting-side blocks deep,\nplacing the real attack time at \u2248 12:40Z.\n\n### 4.3 Anatomy of `V1` (f24a4b17\u2026183f), from raw bytes\n\n| out | asset | value | scriptPubKey | proof |\n|---|---|---|---|---|\n| 0 | explicit L-BTC | committed `08360f95\u2026` (the **huge positive** \u2248 4.18e18 sats; valid proof) | p2wpkh `f590\u2026` (attacker) | 4,174 B, VALID |\n| 1 | explicit L-BTC | committed **`086f5d67\u2026` = C1** (the **crafted negative** \u2248 \u22124.18e18) | **`6a` (OP_RETURN)** | 4,234 B, **INVALID** \u2014 never verified on the attack path (cache hit, \u00a72.4) |\n| 2 | committed asset `0b0957be4c\u2026` | committed `08dc50cf\u2026` | p2wpkh `f590\u2026` | 4,174 B, VALID |\n| 3 | explicit L-BTC | **58 sats** | empty (fee) | \u2013 |\n\nBalance: input `0fbde521:2` (legit, pre-fork) vs `C_out0 + C_out1 + C_out2 + 58\u00b7H_L`.\n`C_out0` commits to a huge **in-range** positive value \u2014 its rangeproof is honestly\ngenerated and *valid* (rangeproofs prove `0 \u2264 v &lt; 2^64`, nothing about inputs). `C1`\ncommits to the corresponding huge **negative** value \u2014 its proof cannot exist, and it is\nthe one that rides the cache. The invalid output is deliberately `OP_RETURN`\n(unspendable), which also exempts it from the `min_value == 0 &amp;&amp; !IsUnspendable()`\nrule; the positive side of the forgery sits in the spendable, validly-proven out0.\n\n`V2` (unmined variant `a1669379\u2026`) is byte-identical except the fee output carries\n**4,179,340,454,199,820,288 sats = `0x3a00000000000000`** instead of out0 hiding the\nvalue. `V2` is unmineable on *any* version: `HasValidFee` (`MoneyRange(fee)`,\n`MAX_MONEY = 21e6 BTC`) has rejected such fees since 2016 \u2014 which is presumably why the\nattacker switched to the `V1` design (value hidden in a *confidential* output, where no\nexplicit-value range check applies). The reused magnitude `0x3a00000000000000` matches\nthe crafted negative commitment in both variants.\n\nEmpirical verification (local libsecp256k1-zkp via ctypes): `P1`/`C1` verifies\n**INVALID** under its true context (L-BTC generator `X`, script `6a`) and 20+ plausible\nalternates \u2014 and it is never verified on the attack path at all: the priming side is\nnow **fully demonstrated**. `P0` verifies **VALID** against `(C0, X, S0)`; the primer\nand attack fixed-key input streams are byte-identical (4,301 B, sha256\n`82b0b8cc\u20269c01a`; `collision_test.py`, \u00a72.4) while the pre-fix keys differ. The live\nprimer never needed to be mined: it only had to be mempool-live on the accepting\nnodes in the ~60 s era before 4050336 connected (\u00a72.3, \u00a74.5, \u00a76).\n\n\n### 4.4 Why `HasValidFee` / `MoneyRange` don't save the network\n\n- The fee check only constrains *explicit fee outputs*; `V1`'s fee is 58 sats.\n- `CheckTransaction`'s per-output `MoneyRange` applies to *explicit* values; the forged\n  amounts live entirely inside *commitments*, which no sanity check can range-check \u2014\n  that is precisely the rangeproof's job, and it was cache-bypassed.\n- The huge explicit values only appear *after* laundering, in `sendtomainchain`\n  OP_RETURN peg-out outputs: 2.65 BTC and 3,996 BTC \u2014 both `&lt; MAX_MONEY`, so even those\n  pass.\n\n### 4.5 Node-level behavior matrix (why the split persisted)\n\n| node software / state | on `V1` in mempool | on block 4050336 |\n|---|---|---|\n| pre-fix release (\u2264 23.3.3), any cache state | reject (real verify fails \u2014 pre-fix keys provably differ, \u00a72.4) | **reject block** |\n| fixed code (post-`c26d719c29` build), cold cache | reject (miss \u2192 real verify fails) | **reject block** |\n| fixed code, primer entry `K` live in cache | accept (hit, no erase) | **accept** (hit, entry erased) |\n\nThe fork sides therefore identify the running software *inversely* to the first-reading\nassumption: the **accepting** side (the signing functionaries, blockstream.info's\nbackend) ran **unreleased fixed code with primed caches**; the **rejecting** side is\nevery node without the live entry \u2014 all pre-fix releases, plus any fixed build that\nnever saw the primer. Rejecting nodes never see a valid heavier chain (the\nfunctionaries kept building on the invalid chain \u2014 their own nodes were primed), so\ntheir tip freezes at 4050335: exactly what liquid.network's backend shows a day later.\n\nEra mechanics (\u00a72.3): the dry-run entries from 4050335 were erased when that block\nconnected, so the attack required a live primer in the accepting mempools in the\n~60 s before 4050336 connected. The other five non-coinbase transactions in 4050336\nwere fetched from the accepting-side explorer and parsed (`09016269\u2026`, `c652a104\u2026`,\n`efa5e6e6\u2026`, `5707d0ce\u2026`, `2817e839\u2026`): **none carries the primer tuple** \u2014 the live\nprimer was never mined and is identifiable only in accepting nodes' mempool\nacceptance logs. On pre-fix nodes the same primer is a harmless, valid tx.\n\nConsistent confirmation of this partition: a published node log from a fresh-sync\nnode running `elements-23.3.4rc1` (tagged 2026-07-01 \u2014 **pre-fix code**) shows\n`ConnectTip: ConnectBlock e1d9a2aa\u2026 failed, block-validation-failed` at height\n4050336, its tip frozen at `aad24e4f\u2026`@4050335, and the invalid side observed at\n`0b4505c7\u2026`@4050367 \u2014 \"~6 blocks longer than our best chain\" (artifact\n`coldsync_2334rc1_rejection.png`; the log's `date=` fields are header nTimes, which\nrun ~73 min ahead of wall clock \u2014 see \u00a74.2 note). Under the corrected mechanism this\nis the *required* behavior of pre-fix code; a cold-synced fixed build would reject\nidentically (no live entry). Acceptance is a property of (fixed code \u2227 live primed\nentry) \u2014 cf. \u00a75.2 item 8.\n\n### 4.6 Pre-fork dry run\n\n`71c93d43\u2026f411` and `27114710\u20267ec5` (both @4050335) contain *identical* explicit-L-BTC\nOP_RETURN outputs: same 4,166-byte proof `P0`, same commitment `C0` = `09d6c615\u202683f5`,\nexplicit L-BTC asset (\u2192 generator `X` in the fixed key), same 69-byte script\n`S0 = 6a 43 \u2016 C1 \u2016 X \u2016 6a`. The output genuinely verifies (VALID, min=0, max=2^52\u22121,\nre-verified 2026-09-07) \u2014 so on fixed-code nodes the first copy **stores** cache entry\n`K`, and the duplicate in the sibling tx **reads `K` back** (identical key, no\nre-verification): a live end-to-end dry run of the cache-hit path on the\nfunctionaries' own mempools, in the last block before the attack. The \"34-byte blob\"\nthat resisted decoding in the first analysis is now fully identified: it is `X \u2016 6a` \u2014\nthe **L-BTC generator serialization** (byte-exact reproduction via the\nShallue\u2013van de Woestijne map, \u00a72.4) plus the script's final opcode \u2014 planted as inert\nscript data so it re-enters the key stream at exactly the offsets where the attack\ntuple places its commitment and generator fields. Byte forensics (`prooflen_test.py`,\n`collision_test.py`): the attack's crafted proof is exactly `P0 \u2016 C0 \u2016 X \u2016 6a 43`\n(\u00a72.4). Liquid uses the original Borromean-style CT rangeproofs (kilobyte-scale; size\nvaries with encoding parameters and message), so the 4,166/4,174/4,234-byte sizes are\nall legitimate, and verification is exact-length \u2014 truncating or extending `P0` by\neven one byte makes it INVALID (relevant to the dry run's own validity; `P1` is never\nparsed on the attack path).\n\n---\n\n## 5. Affected versions\n\n- **Every released Elements version carries Bug A** (context-omitting key, 2019 \u2192\n  `elements-23.3.3`): the fix exists only on the `master`, `elements-23.x`,\n  `elements-23.3.x` branches (merged 2026-09-01..03). Verified:\n  `git tag --contains {c26d719c29,6253d7e103,212c43f475}` \u2192 no release tags.\n  Latest releases (`elements-23.3.3`, `23.3.4rc1`, `23.4.0rc3`, `29.4.1rc1`) predate it.\n- **Every build of the fix carries Bug B** (ambiguous key encoding, \u00a72.2) \u2014 the bug\n  actually exploited \u2014 and **no branch contains a delimiting follow-up** (verified\n  2026-09-07: `git log --all -- src/script/sigcache.cpp` shows the three fix variants\n  as the only changes since 2024; the ambiguous raw-concat key is present in all).\n  Any node running the patch \u2014 including the functionaries' 23.3.4rc2-era builds \u2014 is\n  exploitable again today by re-priming.\n- Any sidechain based on Elements with confidential assets enabled is consensus-affected\n  the same way; both bugs are in shared consensus code, not Liquid-specific config.\n- **Severity: critical (\u00d72).** Remote, unauthenticated, splits the network\n  deterministically per-node-cache-state, and on Liquid enabled direct theft of\n  federation BTC via peg-outs.\n\n\n### 5.1 Adjacent-code audit (2026-09-07): no other critical bugs found\n\nA targeted audit of the surrounding consensus code (master `c7e856fab1` and\n`c26d719c29~1`) found **no additional unfixed vulnerability of the same class**.\n(Candidly: this audit compared pre- and post-fix code for additional *distinct* issues\nand did not itself spot the key-encoding ambiguity the fix introduced \u2014 Bug B, \u00a72.2 \u2014\nwhich was identified by an independent researcher; see \u00a75.2 items 7\u20138.):\n\n- **Only two proof caches exist** (rangeproof, surjection). The surjection cache key\n  was always safe (wtxid-bound, see \u00a72.2). The ECDSA/Schnorr `SignatureCache` uses\n  upstream Bitcoin keying \u2014 sighash commits to all relevant context.\n- **Single priming vector confirmed:** `VerifyAmounts` is called only from\n  `Consensus::CheckTxInputs` (tx_verify.cpp:250) \u2014 mempool acceptance\n  (`cacheStore=true`) and `ConnectBlock` (`cacheStore=false`). `testmempoolaccept`\n  would also store cache entries but requires RPC auth; `CTxMemPool::check` is a\n  local sanity routine. No block-relay or orphan path stores entries.\n- **`HasValidFee`** (confidential_validation.cpp:33): per-fee-output `fee &gt; 0` and\n  `MoneyRange`, plus cumulative per-asset `MoneyRange` \u2014 no overflow possible\n  (output count is weight-bounded). Note: consensus does **not** restrict the fee\n  *asset* (fees may be paid in any asset; only mempool policy requires the policy\n  asset). By design, but worth documenting.\n- **Explicit values:** `CheckTransaction` enforces per-output `&lt; MAX_MONEY` *and* a\n  cumulative `MoneyRange` over all explicit outputs (CVE-2010-5139 lineage).\n  Explicit issuance amounts must be `&gt; 0`, and `MoneyRange` is enforced when the\n  issued asset is the pegged asset (`VerifyIssuanceAmount`). Confidential issuance\n  amounts use rangeproofs that went through the same (formerly buggy) checker \u2014\n  covered by the same fix.\n- **Consequence landmine (not a separate vuln, and *not* addressed by the fix):** the\n  erase-on-read semantics (`Get(entry, /*erase=*/!store)`) mean a node that accepted\n  the invalid block *consumed* its cache entry. On reorg disconnect/reconnect, or on\n  the startup `-checkblocks` re-validation of recent blocks (which re-runs\n  `ConnectBlock`), such a node **fails to re-validate the very block it accepted**\n  and errors out (forcing a reindex onto the honest chain). Restarting also wipes\n  the cache. This explains part of the observed post-attack network behavior and\n  means accepting-side infrastructure is fragile until patched and reindexed.\n\n### 5.2 Adjudication of alternative root-cause theories (rev. 2, 2026-09-07)\n\nBecause the completeness of `c26d719c29` determines which nodes were (and remain)\nexploitable, every candidate residual gap and every public alternative theory was\ntested. Rev. 2 corrects two verdicts of the first revision: the concatenation-\nambiguity mechanism (item 7) is **exploitable**, and the third-party \"the fix\nintroduced the exploited bug\" claim (item 8) is **confirmed**; both corrections\nfollow from the byte-level reproduction in \u00a72.4.\n\n1. **Degenerate key component from the caller?** No. For outputs with *explicit* assets,\n   `VerifyAmounts` serializes a generator derived from the asset ID\n   (`confidential_validation.cpp:380-384`); for committed assets it passes the parsed\n   generator bytes. The new key component is unique per asset in both cases, so the\n   post-fix key is context-complete for tx outputs (and for issuances, which pass the\n   serialized generator and an empty script \u2014 also the complete verification context).\n2. **Partial cherry-picks?** No. The `elements-23.x` (`6253d7e103`) and\n   `elements-23.3.x` (`212c43f475`) cherry-picks change exactly the same lines as master.\n3. **Surjection cache still context-confusable?** No. It has been keyed on\n   `salted(wtxid \u2016 proof \u2016 output_generator)` since 2018 (`4815bc62cd`), and that code\n   is present in `elements-23.3.3`; the wtxid transitively binds the input-tag set.\n4. **Deployed libsecp256k1-zkp verifies the crafted proof differently?** No. The tree\n   pinned by `elements-23.3.3` (`443b7094\u2026`) differs from master's (`d0854d8b\u2026`) inside\n   the rangeproof module, but the diff is a behavior-preserving refactor (`hash_ctx`\n   threading, serialization helpers, memclear hardening). The 23.3.3-pinned library was\n   built from the vendored tree (`/tmp/secp2333/`) and the verification harness re-run\n   (`verify_2333lib.py`): `P1`/`C1` under (`H_L`, `6a`) is still **INVALID**, and all\n   legitimate control proofs in the same txs verify VALID \u2014 byte-identical conclusions\n   to the master build.\n5. **Priming a patched node through an identical key?** *Possible* \u2014 this is exactly\n   Bug B. Post-fix, equal cache keys no longer imply equal verification contexts,\n   because the key encoding is ambiguous (item 7): the attacker's two tuples have\n   different contexts but identical key byte streams (\u00a72.4). What remains true: a\n   restart wipes the cache (per-process salt), so a primed entry cannot survive a\n   restart; and priming requires mempool acceptance in the same inter-block era\n   (\u00a72.3).\n6. **The August 2026 secp256k1-zkp update is the \"real\" fix / the split cause?** No.\n   The subtree bump `95b983597a..a2b001cc20` (merged 2026-08-14, `9bc77876a3`;\n   deployed to 23.3.x/23.x/29.x on 2026-08-21 via #1585/#1586/#1587) hardens\n   surjection-proof *generation* against s-value (nonce) reuse \u2014 its own code\n   comments state the goal is preventing distinct proof inputs from reusing any\n   s-value \u2014 plus memclear/serialization hardening and a module sync. Verification\n   code is untouched except behavior-preserving `hash_ctx` threading\n   (`surjectionproof_verify` and `rangeproof_verify` both merely pass the context's\n   hash state into the unchanged message-generation and shared Borromean-verify\n   path). Master's vendored tree is byte-identical to `a2b001cc20`\n   (`git diff 644c14d263 master -- src/secp256k1` = \u2205), so the deployed-lib\n   equivalence test (item 4) already covers this update end-to-end: the attack\n   proof stays INVALID and every control proof VALID. Generation-side nonce\n   hardening cannot mint value and cannot split consensus.\n\n7. **Cache-key concatenation ambiguity via field-boundary shift?** **Confirmed \u2014\n   this is the exploited mechanism.** Rev. 1 of this report dismissed it (\"the fields\n   adjacent to the variable-length proof are fixed at 33 bytes \u2026 pinning the split\n   point\"), which was wrong: it considered only the proof\u2194commitment boundary. The\n   fixed key is `proof \u2016 commitment(33) \u2016 generator(33) \u2016 script`, and the *second*\n   variable-length field \u2014 the scriptPubKey \u2014 sits at the opposite end, so the\n   proof\u2194script boundary is **not** pinned: shifting `k` bytes from the script's head\n   to the proof's tail preserves the entire byte stream, while the two fixed middle\n   fields are kept byte-identical by embedding the attack's `C1` and the generator\n   `X` inside the primer's script (\u00a72.2, \u00a72.4). No elliptic-curve grinding is\n   required \u2014 the construction relocates existing bytes, so rev. 1's objection that\n   \"commitments/generators cannot be ground to forced byte patterns\" is moot, and the\n   per-process salt is irrelevant (both keys are computed in the victim's process).\n   Rev. 1's empirics remain true but inapplicable: verification is indeed\n   exact-length (`prooflen_test.py`), but the colliding proof `P1` is never verified\n   on the attack path; and \"the attack pair shares no key stream with the dry-run\n   pair\" measured the *pre-fix* streams \u2014 under the fixed key the streams are\n   byte-identical (`collision_test.py`: 4,301 B both, sha256 `82b0b8cc\u20269c01a`).\n8. **Did `c26d719c29` introduce the exploited vulnerability? (independent\n   researcher's claim, 2026-09-07)** **Yes \u2014 confirmed and reproduced.** The\n   third-party analysis argued: the *fixed* key is a raw, undelimited concatenation\n   `proof\u2016commitment\u2016asset\u2016script`; the attacker stretched the proof and shrank the\n   script so the exploit tuple and a dry-run primer tuple hash to identical bytes;\n   therefore the accepting side ran the fixed code and the fork attribution is\n   inverted. Every element checks out byte-for-byte (\u00a72.4): the fixed-key streams\n   are identical (4,301 B, sha256 `82b0b8cc\u20269c01a`), the pre-fix keys are not, and\n   the planted generator `X` is byte-exactly `secp256k1_generator_generate(L-BTC)`\n   (`gen_check.py`). The attribution correction stands: the accepting side \u2014\n   functionaries included \u2014 ran unreleased fixed code with primed caches; the\n   rejecting side ran pre-fix releases or unprimed fixed builds. This report's\n   rev.-1 rebuttal (\"under the fixed key the primed entry is unreachable\") was\n   wrong because it assumed the attacker replayed the `(P, C)` pair verbatim; the\n   actual construction shifts the proof/script boundary, which rev.-1 item 7 had\n   dismissed. Two details of the third-party account need correction: (a) the\n   key's \"asset\" field is the 33-byte *generator serialization*\n   (`confidential_validation.cpp:381-388`), not the raw asset id \u2014 immaterial to\n   the mechanism, material to reproducing it; (b) an earlier claim from the same\n   source that release `23.3.3` *accepted* the attack block is inconsistent with\n   pre-fix code (the pre-fix keys provably differ and the real verification\n   fails, \u00a72.4/\u00a74.3) and is superseded by the same author's later fresh-sync\n   `23.3.4rc1` rejection log (\u00a74.5), which is exactly what the corrected\n   mechanism predicts. The pre-fix Bug A (context omission, `0b5066143d`,\n   2019-03-19, 99 release tags) remains real and unfixed in every release \u2014 but\n   it is not what was exploited on 2026-09-06.\n\nCorroborating timeline: the `elements-23.3.x` backport of exactly this fix was\nqueued **pre-attack** as PR #1599 (opened 09-04 by psgreco: \"All clean cherry picks\nfrom 23.x branch, in preparation for 23.3.4rc2\"; 12 cherry-picks, the cache fix\n`212c43f475` first; ACKs tomt1664 09-05, delta1 09-06) and merged attack-day evening\n(`3b3f01eac9`, 2026-09-06 19:20:55 +0200, merge-script) \u2014 the only attack-day commit\nactivity on public branches (as of 2026-09-07). The vendor's conduct is consistent\nwith having treated Bug A as a low-severity cache-correctness issue, having raced\nthe fix onto its own infrastructure ahead of release (dogfooding \u2014 precisely what\nmade the functionaries exploitable via Bug B), and not having recognized the\nkey-encoding ambiguity: post-attack the fix was neither reverted nor hardened, and\nas of this writing **no delimiting follow-up exists in any branch**\n(`git log --all -- src/script/sigcache.cpp`).\n\n**Residual operational exposure:** Bug A is live in every released version; Bug B is\nlive in every build of the fix; and the patch leaves the fragile cache-as-consensus\ndesign in place (erase-on-read footgun, \u00a72.3) rather than removing the rangeproof\ncache from the consensus path. Until a delimiting fix (or cache removal) ships, the\nnetwork is one primed mempool away from a repeat \u2014 against *whichever* keying the\nvictim runs. Rev. 1's \"residual gap\" caveat (accepting nodes possibly running fixed\nbuilds) is no longer hypothetical: it is the established mechanism (\u00a72.4), and the\nremaining unknowns are which exact builds the functionaries ran and what their\nmempool logs show (\u00a76).\n\n\n---\n\n## 6. Open items / limitations\n\n1. **Live priming transaction mempool-only \u2014 now the expected shape, not a gap.**\n   Cache-era mechanics (\u00a72.3) require the primer to be mempool-live on the accepting\n   nodes in the ~60 s before 4050336 connected; it never needed to be mined.\n   Consistent with that: the **1,500-block (~25 h) pre-fork scan** on the valid chain\n   (`scan_back.py`, `DONE found=[]`) found no `C1` and no blob bytes except the\n   4050335 dry-run pair, and **all five other non-coinbase txs of 4050336** were\n   fetched and parsed (\u00a74.5) \u2014 none carries the primer tuple. Only the accepting\n   nodes' mempool acceptance logs / `debug.log` can identify the live primer.\n2. Which exact builds the accepting functionaries/explorers ran \u2014 established to be\n   **post-fix, unreleased** (23.3.4rc2-era; \u00a72.4, \u00a74.5), but the precise commit set\n   and deployment date can only be confirmed by the vendor.\n3. Whether `V2` was ever broadcast on the P2P network or only seen by one explorer\n   backend; its purpose is inferred (first-generation design, killed by `HasValidFee`).\n4. mempool.space's Liquid backend was unreachable during analysis; its fork position\n   could not be re-confirmed today.\n5. ~~The cryptographic demonstration is one-sided~~ **Resolved (rev. 2):** the\n   priming context is demonstrated \u2014 `P0` verifies VALID against `(C0, X, S0)` and\n   `(P1, C1, X, S1)` verifies INVALID (`prime_verify_test.py`), the fixed-key streams\n   of the primer and attack tuples are byte-identical, and the pre-fix streams\n   differ (`collision_test.py`, \u00a72.4).\n\n---\n\n## 7. Recommendations\n\n1. **Do not ship the current patch as-is \u2014 it does not close the exploited hole.**\n   Emergency release of all maintained branches with a *corrected* fix: serialize the\n   rangeproof-cache key with unambiguous field boundaries (length-prefix each field,\n   or hash each field separately and hash the concatenation of the digests) \u2014 or\n   remove the rangeproof result cache from the consensus path entirely. Until then\n   **both** keyings are exploitable (Bug A in every release, Bug B in every patched\n   build) and the attack is repeatable by re-priming with fresh tuples (\u00a72.4).\n2. **Node operators:** restarting clears the primed cache but does not by itself resolve\n   the split; accepting-side nodes must `invalidateblock e1d9a2aa\u2026` (and everything on\n   top) after upgrading to rejoin the valid chain. Caveat for **functionaries**:\n   Liquid's blocksigners remember prior signing state and refuse reorgs deeper than\n   one block, so abandoning the invalid chain on the signing side additionally\n   requires an authorized override of that signer protection \u2014 `invalidateblock`\n   alone does not move the federation.\n3. **Functionaries / watchmen:** halt peg-out processing while the network is split;\n   the 14:25 payout shows watchmen maturing peg-outs on a chain that honest unprimed\n   nodes reject. Consider requiring N-of-M oracle agreement on best chain before\n   signing withdrawals.\n4. **Defense in depth (code):**\n   - The context-in-key direction is right but insufficient on its own; add consensus\n     regression tests: (i) two tuples related by a shifted proof/script boundary must\n     never share a cache key; (ii) same `(proof, commitment)` under a different asset\n     and script must fail.\n   - `CachingSurjectionProofChecker` was audited and is safe (key includes the wtxid,\n     which commits to all generator context); a dedicated regression test is still\n     worthwhile to lock that property in.\n   - Consider skipping the rangeproof-result cache during block validation entirely\n     (verification is cheap relative to Liquid's 1-minute blocks).\n   - **Length-delimit the rangeproof-cache key fields (or hash each field\n     separately) \u2014 required, not optional.** The undelimited encoding is the\n     exploited bug (\u00a72.2, \u00a75.2 item 7): with variable-length fields at both ends\n     of the stream, no fixed-width interior field can pin the boundary.\n   - Consider `MoneyRange`-style caps for *peg-out* OP_RETURN explicit values\n     (per-output and per-block) \u2014 that alone would have stopped the 3,996 BTC output\n     even with the cache bypassed.\n5. **Disclosure process:** the fix was authored 2026-08-03 and sat unmerged for ~4\n   weeks, then was public, clearly titled, and easily diffable for 3\u20135 days\n   pre-attack (\"Fix caching bug in rangeproof caching\") \u2014 effectively a published\n   exploit recipe. For consensus-critical crypto/cache fixes, consider\n   embargoed or low-visibility landing (merge at release time), as practiced for\n   Bitcoin Core CVEs. A second process failure compounded the first: the patch was\n   **deployed to the signing functionaries before any release and before\n   adversarial review of the patch itself** \u2014 and it was the deployment, not just\n   the publication, that enabled the attack (pre-fix nodes could never have\n   accepted the crafted tuple, \u00a72.4). Rushing consensus-touching patches onto the\n   signing set ahead of release converts every latent bug *in the patch* into a\n   federation-level incident.\n\n", "creation_timestamp": "2026-09-08T09:00:50.388220Z"}]}