google / google/zerocopy

Soundness Advisory: Safe padding witnesses bypass IntoBytes validation

Open
#3,614 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:** Safe API unsoundness / uninitialized-memory read
- **Affected Software:** zerocopy
- **Date:** September 2026
- **Discoverer:** OpenAI (OutboundDisclosures@openai.com)

## Summary

The public, documentation-hidden `PaddingFree` and `DynamicPaddingFree` traits are safe to implement and are not sealed. A downstream crate can provide a false implementation using its own local type. `IntoBytes` derivation trusts these implementations as proof that a type has no padding, allowing a padded Rust value to be exposed as initialized bytes.

The proof of concept implements `PaddingFree` for `()`. `Padded` really has three padding bytes. Deriving `IntoBytes` succeeds with this safe implementation present; removing it correctly rejects the derive. Default Miri validation detects an uninitialized read when the resulting byte slice is printed.

**Classification and threat model:** This is confirmed API unsoundness. A caller needs to compile the false safe-trait implementation; ordinary attacker-controlled packet bytes do not create that implementation. If an application includes it and transmits the resulting byte view, padding can expose residual memory, but no packet-only exploit or native secret disclosure is established by this repro. The absence of caller-written unsafe code makes this a library soundness defect even though the witness deliberately makes a false claim.

**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 native compilation and nightly-2026-09-04 (`rustc 1.100.0-nightly`, `a69a63265`) for Miri. Miri used normal validity checks and strict provenance.

## Sketch of the attack

The derived implementation asks whether `(): PaddingFree` holds. The downstream crate is allowed to implement that safe trait because `Padded` is local. The trait bound then succeeds even though the padding count is nonzero. `Padded::as_bytes()` exposes all eight bytes of a value with only five initialized field bytes.

## Full repro

Save this as `Cargo.toml`:

```toml
[package]
name = "zerocopy-disclosure-poc"
version = "0.1.0"
edition = "2024"

[workspace]

[features]
padding-control = []

[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 }
```

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

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

use zerocopy::{Immutable, IntoBytes};

#[derive(Immutable, IntoBytes)]
#[repr(C)]
struct Padded {
tag: u8,
value: u32,
}

#[cfg(not(feature = "padding-control"))]
impl zerocopy::util::macro_util::PaddingFree for () {}

fn main() {
let packet = Padded { tag: 1, value: 42 };
println!("{:02x?}", packet.as_bytes());
}
```

Run:

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

Miri reports an uninitialized-memory read during byte formatting. The relevant diagnostic and allocation contents are:

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

alloc222 (stack variable, size: 8, align: 4) {
01 __ __ __ 2a 00 00 00
}
```

The allocation identifier can vary. `__` denotes uninitialized memory; the fields hold `1` and `42` while the three padding bytes have no initialized values.

The negative control disables the false witness:

```sh
cargo +stable check --locked \
--bin proof_trait_padding --features padding-control
```

Compilation then fails with:

```text
error[E0277]: `Padded` has 3 total byte(s) of padding
```

This is the expected rejection. It shows that the safe witness implementation is what bypasses the intended check.

## Root cause

1. [`PaddingFree`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/util/macro_util.rs#L51-L64) is public and safe. The library implements it only for a zero padding count, but Rust's orphan rules also permit the shown downstream implementation with a local `T` and nonzero count.
2. [`DynamicPaddingFree`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/util/macro_util.rs#L72-L82) has the analogous problem: downstream code can supply the safe witness for `HAS_PADDING = true`.
3. [`IntoBytes` derivation](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/zerocopy-derive/src/derive/into_bytes.rs#L93-L155) uses padding checks to justify an unsafe implementation. The [padding-check dispatcher](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/zerocopy-derive/src/util.rs#L340-L354) selects these witness traits. A safe, externally implementable witness cannot establish the memory-safety invariant on which byte exposure relies.

The witness definitions belong to `zerocopy`; `zerocopy-derive` consumes them. Seal the witnesses or require an unsafe implementation with an explicit semantic contract, then retain the compile-fail control for false safe witnesses. Both witness traits are manifestations of the same trust-boundary defect.

## Suggested remediation

A candidate fix is to keep both public trait names available to generated code and add private supertraits for the complete type-and-constant predicates. Implement the private predicates only for a zero padding count and `HAS_PADDING = false`. A marker trait implemented for `()` alone would still allow downstream code to claim the wrong constant value.

The following patch was tested against the audited revision in an isolated copy. Keep `padding_seal` private; no change to generated derive code is needed.

```diff
diff --git a/zerocopy/src/util/macro_util.rs b/zerocopy/src/util/macro_util.rs
index c117d0d4c0732..f0ca128618f11 100644
--- a/zerocopy/src/util/macro_util.rs
+++ b/zerocopy/src/util/macro_util.rs
@@ -50,6 +50,14 @@ pub unsafe trait Field {
type Type: ?Sized;
}

+mod padding_seal {
+ pub trait PaddingFree {}
+ impl PaddingFree for () {}
+
+ pub trait DynamicPaddingFree {}
+ impl DynamicPaddingFree for () {}
+}
+
#[cfg_attr(
not(no_zerocopy_diagnostic_on_unimplemented_1_78_0),
diagnostic::on_unimplemented(
@@ -60,7 +68,10 @@ pub unsafe trait Field {
note = "consider using `#[repr(packed)]` to remove padding"
)
)]
-pub trait PaddingFree {}
+pub trait PaddingFree:
+ padding_seal::PaddingFree
+{
+}
impl PaddingFree for () {}

// FIXME(#1112): In the slice DST case, we should delegate to *both*
@@ -78,7 +89,10 @@ impl PaddingFree for () {}
note = "consider using `#[repr(packed)]` to remove padding"
)
)]
-pub trait DynamicPaddingFree {}
+pub trait DynamicPaddingFree:
+ padding_seal::DynamicPaddingFree
+{
+}
impl DynamicPaddingFree for () {}

#[cfg(__ZEROCOPY_INTERNAL_USE_ONLY_NIGHTLY_FEATURES_IN_TESTS)]
```

Validation on macOS arm64 used Rust 1.98.1 and the nightly-2026-09-04 Miri toolchain with strict provenance. The unchanged repro produced the reported uninitialized read before the patch and was rejected with `E0277` afterward. An additional safe implementation of `DynamicPaddingFree` for a `#[repr(C)]` type containing `u8` followed by `[u32]` compiled before the patch and was also rejected afterward. Four positive controls passed natively and under Miri: sized serialization, slice-DST serialization, generic sized-to-DST serialization, and public witness bounds with unsized type arguments.

This best-effort suggestion intentionally prevents downstream implementations of the padding witnesses. The patch was formatted, but upstream Clippy stopped at `clippy::needless_nonzero_get` in unchanged `zerocopy-derive/src/repr.rs`. The full upstream test suite, minimum supported Rust version, and other targets remain untested. Maintainers should retain compile-fail tests for both false predicates and review those compatibility cases before adopting the change.

## 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/src/util/macro_util.rs and the IntoBytes padding-check paths in zerocopy-derive/src/derive/into_bytes.rs and src/util.rs. Run the provided proof of concept under Miri, then the padding-control compile check and existing positive controls. Done means false PaddingFree and DynamicPaddingFree witnesses are rejected while valid derives and public witness bounds continue to pass.

Written by the indexing model from the issue text.

Assessment

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.