Repository navigation
Description
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:
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, thewith_prepared_dictionaryconstructor accepts a reference to a prepared dictionary (EncoderDictionaryorDecoderDictionary). For example,stream::raw::Encoderdefines: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
-
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 regardingisize::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.
-
-
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.
-
-
src/stream/raw.rs:111🔴111: unsafe { output.set_pos(output_pos + len) };
-
Issue: Completely missing safety comment.
OutBuffer::set_posis an
unsafe fnwhose safety contract requires that memory up toposin 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
gyscos commentedon Jun 24, 2026
Hi, and thanks for the report! This might indeed have been a mistake in the reference lifetime, will check.
zstd-safe: bind init_using_{c,d}dict's dictionary borrow to the context
zstd-safe: bind init_using_{c,d}dict's dictionary borrow to the context