Skip to content

bug: Vec deserializer preallocates full capacity from untrusted varint before reading data #10545

Description

@mpguerra

Credit: @dingledropper for reporting this issue

This issue was created with help from Claude.ai

What happened?

I expected to see this happen:
zcash_deserialize_external_count should allocate memory proportionally to data actually read, as zcashd does via chunked resize() interleaved with reads (serialize.h:761–777).

Instead, this happened:
zcash_deserialize_external_count calls Vec::with_capacity(external_count) in a single upfront allocation based on the peer-supplied varint, before reading any element data. A crafted message with an inflated count forces the full allocation regardless of how much data follows. For block::Hash (32 bytes × 65,535 = ~2 MiB), this is reachable via read_getblocks and read_getheaders from any peer that has completed the version/verack handshake.

This is the same defect class fixed for AddrV1/AddrV2 in PR #10494 (GHSA-xr93-pcq3-pxf8), but those fixes capped the per-type max_allocation() rather than addressing the root cause in the deserializer.


What were you doing when the issue happened?

Code audit of TrustedPreallocate impls following the AddrV1/V2 fix in PR #10494.

The root cause is in zebra-chain/src/serialization/zcash_deserialize.rs:96:

let mut vec = Vec::with_capacity(external_count);  // full upfront allocation
for _ in 0..external_count {
    vec.push(T::zcash_deserialize(&mut reader)?);   // data read happens after
}

zcashd's equivalent (serialize.h:761–777) avoids this by growing the vector in chunks interleaved with reads, so memory tracks data actually received rather than the claimed count:

while (nMid < nSize) {
    nMid += 5000000 / sizeof(T);
    if (nMid > nSize) nMid = nSize;
    v.resize(nMid);
    for (; i < nMid; i++)
        Unserialize(is, v[i]);
}

A minimal Rust equivalent is to cap the initial allocation:

let mut vec = Vec::with_capacity(external_count.min(1024));
for _ in 0..external_count {
    vec.push(T::zcash_deserialize(&mut reader)?);
}

This fixes the root cause once in the core deserializer rather than requiring each TrustedPreallocate impl to be individually audited and capped. The Vec grows naturally via push() as real data arrives — the performance cost is negligible (a handful of amortized reallocation-copies vs. per-element deserialization cost).

TrustedPreallocate and max_allocation() remain as defense-in-depth to reject clearly bogus counts early.


Zebra Version

zebrad 4.4.1 (commit 1ec1078)


Additional information

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions