{"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"}