google / google/zerocopy

Correctness Advisory: Ref constructors discard explicit trailing-element counts

Open
#3,618 0 comments 0 reactions 0 assignees View on GitHub
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:*

- **Bug Type:** Incorrect slice metadata / API contract violation
- **Affected Software:** zerocopy
- **Date:** September 2026
- **Discoverer:** OpenAI (OutboundDisclosures@openai.com)

## Summary

`Ref::from_bytes_with_elems`, `from_prefix_with_elems`, and `from_suffix_with_elems` can return an object whose trailing slice has more elements than the explicitly requested count. These constructors reduce the count to a padded byte size and then discard it. Dereferencing the wrapper reconstructs the maximum count fitting that byte size.

In the proof of concept, a request for one trailing byte returns eight through `Ref`, while the corresponding direct `FromBytes` API returns the requested one. A request for zero trailing `u16` elements similarly returns one element for another layout.

**Classification and threat model:** This is a correctness bug. A parser using a validated length field as the count can accidentally process padding as additional elements. All additional bytes in this repro remain within the initialized input buffer; it does not demonstrate undefined behavior, an out-of-allocation read, or an application security bypass. Security consequences would require additional consumer behavior not established here.

**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:** macOS arm64 (`aarch64-apple-darwin`), Rust 1.98.1.

## Sketch of the attack

A `#[repr(C)]` object with a `u64` header and a trailing byte slice has eight-byte alignment. With one trailing byte its total padded size is sixteen bytes; with eight trailing bytes the size is also sixteen. The constructor initially computes the correct sixteen-byte extent, but the wrapper stores only that extent. Dereferencing it chooses eight trailing elements instead of the caller's one.

## Full repro

Save this as `Cargo.toml`:

```toml
[package]
name = "zerocopy-disclosure-poc"
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 }
```

The commands below select the latest stable Rust, validated here with Rust 1.98.1.

Save this as `src/bin/ref_count.rs`:

```rust
#![forbid(unsafe_code)]

use zerocopy::{FromBytes, Immutable, KnownLayout, Ref};

#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C)]
struct Frame {
count: u64,
body: [u8],
}

#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C)]
struct OddHeader {
count: u32,
marker: u8,
body: [u16],
}

#[repr(align(8))]
struct Aligned([u8; N]);

fn main() {
let mut bytes = Aligned([0u8; 32]);
bytes.0[8..16].copy_from_slice(b"APADDING");

let direct = Frame::ref_from_bytes_with_elems(&bytes.0[..16], 1).unwrap();
let whole = Ref::<_, Frame>::from_bytes_with_elems(&bytes.0[..16], 1).unwrap();
let (prefix, rest) = Ref::<_, Frame>::from_prefix_with_elems(&bytes.0[..], 1).unwrap();
let (before, suffix) = Ref::<_, Frame>::from_suffix_with_elems(&bytes.0[..24], 1).unwrap();

println!("Frame count=1; FromBytes body.len()={}", direct.body.len());
println!("Frame count=1; Ref whole body.len()={}", whole.body.len());
println!(
"Frame count=1; Ref prefix body.len()={}, remainder={}",
prefix.body.len(),
rest.len()
);
println!(
"Frame count=1; Ref suffix body.len()={}, prefix={}",
suffix.body.len(),
before.len()
);
println!(
"Frame count=1; direct bytes={:?}; Ref bytes={:?}",
&direct.body, &whole.body
);

let empty = Ref::<_, OddHeader>::from_bytes_with_elems(&bytes.0[..8], 0).unwrap();
println!("OddHeader count=0; Ref body.len()={}", empty.body.len());

assert_eq!(direct.body.len(), 1);
assert_eq!(whole.body.len(), 8);
assert_eq!(prefix.body.len(), 8);
assert_eq!(suffix.body.len(), 8);
assert_eq!(empty.body.len(), 1);
}
```

Run:

```sh
cargo +stable generate-lockfile
cargo +stable run --locked --release --bin ref_count
```

Observed output:

```text
Frame count=1; FromBytes body.len()=1
Frame count=1; Ref whole body.len()=8
Frame count=1; Ref prefix body.len()=8, remainder=16
Frame count=1; Ref suffix body.len()=8, prefix=8
Frame count=1; direct bytes=[65]; Ref bytes=[65, 80, 65, 68, 68, 73, 78, 71]
OddHeader count=0; Ref body.len()=1
```

The direct `FromBytes` result contains `A`. The `Ref` result contains `APADDING`, despite both constructors being given count `1`. The assertions deliberately confirm the current incorrect behavior; after a fix, the three `Ref` length assertions should expect `1` and the `OddHeader` assertion should expect `0`.

## Root cause

1. [`from_bytes_with_elems`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/ref.rs#L491-L500) computes `T::size_for_metadata(count)` and checks the input length, then calls `Self::from_bytes(source)` without preserving `count`.
2. [`from_prefix_with_elems`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/ref.rs#L542-L552) and [`from_suffix_with_elems`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/ref.rs#L588-L605) likewise select the byte extent and delegate without the metadata.
3. [`Deref::deref`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/ref.rs#L830-L834) reconstructs the reference with `try_cast_into_no_leftover(..., None)`, allowing the layout code to select the largest element count fitting the bytes. With dynamic trailing padding, byte size does not uniquely determine the original count.

Preserve explicit metadata in the wrapper, or reject ambiguous counts if the representation cannot retain them. The direct `FromBytes::*_with_elems` APIs preserve the requested metadata and provide the passing comparison in this repro.

## 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

Open the contributing guide

Research direction

Start in zerocopy/src/ref.rs at from_bytes_with_elems, from_prefix_with_elems, from_suffix_with_elems, and Deref::deref to trace how explicit element counts are lost. Use the provided src/bin/ref_count.rs reproduction and run cargo +stable run --locked --release --bin ref_count. Done means the Ref assertions report the requested counts of 1, 1, 1, and 0 without exposing padding.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend-api-design
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.