{"uuid": "77175a0b-ae11-4d0e-b54f-533cd89af427", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-rgwx-8r98-p34c", "type": "seen", "source": "https://gist.github.com/web3securityauditor/d2e4cde8ac7f8d357246d720abdd202b", "content": "# [Coordinated Disclosure] zcashd \u2014 single P2P transaction message can crash any zcashd node via OOM in librustzcash's v5 Sapling/Orchard bundle parser (pre-allocation gap)\n\n| Field | Value |\n| --- | --- |\n| Class | Denial-of-service \u2014 remote, unauthenticated, single-message process crash via OOM |\n| Severity (proposed) | **Critical.** Any peer that completes the P2P handshake (no auth required \u2014 zcashd accepts inbound peers by default) can send a single transaction message within the 2 MiB protocol cap that causes zcashd to attempt a \u22653.2 GB allocation in librustzcash's deserializer, OOM-aborting the process. The bug class is the same as the recently-disclosed `GHSA-rgwx-8r98-p34c` (which Zebra fixed in v4.4.0 / commit f4267ef), but the librustzcash side that zcashd consumes via FFI never received an equivalent fix. zcashd is being deprecated in favor of Zebra, but it is still in scope for the ZCG bug bounty and is currently deployed on mainnet. |\n| Affected repo | `zcashd` (via FFI into `librustzcash`) |\n| Affected code | `zcashd/src/rust/src/sapling.rs:137-141` (`parse_v5_sapling_bundle`); `librustzcash/zcash_primitives/src/transaction/components/sapling.rs` (`read_v5_bundle`); `librustzcash/components/zcash_encoding/src/lib.rs` (`Vector::read` \u2192 `Array::read_collected_mut` \u2014 the actual allocation site) |\n| Discovery date | 2026-05-17 |\n\n---\n\n## TL;DR\n\nzcashd parses v5 transactions by delegating the Sapling bundle to librustzcash via FFI:\n\n```\nzcashd CTransaction parse\n    \u2192 parse_v5_sapling_bundle (zcashd/src/rust/src/sapling.rs:137)\n    \u2192 Transaction::temporary_zcashd_read_v5_sapling (librustzcash)\n    \u2192 read_v5_bundle (librustzcash, zcash_primitives/.../sapling.rs)\n    \u2192 Vector::read(&amp;mut reader, read_spend_v5)   // \u2190 here\n```\n\n`Vector::read` is implemented as:\n\n```rust\nlet count: usize = CompactSize::read_t(&amp;mut reader)?;\nArray::read_collected_mut(reader, count, func)\n// \u2192\n(0..count).map(|_| func(&amp;mut reader)).collect::&gt;()\n```\n\nThe `(0..count).map(...).collect::&gt;()` calls `Vec::with_capacity(count)` upfront via the iterator's size_hint. The `count` is the wire-supplied CompactSize value, capped only by `MAX_COMPACT_SIZE = 0x02000000 = 33_554_432`. For `E = SpendDescriptionV5` (~96 bytes including struct overhead), `Vec::with_capacity(33_554_432)` requests ~3.2 GB. For `OutputDescriptionV5` (~580+ bytes ciphertext, larger struct), ~19 GB. For Orchard `Action` (~820 bytes), ~27 GB. Any of these exceeds the available RAM on a typical zcashd node; the allocator returns failure; `Vec::with_capacity` panics with `capacity_overflow`; the panic crosses the C++/Rust FFI boundary; the zcashd process aborts.\n\nThe attack message stays within zcashd's `MAX_PROTOCOL_MESSAGE_LENGTH = 2 MiB` (the CompactSize for u64-range count uses only 9 bytes), so the P2P-layer message cap does not protect against it. The post-parse `MAX_TX_SIZE_AFTER_SAPLING = 2 MB` size check is too late \u2014 the allocator panic happens during parse, before any size check runs.\n\nZebra is **not** affected: `zebra-chain/src/sapling/spend.rs` defines `TrustedPreallocate` for `Spend` and `SpendPrefixInTransactionV5` with `max_allocation = MAX_BLOCK_BYTES / SHARED_ANCHOR_SPEND_SIZE` AND `&lt; 2^16` per the NU5 consensus rule. zcashd has no equivalent guard on the librustzcash side it consumes.\n\n---\n\n## Verification at code level\n\n### The allocation site \u2014 `librustzcash/components/zcash_encoding/src/lib.rs`\n\n```rust\npub struct CompactSize;\nimpl CompactSize {\n    pub fn read(mut reader: R) -&gt; io::Result {\n        // ... reads 1-9 bytes per Bitcoin CompactSize ...\n        match result {\n            s if s &gt; ::from(MAX_COMPACT_SIZE) =&gt; Err(io::Error::new(\n                io::ErrorKind::InvalidInput,\n                \"CompactSize too large\",\n            )),\n            s =&gt; Ok(s),\n        }\n    }\n}\n\npub const MAX_COMPACT_SIZE: u32 = 0x02000000;  // 33_554_432\n\npub struct Vector;\nimpl Vector {\n    pub fn read(reader: R, func: F) -&gt; io::Result&gt;\n    where F: Fn(&amp;mut R) -&gt; io::Result\n    {\n        Self::read_collected(reader, func)\n    }\n    pub fn read_collected&gt;(reader: R, func: F) -&gt; io::Result\n    where F: Fn(&amp;mut R) -&gt; io::Result\n    {\n        Self::read_collected_mut(reader, func)\n    }\n    pub fn read_collected_mut&gt;(mut reader: R, func: F) -&gt; io::Result\n    where F: FnMut(&amp;mut R) -&gt; io::Result\n    {\n        let count: usize = CompactSize::read_t(&amp;mut reader)?;\n        Array::read_collected_mut(reader, count, func)\n    }\n}\n\npub struct Array;\nimpl Array {\n    pub fn read_collected_mut&gt;(\n        mut reader: R, count: usize, mut func: F,\n    ) -&gt; io::Result\n    where F: FnMut(&amp;mut R) -&gt; io::Result\n    {\n        (0..count).map(|_| func(&amp;mut reader)).collect()    // \u2190 Vec::with_capacity(count) upfront\n    }\n}\n```\n\nPer the Rust stdlib, `Iterator::collect::&gt;()` uses the iterator's `size_hint()` to call `Vec::with_capacity`. For `0..count` the size_hint is `(count, Some(count))`. So `Vec::with_capacity(count)` is called BEFORE any `func(reader)` is invoked. For `count = MAX_COMPACT_SIZE = 33,554,432` and `E = SpendDescriptionV5`, the allocator is asked for `33_554_432 * size_of::()` bytes.\n\n### `SpendDescriptionV5` size \u2014 `sapling-crypto/src/bundle.rs:295-300`\n\n```rust\n#[derive(Clone)]\npub struct SpendDescriptionV5 {\n    cv: ValueCommitment,                          // ~32 bytes\n    nullifier: Nullifier,                         // 32 bytes\n    rk: redjubjub::VerificationKey,    // ~32 bytes\n}\n```\n\nWith Rust struct padding/alignment, this is on the order of 96-128 bytes. `33M * 96B \u2248 3.2 GB`.\n\nFor `OutputDescriptionV5` (cv + cmu + ephemeral_key + 580-byte enc_ciphertext + 80-byte out_ciphertext) \u2192 ~720 bytes \u2192 33M \u00d7 720 = ~24 GB.\n\nFor Orchard `Action` (cv + nullifier + rk + cmx + epk + 580 + 80 + zkproof_bytes + spend_auth_sig) \u2192 ~820 bytes \u2192 33M \u00d7 820 = ~27 GB.\n\nEach of these exceeds the available RAM on essentially any zcashd deployment.\n\n### The FFI path \u2014 `zcashd/src/rust/src/sapling.rs:137-141`\n\n```rust\npub(crate) fn parse_v5_sapling_bundle(reader: &amp;mut CppStream&lt;'_&gt;) -&gt; Result, String&gt; {\n    Bundle::parse_v5(reader)\n}\n\nimpl Bundle {\n    fn parse_v5(reader: &amp;mut CppStream&lt;'_&gt;) -&gt; Result, String&gt; {\n        match Transaction::temporary_zcashd_read_v5_sapling(reader) {\n            Ok(parsed) =&gt; Ok(Box::new(Bundle(parsed))),\n            Err(e) =&gt; Err(format!(\"Failed to parse v5 Sapling bundle: {}\", e)),\n        }\n    }\n}\n```\n\n`temporary_zcashd_read_v5_sapling` is librustzcash's own helper that wraps `sapling_serialization::read_v5_bundle`, which in turn uses `Vector::read(&amp;mut reader, read_spend_v5)` and `Vector::read(&amp;mut reader, read_output_v5)`.\n\n**zcashd's `Cargo.toml:134-137` sets `panic = 'abort'`.** That means no unwinding occurs in any zcashd Rust code: any panic immediately aborts the process. Additionally, `Vec::with_capacity` on allocation failure does NOT panic \u2014 it calls `handle_alloc_error()` which in turn calls `abort()` directly (see Rust stdlib `alloc/src/alloc.rs`). So **two independent abort paths** are triggered by the malicious input:\n1. If allocation fails: `handle_alloc_error \u2192 abort()` \u2014 direct abort, no Result returned.\n2. If capacity calculation overflows (`count * size_of::` overflows `usize`): `capacity_overflow` panic \u2192 with `panic=abort`, abort directly.\n\nEither way: the `Result&lt;_, String&gt;` return type of `parse_v5` cannot catch this. Process dies before any error handling at the C++ layer runs.\n\n### Zebra is protected \u2014 `zebra-chain/src/sapling/spend.rs`\n\n```rust\nimpl TrustedPreallocate for Spend {\n    fn max_allocation() -&gt; u64 {\n        const MAX: u64 = (MAX_BLOCK_BYTES - 1) / ANCHOR_PER_SPEND_SIZE;\n        // &gt; [NU5 onward] nSpendsSapling, nOutputsSapling, and nActionsOrchard MUST all be less than 2^16.\n        // https://zips.z.cash/protocol/protocol.pdf#txnencodingandconsensus\n        ...\n    }\n}\n\nimpl TrustedPreallocate for SpendPrefixInTransactionV5 {\n    fn max_allocation() -&gt; u64 {\n        const MAX: u64 = (MAX_BLOCK_BYTES - 1) / SHARED_ANCHOR_SPEND_SIZE;\n        // &gt; [NU5 onward] nSpendsSapling, nOutputsSapling, and nActionsOrchard MUST all be less than 2^16.\n        ...\n    }\n}\n```\n\nZebra's `zcash_deserialize_external_count` enforces `external_count &lt;= T::max_allocation()` before allocating. Zebra is safe. zcashd via librustzcash is not.\n\n### Recent related Zebra fix \u2014 context\n\nZebra v4.4.0 / commit `f4267ef` (\"fix(chain): reject coinbase Sapling spends during deserialization (GHSA-rgwx-8r98-p34c)\") fixed a different but adjacent gap: coinbase txs with Sapling spends were getting their spends Vec allocated before validation rejected them. The fix rejects the combination at deserialization time. That advisory and the TrustedPreallocate caps together close Zebra's exposure.\n\nlibrustzcash never received an equivalent fix (`grep -rn \"GHSA-rgwx-8r98-p34c\" /root/zcg/repos/librustzcash/` returns zero hits; no commit message mentions it). The Vector/Array pre-allocation pattern with no TrustedPreallocate-equivalent is the unfixed gap that zcashd inherits via FFI.\n\n---\n\n## Reproduction (paper)\n\n```\n# Construct a v5 transaction wire format:\n# [4-byte version] [4-byte versionGroupId] [4-byte consensusBranchId]\n# [4-byte lock_time] [4-byte expiry_height]\n# [transparent bundle: 1 byte vin_count=0, 1 byte vout_count=0]\n# [Sapling bundle:\n#     [5 bytes: CompactSize nSpendsSapling = 0xFE + u32_LE(0x02000000)]\n#     [0 bytes: actual spend data \u2014 reader will only allocate, never read]\n#     ... ]\n# [orchard bundle: 1 byte 0]\n# Total wire size: ~26 bytes\n#\n# Note: CompactSize uses the 5-byte form (0xFE prefix + u32) for values\n# in [0x10000, 0xFFFFFFFF]. MAX_COMPACT_SIZE = 33,554,432 = 0x02000000\n# fits in u32, so the 9-byte form (0xFF + u64) is rejected by librustzcash's\n# canonicality check (`n &lt; 0x100000000 =&gt; non-canonical`).\n```\n\nSend via P2P `tx` or `block` message to any zcashd peer. zcashd reads the message, dispatches to its CTransaction deserializer, which calls `parse_v5_sapling_bundle`, which calls librustzcash's `read_v5_bundle`, which calls `Vector::read(reader, read_spend_v5)`. The allocator is asked for ~3.2 GB. OOM. Process abort.\n\nI have not built the exact wire-format reproducer; the structural analysis above is verifiable by reading the four cited functions plus the SpendDescriptionV5 type definition. I'd be happy to construct an actual `tx` wire-format payload in a controlled regtest harness before publication if useful.\n\n---\n\n## Suggested fix\n\nThree options, ordered by minimal change \u2192 most-thorough:\n\n**Option A (minimal): cap the count in `Vector::read` before passing to `Array::read_collected_mut`.**\n\nAdd a `max_count` parameter to the existing `Vector::read*` family or introduce a new `Vector::read_bounded(reader, max, func)` variant. Update each Sapling/Orchard read site to pass a max derived from `MAX_BLOCK_BYTES / SIZE_OF_ELEMENT` (mirrors Zebra's TrustedPreallocate cap) AND from the NU5 spec rule `&lt; 2^16` for nSpendsSapling/nOutputsSapling/nActionsOrchard.\n\n**Option B (recommended): add a `TrustedPreallocate`-equivalent trait in librustzcash and gate all peer-reachable deserialization on it.**\n\nMirror Zebra's TrustedPreallocate pattern. Each element type that's deserialized from attacker-supplied bytes implements `max_allocation()` returning the consensus-derived upper bound. `Vector::read` (renamed to make it bounds-aware, or wrapped in a new function) refuses counts above the type's `max_allocation()`.\n\nThis is the cleanest long-term fix because it forces every new peer-reachable type to opt into a cap.\n\n**Option C: replace `Vec::with_capacity` with `try_reserve_exact`.**\n\n`Vec::with_capacity` panics on alloc failure; `Vec::new().try_reserve_exact(n)` returns `Err(TryReserveError)` which can be propagated as `io::Error::OutOfMemory`. This avoids the FFI-panic problem but does not avoid the underlying issue (attacker can still cause large alloc attempts, which on a multi-tenant system may pressure other services). Best combined with Option A or B.\n\nWhichever direction the maintainers pick, the fix should also be applied to:\n- `read_v5_bundle` Sapling spends (`Vector::read(&amp;mut reader, read_spend_v5)`)\n- `read_v5_bundle` Sapling outputs (`Vector::read(&amp;mut reader, read_output_v5)`)\n- The equivalent Orchard action read path\n- Transparent vin/vout (separate code paths)\n- Any other `Vector::read` over peer-supplied bytes\n\n---\n\n## Severity rationale\n\n- **Reachability:** trivial. zcashd accepts inbound P2P peers by default (`-listen=1` default). Any node operator that exposes a node to the public internet is reachable from any attacker. No auth required.\n- **Attack message:** ~30 bytes on the wire, well within `MAX_PROTOCOL_MESSAGE_LENGTH = 2 MiB`. Trivially constructible.\n- **Impact:** zcashd process abort. Recovery requires operator / process-supervisor restart. Repeated attack post-restart causes repeated crashes. Effective network-wide DoS against zcashd nodes (Zebra nodes are not affected, so the attack splits the network between the two impls).\n- **Recoverability:** server-side restart; client-facing apps (wallets that talk to zcashd, exchanges) lose their backend until manual operator action.\n- **Fund safety:** none threatened by this bug alone; but during the outage, wallet users cannot transact (cannot get block data, cannot broadcast txs).\n- **Privacy:** none threatened directly; but during outage, users may switch to other lwd / different nodes, potentially less-private.\n- **Tier:** **Critical** \u2014 remote unauthenticated single-message process crash against a deployed Zcash node implementation. Even though zcashd is in deprecation, it remains the dominant deployment for some operators (exchanges, payment processors, miners).\n\n---\n\n## What I am NOT claiming\n\n- I am not claiming zebra is affected. Zebra's TrustedPreallocate cap is the structural mitigation.\n- I am not claiming on-chain bytes are affected. A valid block could not contain a tx claiming 33M spends because it wouldn't fit in MAX_BLOCK_SIZE; the attack is on the parser BEFORE consensus rules apply, via the P2P layer.\n- I am not claiming RCE. The bug is a remote crash via allocator panic, not a write primitive. Modern Rust \u22651.81 aborts on panic-across-FFI rather than producing UB; older Rust would be UB but exploitation as RCE would require specific platform conditions I haven't verified.\n- I am not claiming this affects the wallet (`zcash_client_backend`) path through the same parser. Wallets receive blocks via lightwalletd's CompactBlock format, not raw v5 tx serialization. A different but adjacent panic family in `zcash_client_backend/src/scanning/compact.rs` is the subject of my companion disclosure (Gist 17).\n- I have not built the exact wire-format reproducer. The structural analysis above identifies the vulnerable function chain; an actual reproducer is a matter of wire-format construction.\n- I checked the librustzcash repo for any commit message referencing GHSA-rgwx-8r98-p34c or this bug class \u2014 zero hits. If a fix is in flight that I missed, the maintainers should redirect me to the relevant PR.\n\n---\n\n## Researcher contact\n\nSignal: this thread. Happy to:\n- construct an exact wire-format reproducer in a controlled regtest harness,\n- draft a PR implementing Option B (TrustedPreallocate-equivalent in librustzcash),\n- sweep the rest of `librustzcash/components/zcash_encoding/` callers for the same Vector::read-without-cap pattern (transparent vin/vout, Orchard, any future v6 fields, etc.).\n", "creation_timestamp": "2026-05-17T22:15:11.000000Z"}