Soundness Advisory: A safe `Read` witness permits use-after-free
- Dominant language
- Rust
- Stars
- 2.6k
- Forks
- 179
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 29
Description
*The following was reported by OpenAI via email:*
- **Vulnerability Type:** Safe API unsoundness / use-after-free
- **Affected Software:** zerocopy
- **Date:** September 2026
- **Discoverer:** OpenAI (OutboundDisclosures@openai.com)
## Summary
The public `zerocopy::pointer::invariant::Read` trait is safe and unsealed, but zerocopy relies on its implementations to justify unsafe pointer conversions. A downstream crate can implement this trait for `Cell>` and `Vec` using a local reason type, then use safe `Ptr` methods to obtain a shared reference to the vector inside the cell. Replacing the cell frees the vector's buffer while a slice of that buffer remains accessible. The complete reproducer below contains no caller-written unsafe code, and default Miri detects a use-after-free.
This `Read` is zerocopy's marker trait for permitted reads; it is unrelated to `std::io::Read`. Its documentation requires either exclusive access or an immutable referent, but an ordinary safe trait implementation can violate that requirement.
**Threat model and impact:** Triggering this defect requires downstream Rust code to supply these trait implementations and call zerocopy's public, documentation-hidden pointer APIs. It defeats the guarantee that safe Rust callers preserve memory safety. We have not demonstrated exploitation by a network peer controlling only packet bytes, remote code execution, or recovery of confidential data. Ordinary shared `FromBytes` and `TryFromBytes` parsing independently requires `Immutable`; the reproducer does not establish that those entry points accept mutable types. This is confirmed API unsoundness; an exploitable application security consequence remains unproven.
**Affected version:** Reproduced using the published `zerocopy 0.8.56` crate. Its [release source](https://github.com/google/zerocopy/tree/6dc429c451bdf1d7202ec1ec2cf426514e00d8eb) contains the same affected code as audited revision `2dad389b030e9268d6645ac0bf0626b867e96068`, which the source references below use. Earlier releases have not been exhaustively tested.
**Environment:** The reproducer compiles with Rust 1.98.1 on macOS arm64. We validated the use-after-free with the default checks in Miri from `nightly-2026-09-04` (`rustc 1.100.0-nightly`, commit `a69a63265`, host `aarch64-apple-darwin`).
## Sketch of the attack
`CallerReason` is local to the downstream crate, so Rust's orphan rules permit safe implementations of `Read` for both `Cell>` and `Vec`. These false witnesses let `Ptr::transmute` and `Ptr::as_ref` expose a shared reference to the vector inside the cell.
The caller borrows the vector's slice, then calls `Cell::set` to replace and drop the vector. The slice remains accessible to safe code after its allocation is freed; reading it causes the use-after-free.
## Full repro
Save the following as `Cargo.toml`:
```toml
[package]
name = "zerocopy-read-witness-repro"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
zerocopy = { version = "=0.8.56", features = ["derive", "std", "simd"] }
# Pin the derive dependencies to the tested versions.
proc-macro2 = { version = "=1.0.80", default-features = false }
quote = { version = "=1.0.40", default-features = false }
syn = { version = "=2.0.56", default-features = false }
unicode-ident = { version = "=1.0.22", default-features = false }
```
Save the following complete program as `src/bin/proof_trait_read.rs`:
```rust
#![forbid(unsafe_code)]
use std::cell::Cell;
use zerocopy::pointer::{
invariant::{Read, Shared, Valid},
Ptr,
};
struct CallerReason;
impl Read for Cell> {}
impl Read for Vec {}
fn main() {
let cell = Cell::new(vec![0x41_u8; 64]);
let vector = Ptr::from_ref(&cell)
.transmute::, Valid, _>()
.try_into_aligned()
.unwrap()
.as_ref();
let bytes = vector.as_slice();
cell.set(Vec::new());
println!("{}", bytes[0]);
}
```
Then run:
```sh
cargo +stable generate-lockfile
cargo +stable check --locked --bin proof_trait_read
cargo +nightly-2026-09-04 miri run --locked --bin proof_trait_read
```
Compilation succeeds. Miri exits with status 1 and reports the following diagnostic:
```text
error: Undefined Behavior: reference not dereferenceable: alloc319 has been freed, so this pointer is dangling
--> src/bin/proof_trait_read.rs:23:20
|
23 | println!("{}", bytes[0]);
| ^^^^^^^^ Undefined Behavior occurred here
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
help: alloc319 was allocated here:
--> src/bin/proof_trait_read.rs:15:26
|
15 | let cell = Cell::new(vec![0x41_u8; 64]);
| ^^^^^^^^^^^^^^^^^
help: alloc319 was deallocated here:
--> src/bin/proof_trait_read.rs:22:5
|
22 | cell.set(Vec::new());
| ^^^^^^^^^^^^^^^^^^^^
```
The allocation identifier may vary. The diagnostic is obtained without disabling Miri's validity, aliasing, or allocation checks. Native output is not a reliable detector of this defect because the freed allocation may still contain its old bytes.
## Root cause
All zerocopy links below refer to public revision `2dad389b030e9268d6645ac0bf0626b867e96068`.
1. [`Read` is a public safe trait with no sealing bound](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/pointer/invariant.rs#L248-L265). Its documented condition is that `A` is `Exclusive` or the referent implements `Immutable`. Neither condition is enforced for additional downstream implementations. The [pointer module is publicly exported](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/lib.rs#L364-L366); hiding it from generated documentation does not prevent safe code from using it.
2. The [unsafe blanket implementation of `MutationCompatible`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/pointer/transmute.rs#L182-L205) accepts `Src: Read` and `Dst: Read` as its evidence. [`TryTransmuteFromPtr` then relies on that compatibility](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/pointer/transmute.rs#L152-L162). The local reason parameter allows the reproducer to satisfy these bounds for a mutable cell and its contents without implementing an unsafe trait.
3. Zerocopy already provides the [size and value-representation conversions between `Cell` and `T`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/pointer/transmute.rs#L452-L472). These facts do not establish that `&Cell` and `&T` may remain usable simultaneously. The [safe `Ptr::transmute` path](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/pointer/ptr.rs#L430-L468) accepts the forged compatibility evidence, after which [`Ptr::as_ref` constructs the shared reference](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/pointer/ptr.rs#L224-L277). Replacing the cell invalidates the vector's borrowed storage, contrary to Rust's [reference and interior-mutability rules](https://doc.rust-lang.org/std/cell/struct.UnsafeCell.html#aliasing-rules).
4. Ordinary shared parsing has additional checks: [`TryFromBytes::try_ref_from_bytes`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/lib.rs#L1878-L1883) and [`FromBytes::ref_from_bytes`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/lib.rs#L4155-L4161) require `Immutable` and explicitly select `BecauseImmutable`. This is why the demonstrated trigger requires use of the more general pointer APIs.
## Suggested remediation
A minimal, best-effort candidate is to make `Read` an unsafe trait and retain
its two existing blanket implementations. This preserves calls supported by
those implementations, including generated derive code, while requiring
additional implementations to explicitly assume the existing safety
obligations. The following patch was tested in an isolated copy of audited
revision `2dad389b030e9268d6645ac0bf0626b867e96068`:
```diff
--- a/zerocopy/src/pointer/invariant.rs
+++ b/zerocopy/src/pointer/invariant.rs
@@ -252,18 +252,21 @@
/// because `T` does not permit interior mutation.
///
/// # Safety
///
-/// `T: Read` if either of the following conditions holds:
+/// Implementors must ensure that either of the following conditions holds:
/// - `A` is [`Exclusive`]
-/// - `T` implements [`Immutable`](crate::Immutable)
+/// - `Self` implements [`Immutable`](crate::Immutable)
///
/// As a consequence, if `T: Read`, then any `Ptr` is
/// permitted to perform unsynchronized reads from its referent.
-pub trait Read {}
+pub unsafe trait Read {}
-impl Read for T {}
-impl Read for T {}
+// SAFETY: `T: Immutable`, satisfying the second condition of `Read`'s contract.
+unsafe impl Read for T {}
+// SAFETY: The aliasing is `Exclusive`, satisfying the first condition of
+// `Read`'s contract.
+unsafe impl Read for T {}
/// Unsynchronized reads are permitted because only one live [`Ptr`](crate::Ptr)
/// or reference may exist to the referent bytes at a time.
#[derive(Copy, Clone, Debug)]
```
Observed validation on macOS arm64, using Rust 1.98.1 and the Miri version
identified above:
- Before the patch, the unchanged reproducer reports the use-after-free under
default Miri checks. After the patch, Rust rejects both of its safe `Read`
implementations with E0200, requiring an `unsafe impl` declaration.
- Seven control tests passed both natively and under default Miri checks.
These include shared immutable reads, exclusive reads and a valid exclusive
`Cell>` conversion, derived enum validation, and four existing
controls for ordinary parsing, allocation, vector insertion, and a
caller-provided reader.
- `cargo fmt` completed successfully. An upstream library Clippy attempt with
all features stopped at the unchanged `zerocopy-derive/src/repr.rs:572` on
`clippy::needless_nonzero_get`; no clean upstream Clippy result is claimed.
This changes source compatibility for manual implementations of `Read`.
Adding `unsafe` to the reproducer's false implementations would violate the
trait's contract and is not a valid migration. Validation covered the
reproduced failure and the listed controls. The upstream CI suite, MSRV,
other targets, and all feature combinations were not validated.
## Disclaimer
This information is being shared by OpenAI solely for the purpose of improving security and reducing potential harm. This information is presented as-is. We make no representations or warranties, express or implied, as to the completeness, accuracy, or fitness for any particular purpose of the information. This includes, without limitation any suggestions or ideas presented on how to remedy or mitigate an identified vulnerability, including whether such suggestions or ideas would be effective and/or could have other negative impacts.
OpenAI disclaims any liability for direct or indirect damages arising from the reliance on, or use, misuse, or interpretation of this information. Any references to third-party systems, services, or entities are included solely for identification purposes and do not imply endorsement, responsibility, or attribution.
Contributor guide
Research direction
Start with zerocopy/src/pointer/invariant.rs, then trace the Read bounds through pointer/transmute.rs and pointer/ptr.rs; run the proof_trait_read.rs reproducer under the specified Miri command. Done means the forged downstream implementations are rejected while the existing control tests, formatting, and supported conversions continue to pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100