Skip to content

Soundness: UB reading uninitialized union memory in manual PartialEq and Hash implementations #5187

Description

@Manishearth

Note

This finding was identified during an agentic unsafe Rust code review performed by Gemini AI, followed by human review and verification.

The Issue

Standard Rust derive macros reject union types because unions overlay multiple fields of different types and sizes across the same memory address. This crate correctly enforces this restriction in the standard s! macro (compile_error!("unions cannot derive extra traits...")). However, for several platform-specific C unions defined via s_no_extra_traits!, PartialEq and Hash are manually implemented when #[cfg(feature = "extra_traits")] is enabled.

In these manual implementations, the code unconditionally accesses union variants regardless of which variant was initialized. For example, in __c_anonymous_ptrace_syscall_info_data (spanning 64 bytes):

impl PartialEq for __c_anonymous_ptrace_syscall_info_data {
fn eq(&self, other: &__c_anonymous_ptrace_syscall_info_data) -> bool {
unsafe {
self.entry == other.entry
|| self.exit == other.exit
|| self.seccomp == other.seccomp
}
}
}
impl Eq for __c_anonymous_ptrace_syscall_info_data {}
impl hash::Hash for __c_anonymous_ptrace_syscall_info_data {
fn hash<H: hash::Hasher>(&self, state: &mut H) {
unsafe {
self.entry.hash(state);
self.exit.hash(state);
self.seccomp.hash(state);
}

When an instance is initialized in Rust using the smaller exit variant (occupying 16 bytes), the remaining 48 bytes of the union remain uninitialized memory. When PartialEq::eq evaluates self.entry == other.entry (which spans 56 bytes), it reads 40 bytes of uninitialized union padding as u64 integers.

Other affected union types across libc include:

  • src/unix/bsd/apple/mod.rs (semun, __c_anonymous_ifk_data,
    __c_anonymous_ifr_ifru)
  • src/vxworks/mod.rs (sa_u_t, sigval)
  • src/unix/solarish/mod.rs (pad128_t, upad128_t)
  • src/unix/bsd/freebsdlike/freebsd/mod.rs (__c_anonymous_cr_pid,
    __c_anonymous_elf32_auxv_union)

Furthermore, these manual implementations violate standard PartialEq and Hash trait coherence invariants: if two instances have identical entry fields but differing uninitialized memory in their exit variants, a == b evaluates to true, but Hash::hash unconditionally hashes all fields, resulting in hash(a) != hash(b).

Minimal Reproduction (Miri)
// Reproduction test case for Miri (requires `extra_traits` feature flag enabled)
// Run via: cargo miri run --features extra_traits

use libc::{__c_anonymous_ptrace_syscall_info_data, __c_anonymous_ptrace_syscall_info_exit};

fn main() {
    let exit_struct = __c_anonymous_ptrace_syscall_info_exit {
        sval: 0,
        is_error: 0,
    };

    // Initializing the union via the smaller `exit` variant (16 bytes)
    // leaves the remaining 48 bytes of the 64-byte union uninitialized.
    let a = __c_anonymous_ptrace_syscall_info_data {
        exit: exit_struct,
    };
    let b = __c_anonymous_ptrace_syscall_info_data {
        exit: exit_struct,
    };

    // Trigger PartialEq::eq.
    // The manual implementation compares `self.entry == other.entry` (56 bytes),
    // reading 40 bytes of uninitialized union padding as u64 integers.
    // Miri flags immediate Undefined Behavior (reading uninitialized memory).
    let _ = a == b;
}
error: Undefined Behavior: reading memory at alloc109[0x8..0x38], but memory is uninitialized at [0x9..0x38], and this operation requires initialized memory
   --> core/src/array/equality.rs:157:18
    |
157 |         unsafe { crate::intrinsics::raw_eq(a, crate::mem::transmute(b)) }
    |                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
Suggested Fix

Unions in C/C++ FFI bindings cannot soundly implement PartialEq, Eq, or
Hash because there is no runtime tag indicating which union variant is
currently active.

We suggest removing manual PartialEq, Eq, and Hash implementations for all union types defined via s_no_extra_traits!, ensuring they behave identically to unions defined via s! where deriving extra traits is explicitly prohibited.


Note

The full audit report below also contains additional minor findings (such as missing safety comments or undocumented FFI assumptions) that are probably worth fixing as well but not the primary goal of this issue.

Full Gemini Unsafe Code Audit Report

Unsafe Rust Review: libc (v0_2)

Overall Safety Assessment

The libc crate (v0_2, audited at version 0.2.183) is the foundational
Foreign Function Interface (FFI) binding library for the Rust ecosystem. It
defines cross-platform C types, constants, struct layouts, unions, and extern "C" function signatures corresponding to standard C libraries and system call
ABIs across dozens of target operating systems and architectures.

The explicit unsafe surface area of libc is immense in raw line count, but
structurally monolithic in nature:

  1. Almost the entire codebase consists of external C function declarations
    inside extern blocks. Prior to Rust Edition 2024, these declare unsafe fn items by default.
  2. Target data structures are defined with explicit C layout representations
    (#[repr(C)], #[repr(packed)], #[repr(align(...))], and
    #[repr(transparent)]) via declarative macro systems (s!,
    s_no_extra_traits!, s_paren!, c_enum!, f!, and safe_f!). Notably,
    the s! macro soundly prevents deriving PartialEq, Eq, and Hash on C
    union types by triggering an explicit compile_error!.
  3. Executable Rust code within the crate is limited to macro-generated accessor
    methods on complex kernel structures (e.g. siginfo_t), polyfills for
    platform compatibility (e.g. unix/solarish/compat.rs), CPU affinity
    bitmask operations (CPU_COUNT_S, etc.), and manual trait implementations
    under the optional extra_traits feature flag.

Architecturally, the crate adheres to strict FFI compatibility principles.
However, rigorous security auditing under unsafe-rust-review-joshlf
proof-obligation standards reveals 2 Critical findings related to Undefined
Behavior (UB) when implementing traits on C union types and accessing flexible
array members under the extra_traits feature flag. Additionally, several
Fishy findings were identified regarding undocumented FFI hazards in
platform polyfills, accompanied by systematic omissions of // SAFETY: comments
and # Safety contracts on executable Rust code.

Critical Findings

1. Undefined Behavior reading uninitialized union memory in manual PartialEq and Hash implementations under extra_traits 🔴 🤦

  • Severity: 🔴 High
  • Threat Vector: 🤦 Accidental Misuse
  • Bug Type: Uninitialized Memory Read

Locations:

  • src/unix/linux_like/linux/gnu/mod.rs:467-485
    (__c_anonymous_ptrace_syscall_info_data)
  • src/unix/bsd/apple/mod.rs:1675-1685 (semun)
  • src/unix/bsd/apple/mod.rs:1712-1765 (__c_anonymous_ifk_data,
    __c_anonymous_ifr_ifru)
  • src/vxworks/mod.rs:619-657 (sa_u_t, sigval)
  • src/unix/solarish/mod.rs:626-660 (pad128_t, upad128_t)
  • src/unix/bsd/freebsdlike/freebsd/mod.rs:1791-1830 (__c_anonymous_cr_pid,
    __c_anonymous_elf32_auxv_union)

Description: Standard Rust derive macros reject union types because
unions overlay multiple fields of different types and sizes across the same
memory address. The libc authors correctly enforced this restriction in the
standard s! macro (compile_error!("unions cannot derive extra traits...")).
However, for several platform-specific C unions defined via
s_no_extra_traits!, the authors manually implemented PartialEq and Hash
when #[cfg(feature = "extra_traits")] is enabled.

In these manual implementations, the code unconditionally accesses union
variants. For example, in __c_anonymous_ptrace_syscall_info_data (spanning 64
bytes):

impl PartialEq for __c_anonymous_ptrace_syscall_info_data {
    fn eq(&self, other: &__c_anonymous_ptrace_syscall_info_data) -> bool {
        unsafe {
            self.entry == other.entry
                || self.exit == other.exit
                || self.seccomp == other.seccomp
        }
    }
}

When an instance is initialized in Rust using the smaller exit variant
(occupying 16 bytes), the remaining 48 bytes of the union remain uninitialized
memory. When PartialEq::eq evaluates self.entry == other.entry (which spans
56 bytes), it reads 40 bytes of uninitialized union padding as u64 integers.

According to authoritative Rust validity rules (Rust Reference § Behavior
considered undefined) and verified by the Miri execution model, reading
uninitialized memory as an initialized integer, floating-point number, or
non-null pointer type is immediate Undefined Behavior.

Coherence & Law Violations: Furthermore, these manual implementations
violate standard PartialEq and Hash trait invariants (coherence theorem):

  • In ptrace_syscall_info_data, if two instances have identical entry
    fields but uninitialized/differing memory in their exit variants, a == b
    evaluates to true. However, Hash::hash unconditionally hashes all three
    fields (self.entry.hash(state); self.exit.hash(state); self.seccomp.hash(state)), resulting in hash(a) != hash(b). This breaks
    HashMap/HashSet invariants.
  • In __c_anonymous_elf32_auxv_union on FreeBSD (freebsd/mod.rs:1827),
    PartialEq is implemented, but Hash::hash unconditionally panics with
    unimplemented!("traits").

2. Out-of-bounds slice reads in flexible array member idiom af_alg_iv 🔴 🤦

  • Severity: 🔴 High
  • Threat Vector: 🤦 Accidental Misuse
  • Bug Type: Out-of-Bounds Read

Locations:

  • src/unix/linux_like/linux/mod.rs:1287-1295
  • src/unix/linux_like/linux/mod.rs:1389-1410

Description: The Linux kernel AF_ALG socket initialization vector structure
is defined as:

pub struct af_alg_iv {
    pub ivlen: u32,
    pub iv: [c_uchar; 0],
}

Here, iv is a C flexible array member idiom ([u8; 0]). The helper method
as_slice() is implemented as:

impl af_alg_iv {
    fn as_slice(&self) -> &[u8] {
        unsafe { ::core::slice::from_raw_parts(self.iv.as_ptr(), self.ivlen as usize) }
    }
}

When extra_traits is enabled, PartialEq::eq and Hash::hash unconditionally
invoke self.as_slice(). If a user allocates a standalone af_alg_iv instance
on the stack or heap without consecutive trailing buffer allocation (e.g. let iv = af_alg_iv { ivlen: 32, iv: [] };), the allocated object size is exactly 4
bytes. Invoking as_slice() (directly or via == / hash()) constructs a
32-byte slice reference extending past the allocated memory bounds into
arbitrary memory. Reading this slice is immediate Undefined Behavior.

Although the crate authors deprecated af_alg_iv in version 0.2.80 with the
explicit warning "WARNING: The PartialEq, Eq and Hash implementations of
this type are unsound and will be removed in the future"
, the unsound trait
implementations remain publicly exposed under extra_traits.

Fishy Findings

1. Public unsafe FFI polyfills exported without # Safety contracts 🟡 🤦

  • Severity: 🟡 Low
  • Threat Vector: 🤦 Accidental Misuse
  • Bug Type: Undocumented Safety Contract

Location: src/unix/solarish/compat.rs:12, 37, 59, 133, 184, 204 Unlike raw
extern "C" declarations where # Safety documentation is omitted by ecosystem
convention, compat.rs implements executable Rust polyfills (cfmakeraw,
cfsetspeed, openpty, forkpty, getpwent_r, getgrent_r) marked pub unsafe fn. These functions perform raw pointer dereferencing, system ioctls,
and unchecked buffer copies (strcpy). None of these public exports document a
# Safety section detailing caller preconditions (such as buffer allocation
capacities and non-null requirements).

Notably, comments inside openpty (compat.rs:120) explicitly warn of a
classic FFI buffer overflow vulnerability: "Note that this is a terrible
interface: there appears to be no standard upper bound on the copy length for
this pointer"
. Yet no proof obligation contract is specified for callers
passing name.

2. Inconsistent safety classification of errno() retrieval 🟡 🤦

  • Severity: 🟡 Low
  • Threat Vector: 🤦 Accidental Misuse
  • Bug Type: API Inconsistency

Locations: src/teeos/mod.rs:1333 vs src/new/qurt/errno.rs:148 In
teeos/mod.rs, errno() is exported as a safe function (pub fn errno() -> c_int), whereas in new/qurt/errno.rs, it is exported as pub unsafe fn errno() -> c_int. Retrieving the current thread's error number via
*__errno_location() has no safety preconditions in standard C runtime
implementations and is universally sound to call from safe code. Marking it
unsafe fn on QURT creates unnecessary caller proof obligations.

Missing Safety Comments

Executable Rust code across libc systematically omits // SAFETY: comments
inside unsafe blocks and unsafe fn bodies. The following enumerate
representative sites along with rigorous proposed proof obligations:

1. src/macros.rs:454 (offset_of! macro) 🟠

(Note: This site correctly includes a safety comment, serving as a positive
architectural baseline)
:

        // SAFETY: computed address is inbounds since we have a stack alloc for T
        let fptr = unsafe { core::ptr::addr_of!((*ptr).$field) };

2. src/unix/linux_like/mod.rs:1790 & 1794 (SIGRTMAX and SIGRTMIN) 🔴

safe_f! {
    pub fn SIGRTMAX() -> c_int {
        // SAFETY: `__libc_current_sigrtmax()` queries runtime real-time signal bounds from libc. It has no caller safety preconditions (AXIOM: POSIX signal specification).
        unsafe { __libc_current_sigrtmax() }
    }

3. src/teeos/mod.rs:1334 (errno) 🔴

pub fn errno() -> c_int {
    // SAFETY: `__errno_location()` returns a valid, non-null, thread-local pointer to `c_int` (AXIOM: C standard library contract). Dereferencing it for reads is always valid on the active thread.
    unsafe { *__errno_location() }
}

4. src/unix/linux_like/linux/mod.rs:1391 (af_alg_iv::as_slice) 🔴

(Assuming sound allocation bounds precondition):

        impl af_alg_iv {
            fn as_slice(&self) -> &[u8] {
                // SAFETY: `self.iv.as_ptr()` points to the flexible array member trailing `self`. Assuming caller allocated at least `self.ivlen` consecutive bytes after the struct header, the memory range `[as_ptr(), ivlen]` is in-bounds, aligned, and initialized.
                unsafe { ::core::slice::from_raw_parts(self.iv.as_ptr(), self.ivlen as usize) }
            }
        }

5. src/unix/linux_like/linux/gnu/mod.rs:389, 402, 433 (siginfo_t accessors) 🔴

    pub unsafe fn si_addr(&self) -> *mut c_void {
        // ... struct definitions ...
        // SAFETY: `self` is a valid reference to `siginfo_t`. Casting `self` to layout-compatible union overlay structs (`siginfo_sigfault`, `siginfo_timer`, etc.) and accessing fields is valid provided the caller verified `si_signo` and `si_code` correspond to this active union variant.
        (*(self as *const siginfo_t).cast::<siginfo_sigfault>()).si_addr
    }

6. src/wasi/mod.rs:66-67 (clockid_t Send and Sync) 🔴

// SAFETY: `clockid_t` wraps a raw pointer `*const u8` acting as an opaque, immutable WASI clock identifier handle. Clock handles are thread-safe constants managed by the WASI host environment, so transferring or sharing them across threads is sound.
unsafe impl Send for clockid_t {}
// SAFETY: See Send implementation.
unsafe impl Sync for clockid_t {}

7. src/unix/solarish/compat.rs:59 (openpty FFI polyfill export) 🔴

/// # Safety
/// - `amain` and `asubord` must be valid, aligned pointers valid for writes of `c_int`.
/// - If `name` is non-null, it must point to a buffer large enough to contain the null-terminated PTS device path (typically `PATH_MAX` bytes) without overflowing.
/// - `termp` and `winp` may be null or must point to valid initialized instances of `termios` and `winsize`.
pub unsafe fn openpty( ... ) -> c_int { ... }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions