Skip to content

Soundness: Use-after-free via unconstrained borrow lifetime in with_prepared_dictionary constructors #365

Closed

Description

@Manishearth

Note

This finding was identified during an agentic unsafe Rust code review performed by Gemini AI, followed by human review and verification.

Note that I'm not fully familiar with zstd's API here. I think this is a valid bug, but in case I've misunderstood zstd's needs, apologies for wasting your time!

The Issue

Across the streaming compression and decompression modules in zstd v0_13, constructors such as Decoder::with_prepared_dictionary and Encoder::with_prepared_dictionary accept a prepared dictionary reference &EncoderDictionary<'b> or &DecoderDictionary<'b>. For example, in stream::raw::Encoder:

zstd-rs/src/stream/raw.rs

Lines 165 to 179 in 52e9d41

/// Creates a new decoder, using an existing `DecoderDictionary`.
pub fn with_prepared_dictionary<'b>(
dictionary: &DecoderDictionary<'b>,
) -> io::Result<Self>
where
'b: 'a,
{
let mut context = zstd_safe::DCtx::create();
context
.ref_ddict(dictionary.as_ddict())
.map_err(map_error_code)?;
Ok(Decoder {
context: MaybeOwnedDCtx::Owned(context),
})
}

When context.ref_cdict(...) or ref_ddict(...) is called internally, the underlying C library registers and stores the raw dictionary C pointer (ZSTD_CDict* / ZSTD_DDict*) inside the compression context. The C library requires that the registered dictionary remain alive and valid for at least as long as the active context.

While the author attempted to enforce this via the bound where 'b: 'a, 'b represents the lifetime of the dictionary's internal buffers. When an owned dictionary is created via copy(...), 'b is instantiated as 'static. Since 'static: 'a holds trivially for any 'a, the returned Encoder<'a> or Decoder<'a> can be assigned any arbitrary lifetime 'a, including 'static.

Because the parameter is passed as an unconstrained reference &EncoderDictionary<'b> (rather than binding the reference borrow itself to 'a via &'a EncoderDictionary<'b>), the borrow checker only borrows dictionary for the duration of the constructor call. Once the constructor returns, the dictionary object is unborrowed and can be dropped immediately in safe code. Dropping the dictionary frees the underlying C struct (ZSTD_freeCDict), leaving the active streaming context holding a dangling pointer. Subsequent stream operations trigger a use-after-free vulnerability.

Suggested Fix

The prepared dictionary parameter must tie its reference borrow lifetime directly to the context's 'a lifetime, matching the correct pattern already used in bulk operations (src/bulk/compressor.rs):

 impl<'a> Encoder<'a> {
     pub fn with_prepared_dictionary<'b>(
-        dictionary: &EncoderDictionary<'b>,
+        dictionary: &'a EncoderDictionary<'b>,
     ) -> io::Result<Self>
     where
         'b: 'a,

This change should be applied across stream::raw, stream::read, and stream::write constructors.


Note

The full audit report below also contains additional minor findings (such as missing safety comments or undocumented FFI assumptions) that are probably worth fixing as well but not the primary goal of this issue.

Full Gemini Unsafe Code Audit Report

Unsafe Rust Review: zstd (v0_13)

Overall Safety Assessment

zstd (v0_13) is a high-level Rust binding for the Zstandard compression library. The crate acts as a safe wrapper around zstd-safe (v7.1.0), which in turn provides low-level safe FFI bindings over zstd-sys.

The direct unsafe footprint within zstd itself is exceptionally minimal: there are only three unsafe operations across the entire crate, all situated inside the dummy NoOp stream operation implementation in src/stream/raw.rs (pointer arithmetic via add, memory copying via std::ptr::copy_nonoverlapping, and buffer advancement via OutBuffer::set_pos).

However, despite the sparse direct usage of unsafe blocks, the crate exposes a critical lifetime soundness vulnerability across several public API constructors (with_prepared_dictionary in stream::raw, stream::read, and stream::write). Due to an incorrect lifetime specification on dictionary references (&Dictionary<'b> instead of &'a Dictionary<'b>), owned dictionaries (Dictionary<'static>) can be dropped while streaming encoder/decoder contexts remain active, resulting in use-after-free vulnerabilities in safe code during compression or decompression.

Critical Findings

1. Use-After-Free via Unconstrained Borrow Lifetime in with_prepared_dictionary Constructors 🔴 🤦

  • Severity: 🔴 High

  • Threat Vector: 🤦 Accidental Misuse

  • Bug Type: Use-After-Free

    • Locations:

      • src/stream/raw.rs:166 (Decoder::with_prepared_dictionary)
      • src/stream/raw.rs:301 (Encoder::with_prepared_dictionary)
      • src/stream/read/mod.rs:74 (Decoder::with_prepared_dictionary)
      • src/stream/write/mod.rs:219 (Encoder::with_prepared_dictionary)
      • src/stream/write/mod.rs:377 (Decoder::with_prepared_dictionary)
    • Description: Across the streaming compression and decompression
      modules, the with_prepared_dictionary constructor accepts a reference to a prepared dictionary (EncoderDictionary or DecoderDictionary). For example, stream::raw::Encoder defines:

      impl<'a> Encoder<'a> {
          pub fn with_prepared_dictionary<'b>(
              dictionary: &EncoderDictionary<'b>,
          ) -> io::Result<Self>
          where
              'b: 'a,

When context.ref_cdict(dictionary.as_cdict()) is called internally, zstd_safe invokes the underlying C API (ZSTD_CCtx_refCDict), which stores the raw C pointer (ZSTD_CDict*) inside the compression context. The C library requires that the registered dictionary remain alive and valid for at least as long as the compression context references it.

The crate author attempted to enforce this requirement using the trait bound where 'b: 'a. However, 'b represents the lifetime of the dictionary's internal buffers. When an owned dictionary is created via EncoderDictionary::copy(...) or DecoderDictionary::copy(...), 'b is instantiated as 'static. Since 'static: 'a holds trivially for any 'a, the returned streaming context (Encoder<'a> or Decoder<'a>) can be assigned any arbitrary lifetime 'a, including 'static.

Crucially, because the parameter is passed as an unconstrained reference &EncoderDictionary<'b> (rather than binding the reference lifetime itself to 'a via &'a EncoderDictionary<'b>), the borrow checker only borrows dictionary for the duration of the with_prepared_dictionary constructor invocation. Once the constructor returns, the dictionary object is unborrowed and can be dropped immediately in safe code. Dropping the dictionary executes ZSTD_freeCDict or ZSTD_freeDDict, leaving the active Encoder or Decoder with a dangling C pointer. Subsequent stream operations (run, read, write, finish) trigger a use-after-free in the C library.

(Note: In src/bulk/compressor.rs and src/bulk/decompressor.rs, the authors correctly specified dictionary: &'a EncoderDictionary<'b>, which properly ties the borrow of the dictionary object to the lifetime 'a of the bulk context).

Fishy Findings

None.

Missing Safety Comments

  1. src/stream/raw.rs:100 🟡

    98:         // Safe because `output.pos() <= output.capacity()`.
    99:         let output_pos = output.pos();
    100:         let dst = unsafe { output.as_mut_ptr().add(output_pos) };
    • Issue: The comment on line 98 explains the intent but lacks standard
      // SAFETY: prefix header formatting and formal justification regarding isize::MAX.

    • Proposed Proof Comment:

      // SAFETY: `output.as_mut_ptr()` points to the start of the underlying allocated buffer of length `output.capacity()`. By `OutBuffer` invariant, `output_pos <= output.capacity()`. Since container capacities in Rust cannot exceed `isize::MAX`, `output_pos <= isize::MAX`, and the resulting pointer is within or one byte past the end of the same allocated object.
  2. src/stream/raw.rs:109 🟡

    106:         // Safe because:
    107:         // * `len` is less than either of the two lengths
    108:         // * `src` and `dst` do not overlap because we have `&mut` to each.
    109:         unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), dst, len) };
    • Issue: The multi-line comment on lines 106-108 explains
      non-overlapping copy soundness but lacks standard // SAFETY: prefix header formatting.

    • Proposed Proof Comment:

      // SAFETY: `src` is valid for reads of `len` bytes and `dst` is valid for writes of `len` bytes because `len` is computed as `min(src.len(), capacity - output_pos)`. `src` and `dst` are derived from distinct disjoint mutable references (`input` and `output`), ensuring they do not overlap.
  3. src/stream/raw.rs:111 🔴

    111:         unsafe { output.set_pos(output_pos + len) };
    • Issue: Completely missing safety comment. OutBuffer::set_pos is an
      unsafe fn whose safety contract requires that memory up to pos in the underlying container has actually been initialized.

    • Proposed Proof Comment:

      // SAFETY: Prior to this call, `output` had `output_pos` initialized bytes. The preceding `copy_nonoverlapping` call copied `len` initialized bytes from `src` into `output` starting at `output_pos`. Therefore, the first `output_pos + len` bytes of `output` are now fully initialized.

Activity

  1. gyscos commented on Jun 24, 2026

    @gyscos
    Owner

    Hi, and thanks for the report! This might indeed have been a mistake in the reference lifetime, will check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions