Skip to content

Derive macros are unsound due to types being mentioned twice #2762

@theemathas

Description

@theemathas

This is a different unsoundness than #388.

The derive macros expand to code that mentions the field types, in order to validate that they implement the expected traits. This doesn't work if mentioning the same type name twice produces a different type. This can happen if the type is defined as a macro invocation that's nondeterministic.

The code below causes a segmentation fault. (Also in a zip file with all files necessary to reproduce, for your convenience.)

dep/src/lib.rs:

use proc_macro::TokenStream;
use std::sync::atomic::{AtomicI32, Ordering::SeqCst};

static COUNTER: AtomicI32 = AtomicI32::new(0);

#[proc_macro]
pub fn make_type(_: TokenStream) -> TokenStream {
    let index = COUNTER.fetch_add(1, SeqCst);
    match index {
        0..=2 => "i32",
        3 => "&'static i32",
        _ => panic!(),
    }
    .parse()
    .unwrap()
}

src/main.rs:

use dep::make_type;
use zerocopy::FromBytes;

#[derive(FromBytes)]
struct Thing {
    field: make_type!(),
}

fn main() {
    let thing: Thing = Thing::read_from_bytes(&1usize.to_ne_bytes()).unwrap();
    let field: &'static i32 = thing.field;
    println!("{field}");
}
Macro expansion
#![feature(prelude_import)]
#[macro_use]
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;
use dep::make_type;
use zerocopy::FromBytes;

struct Thing {
    field: &'static i32,
}
#[allow(deprecated)]
#[automatically_derived]
unsafe impl ::zerocopy::TryFromBytes for Thing<> where
    i32: ::zerocopy::TryFromBytes {
    fn only_derive_is_allowed_to_implement_this_trait() {}
    fn is_bit_valid<___ZerocopyAliasing>(_candidate:
            ::zerocopy::Maybe<Self, ___ZerocopyAliasing>)
        -> ::zerocopy::util::macro_util::core_reexport::primitive::bool where     
        ___ZerocopyAliasing: ::zerocopy::pointer::invariant::Reference {
        if false {
            fn assert_is_from_bytes<T>() where T: ::zerocopy::FromBytes,
                T: ?::zerocopy::util::macro_util::core_reexport::marker::Sized {} 
            assert_is_from_bytes::<Self>();
        }
        true
    }
}
#[allow(deprecated)]
#[automatically_derived]
unsafe impl ::zerocopy::FromZeros for Thing<> where i32: ::zerocopy::FromZeros    
    {
    fn only_derive_is_allowed_to_implement_this_trait() {}
}
#[allow(deprecated)]
#[automatically_derived]
unsafe impl ::zerocopy::FromBytes for Thing<> where i32: ::zerocopy::FromBytes    
    {
    fn only_derive_is_allowed_to_implement_this_trait() {}
}

fn main() {
    let thing: Thing = Thing::read_from_bytes(&1usize.to_ne_bytes()).unwrap();    
    let field: &'static i32 = thing.field;
    { ::std::io::_print(format_args!("{0}\n", field)); };
}

The make_type!() macro expands to i32 three times, and &'static i32 once. Turns out that the order of macro expansion ends up causing the field field to be defined as a &'static i32, but zerocopy instead checks whether i32 implements FromBytes. The zerocopy derive macro then implements the FromBytes on the Thing type, which can then be exploited to cause UB.

The same issue exists in bytemuck.

For the issue about this as a systematic problem, see rust-lang/rust#148793.

See also rust-lang/rust#147103, for a similar issue in std, although that case didn't cause unsoundness.

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