Skip to content

Correctness Advisory: Ref constructors discard explicit trailing-element counts #3618

Description

@joshlf

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

[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:

#![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<const N: usize>([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:

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

Observed output:

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 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 and from_suffix_with_elems likewise select the byte extent and delegate without the metadata.
  3. Deref::deref 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.

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions