Skip to content

Security Advisory: Failed transmutations can disclose data from a previous response #3632

Description

@joshlf

The following was reported by OpenAI via email:

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

Summary

A failed try_transmute! call can return uninitialized bytes in place of the original input. The macro first moves the input through the destination type. If that type has padding, the move can discard bytes that the error path later treats as initialized input data.

A server that returns rejected input in an error response can consequently disclose data from a previous response. The reproduction below alternates a private response with a malformed 32-byte input. The malformed input contains only byte 2, but its returned error contains fourteen bytes of the private response: SERVER-SECRET!. Miri independently reports undefined behavior while the library constructs that error.

The server must parse into a padded type and expose the rejected source bytes. The attacker supplies the malformed input; the private response belongs to an earlier authorized request. The example models this sequence in one process. The exact residual bytes depend on compiler optimization, target, and storage reuse.

Affected version: Confirmed with the published zerocopy 0.8.56 crate. Earlier versions have not been exhaustively tested.

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

Sketch of the attack

For the WireRecord defined below, a boolean value of 2 correctly fails validation. The error should preserve the supplied bytes:

let input = [2u8; 32];
let result: Result<WireRecord, _> = zerocopy::try_transmute!(input);
let response = result.unwrap_err().into_src();

Under the hood:

  • WireRecord contains a bool followed by a u128. On the tested target, bytes 1 through 15 are padding.
  • try_transmute! moves the byte array into storage typed as MaybeUninit<ReadOnly<WireRecord>>. A typed move need not preserve destination padding.
  • Validation rejects the boolean. The error path moves that storage back into a byte array and calls assume_init(). Padding positions are now array elements, which must contain initialized integers.
  • A native build can leave prior response data in those positions and return it to the caller.

Full repro

Save the following as Cargo.toml:

[package]
name = "zerocopy-transmute-disclosure"
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 }

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

#![forbid(unsafe_code)]

use std::hint::black_box;
use zerocopy::TryFromBytes;

#[derive(Debug, TryFromBytes)]
#[repr(C)]
struct WireRecord {
    flag: bool,
    identifier: u128,
}

#[inline(never)]
fn reply(private: bool, input: [u8; 32]) -> [u8; 32] {
    if private {
        return *b"!SERVER-SECRET!0123456789abcdefg";
    }
    let result: Result<WireRecord, _> = zerocopy::try_transmute!(input);
    result.unwrap_err().into_src()
}

fn main() {
    let input = black_box([2u8; 32]);
    let mut replies = [[0u8; 32]; 4];
    for (index, saved) in replies.iter_mut().enumerate() {
        let output = black_box(reply(black_box(index % 2 == 0), input));
        *saved = output;
    }
    for (index, output) in replies.iter().enumerate() {
        println!("private={} output={output:02x?}", index % 2 == 0);
    }
    let leaked = std::str::from_utf8(&replies[1][1..15]).expect("ASCII fixture");
    println!("first unauthenticated error contains: {leaked}");
    assert_eq!(leaked, "SERVER-SECRET!");
}

The private flag represents the server's authorization decision, not attacker input. The loop calls the same handler first for an authorized response and then for a malformed request, allowing its return storage to be reused.

Run the native reproduction:

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

The final output line is:

first unauthenticated error contains: SERVER-SECRET!

The assertion verifies those fourteen bytes. They were absent from the malformed input and came from the preceding private response.

Run the same program under Miri:

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

Miri reports the error at zerocopy/src/util/macro_util.rs:555, before the caller receives the error value:

error: Undefined Behavior: constructing invalid value of type [u8; 32]: at [1], encountered uninitialized memory, but expected an integer
555 |         Err(ValidityError::new(unsafe { mu_src.assume_init() }))
    |                                         ^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here

Root cause

The source links refer to release revision 6dc429c451bdf1d7202ec1ec2cf426514e00d8eb.

  1. try_transmute requires Src: IntoBytes and Dst: TryFromBytes. These bounds permit a padding-free source and a padded destination. It converts MaybeUninit<Src> to MaybeUninit<ReadOnly<Dst>> by value.
  2. The initialization assumption relies on the original source bytes still being initialized. Destination padding is not guaranteed to survive the preceding move.
  3. The failure branch reconstructs Src from the destination storage. Its justification that the storage was never modified does not account for padding lost during typed moves. assume_init() then requires initialization that the round trip did not preserve.
  4. The standard library's MaybeUninit validity documentation explicitly limits round-trip transmutation when initialized source bytes overlap padding in the intermediate type.

[u8; 32] correctly implements IntoBytes, and the destination's validator correctly rejects the input. This defect does not require a false padding witness or an incorrectly accepted IntoBytes derive. Repairs to those checks would leave this error-recovery path unchanged.

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