Freezing uninitialized values (freeze loads)

tl;dr

I would like to add the following function to the language and the standard library:

// in `core::ptr`
pub unsafe fn read_freeze<T>(ptr: *const T) -> T;

This function works like core::ptr::read(), but if the value is not initialized, it returns an arbitrary bit pattern instead of invoking undefined behavior. The caller must ensure that the ptr is valid for reads and that an arbitrary bit pattern forms a valid value of type T.

(This requires a new intrinsic that produces the LLVM freeze instruction, so it also affects the compiler, it's not just an addition to the standard library.)

My questions are whether it makes sense, what should I do next if I want to implement it, and how long do we have to wait until the compiler can use features introduced in LLVM 23, which will make this function much more useful?

Motivation

I need to "serialize" a struct T by converting &T to a &[u8; size_of::<T>()] and copying it to a memory buffer to be shipped over the network. However, that's not possible in Rust, at least not safely: T may contain uninitialized bytes due to padding, and reading these bytes to u8 is undefined behavior, because u8 must contain only initialized (defined) values.

But I still need to read those bytes! I don't care about the values of the uninitialized bytes, it should be easy for the compiler to just read the unspecified bitpattern that happens to be there and treat it as an arbitrary but defined u8. Rust currently can't do that, but it turns out that in LLVM, undefined values can be tamed by the freeze instruction, which converts undefined and poison values to an arbitrary value and leaves defined values intact.

Approach 1: MaybeUninit<T>::freeze()

There has been prior discussion about exposing the freeze operation in Rust as follows:

impl<T> MaybeUninit<T> {
    pub fn freeze(&self) -> MaybeUninit<T>;
}

This would allow me to write my "serialization" function as follows:

fn serialize_by_copying<T: Copy>(value: &T, dst: &mut [u8]) {
    assert_eq!(mem::size_of::<T>(), dst.len());
    let value_ptr = value as *const T as *const MaybeUninit<u8>;
    for i in 0..mem::size_of::<T>() {
        // this is safe, `value_ptr.add(i)` points inside the `value`, but we have to
        // read a `MaybeUninit<u8>` because the byte at offset `i` may be uninitialized
        // padding
        let byte_maybe_uninit: MaybeUninit<u8> = unsafe { value_ptr.add(i).read() }
        // now we freeze the `MaybeUninit<u8>`, so it's initialized now
        let byte_frozen: MaybeUninit<u8> = byte_maybe_uninit.freeze();
        // this is safe, because `byte_frozen` is initialized and every bit pattern is a valid `u8`
        let byte: u8 = unsafe { byte_frozen.assume_init(); };
        dst[i] = byte;
    }
}

I tried to implement this as a proof of concept, but I got stuck trying to implement an intrinsic that operates on MaybeUninit<T>. I also realized that this might be unnecessary complex, because really the only place where we can encounter uninitialized values is when reading from memory.

Approach 2: ptr::read_freeze()

Instead of trying to implement freezing for arbitrary values, it is enough to expose a fused load + freeze operation:

unsafe fn read_freeze<T>(ptr: *const T) -> T;

This operation loads a value from memory and immediately freezes it, so the uninitialized/undefined value is never exposed to the language. However, the caller must ensure that the pointer is valid for reads and that the frozen bits form a valid value of type T (so read_freeze::<bool>() of an uninitialized byte is unsound, because the frozen byte can have any value, and bool must be either 0 or 1).

With this function, my serialization function becomes:

fn serialize_by_copying<T: Copy>(value: &T, dst: &mut [u8]) {
    assert_eq!(mem::size_of::<T>(), dst.len());
    let value_ptr = value as *const T as *const MaybeUninit<u8>;
    for i in 0..mem::size_of::<T>() {
        // this is safe, because `value_ptr.add(i)` is valid for reads of `u8`, and
        // arbitrary bit pattern is a valid `u8`
        let byte: MaybeUninit<u8> = unsafe { value_ptr.add(i).read_freeze() }
        dst[i] = byte;
    }
}

I have a proof of concept implementation of read_freeze(), which generates the LLVM load followed by a freeze.

Question: does this make sense? What would be the next step if I wanted to add this function to the standard library and to the compiler, does this require an RFC or should I just open a PR?

The catch

However, there is one problem with this function: if some of the loaded bits are initialized and the others are not initialized, we would like to keep the initialized bits unchanged and only set the uninitialized bits to arbitrary values. However, in my implementation, if any loaded bit is uninitialized, the whole value will be replaced by an arbitrary bit pattern! The reason is that read_freeze::<u32>() produces LLVM code that looks like this:

%loaded = load i32, ptr %ptr, align 4
%result = freeze i32 %loaded

The problem is that a value of type i32 does not track definedness for each bit: it is either a defined integer or undef. This means that a single undefined bit "taints" the whole i32 value.

It turns out that LLVM actually has a solution for exactly this problem: the "byte" type. Somewhat confusingly, a "byte type" is not 8 bits wide but it can have an arbitrary bit width, e.g. a b32 is a byte type that is 32 bits wide. The important thing about byte types is that they faithfully represent content of memory, so each bit is either 0, 1 or undefined. This means that we can load a b32, freeze it, and then convert it to an i32 type:

%loaded = load b32, ptr %ptr, align 4
%frozen = freeze b32, %loaded
%result = bitcast b32 %frozen to i32

This sequence of operations preserves all initialized bits, and replaces only undefined bits with arbitrary values.

(The byte type is one part of the effort to remove undef values from LLVM and fix some longstanding LLVM bugs.)

Unfortunately, the byte type was added relatively recently, and it's only available in LLVM 23, which hasn't even been released yet. There is already a MR that updates the compiler to LLVM 23, but it will probably take some time for it to be merged. Moreover, the rustc-dev-guide says that one or two preceding LLVM versions are typically supported.

Question: when will rustc be able to use features introduced by LLVM 23, like the byte type? Will we have to wait for the next year or two, until LLVM 24 or LLVM 25 is released?

Any proposal for this should discuss the countless prior proposals for this, how it differs, and why the reasons for not doing that (layed in them) do not apply to it.

6 Likes

This is not true. poison applies to the whole value (or lane in vector types), but undef is partial. https://llvm.org/docs/LangRef.html#undefined-values


That said, the LLVM semantics aren't really the critical part here. What's the Rust opsem for this operation? That's the hard part that's kept it from being added.

(Aside: MaybeUninit<T> -> T is entirely writable, since the type is already #[lang = "maybe_uninit"] so you can write its type in the place that cares about intrinsic types. Of course, maybe T -> T would be enough, which would be meaningless on T = i32 but would allow using it on other unions too, not just MaybeUninit. After all, freezing will often not give a valid T -- MaybeUninit::uninit().freeze() better not give you a NonZeroU32, for example -- so whether it's valid after the freeze is a separable question.)

Would an operation with the semantics of "replace every padding byte in this value with an arbitrary initialized u8" be sufficient for the OP? (i.e. without the guarantee of preserving the value of padding bytes that happen to be initialized) That would be much easier to define from an operational semantics point of view than an operation that preserves initialised values in padding bytes (but also somewhat less useful for other purposes).

1 Like

I believe you could do this with a dummy (empty, but takes the memory of interest as a parameter) inline assembly block today. Following the story telling approach to inline assembly, the idea would be something like:

asm!("/* {} */", inout(reg) &mymaybeuninit, options(nostack));

The story here would be that the inline asm is some unspecified PRNG that sets the memory to something unknown.

I think that would be a valid story here.

EDIT: Fixed mobile autocorrect, it is supposed to be inout

2 Likes

It is. Do note the implications though:

  • You're taking a reference LLVM can't optimize, so the value won't be placed in a register.
  • This is only sound to provide as a safe function for owned MaybeUninit. Providing it for &MaybeUninit is not sound because of things like MADV_FREE.

Edit: It's possible to lift the first restriction specifically for values fitting in register by replacing the asm with:

asm!("/* {} */", inout(reg) mymaybeuninit, options(nostack, nomem));

Surely MADV_FREE would be the unsafe operation though in this scenario? It is such a weird and awful operation.

It is unsafe, but my understanding is that the understanding of T-opsem is that MADV_FREE is sound under the Rust memory model (producing MaybeUninit) and therefore providing a safe API for it is fine. So the problem has to be in your freeze.

1 Like

It's possible, though, to implement this for references if you write one byte for every page the reference covers (with volatile writes or inside the inline assembly).

The only prior proposal that I'm aware of is this:

I discuss it as "Approach 1" in my post. From reading that discussion, it seems that the reason for not doing this is simply that nobody pursued it further.

But it's very much possible that I missed some other, similar proposals. If you know about them, could you please point me to them?

I also initially thought that LLVM undef applies to individual bits, but when I read the documentation (which you also linked), it never says that; it always says that either the whole value is defined, or that the whole value is undef and can be an arbitrary bit pattern.

But I'm no expert in LLVM, so I may have just misunderstood something; if so, can you please point me to the part of the LangRef or another resource that documents that undef is, in fact, undefined on a bit-by-bit basis? This would be great, because it would allow us to implement read_freeze() soundly without waiting for LLVM 23.

(Howeer, the long-term trend for LLVM seems to be getting rid of undef and keeping only poison, so to me it seemed prudent to design the read_freeze() without relying on undef too much.)

There's a seemingly abandoned RFC for freeze: https://github.com/rust-lang/rfcs/pull/3605

1 Like

I think that this is a bit of a misunderstanding: read_freeze() would not affect memory, the uninitialized bits that it loads would still remain uninitialized. In other words, read_freeze() from uninitialized memory is allowed to produce a different value each time:

let uninit = MaybeUninit::<u32>::uninit();
let a = unsafe { ptr::read_freeze(uninit.as_ptr()) };
let b = unsafe { ptr::read_freeze(uninit.as_ptr()) };
// this CAN fail
assert_eq!(a, b);

So read_freeze() would be sound even if used on MADV_FREE-d memory.

Thank you! I don't understand how come this RFC never turned up in my googling :see_no_evil_monkey: I'll read that RFC and the discussions.

After reading the RFC, it seems that the main drawback of freeze is that its careless use can leak secrets in a very unexpected way without triggering UB; in fact, my motivating example is a prime example!

I will need to think about this, but I'm already half-convinced that's a very good reason to not include the freeze operation in the language.

Well, unsafe in general can already leak arbitrary secrets without triggering UB. That's because unsafe can call assembly, which can already read any part of memory.

That is, read_freeze is a new capability in pure Rust, but it's not a new capability for software written in Rust in general. Most native software already has some assembly somewhere (probably in the libc), and each such piece of asm must be audited to guard against secrets leaking. And anyone that has tooling to audit (and maybe forbid) assembly could also have tooling to audit/disallow read_freeze.

2 Likes

If we take the "storytelling" approach to asm blocks, then an assembler block that reads a byte from possibly uninitialized memory and returns it to safe Rust would be undefined behavior: there is no way to express this in safe Rust, so the assembly performs an operation that does not exist in the Abstract Machine, and hence it's undefined behavior. In practice, tools like Valgrind, which can detect undefined behavior at runtime, would in fact catch this.

The story could be “this asm returns completely arbitrary bytes”, and in practice it may contain secrets (even if you shouldn’t rely on that for correctness and certainly not for soundness). The new capability added by freeze is a way to make the returned bytes not entirely arbitrary (by preserving initialized bytes).

If the read is in-bounds (to some address in the stack or heap or whatever which permits being read), then the read shouldn’t trap and valgrind shouldn’t complain.

1 Like

One thing that I've seen before that isn't captured in here: There are a lot of APIs that take an &mut [u8] and write to it (e.g. std::io::Write::write). This API requires you first initialize the slice to have some value, just for the bytes to be overwritten (assuming a reasonable Write impl). I've never profiled it on my code, but people have complained here about the performance impact.

If we instead have unsafe fn MaybeUninit::<T>::freeze(&mut self) -> &mut T, then your approach still works (cast to [u8; size_of::<T>()], freeze, and then read the bytes from the array). Or if we have a free unsafe const fn freeze<T>() -> T which, when written to a place, keeps whatever bytes were there, then that helps this problem while not helping yours much.

This should at least mention not supporting this case as a drawback, or maybe the correct answer is for Rust to have multiple functions relating to this.

The fix for that is BorrowedBuf in core::io - Rust, not freeze.

5 Likes