Pre-Pre-RFC: `#[zeroize_on_return]` for function-boundary zeroization

Back in 2020, I proposed a #[sensitive] attribute for functions which handle transient secrets. I'm also the author of the zeroize crate.

I'd like to revisit that proposal, particularly as we continue to struggle with what to do for stack zeroization in RustCrypto, where we have effectively punted on this problem and currently don't do any sort of stack zeroization by policy. However, that policy isn't helping us in the age of LLM-assisted vulnerability reporting where we are now being spammed with reports about how we don't wipe every last secret from the stack.

This is an area where I believe some compiler assistance would be extremely beneficial.

Frank Denis a.k.a. jedisct1 wrote an excellent series of blog posts which cover various aspects of this problem. Part 1, “Wiping can make things worse”, shows C code where adding a wipe causes an additional spill which survives the wipe. Part 2, “Clearing the stack and registers”, discusses compiler assistance, cleanup at operation boundaries, and dedicated stacks. Part 3, “Copies you can’t wipe”, also makes the case for APIs which avoid materializing unnecessary secret copies.

There have been some interesting developments in both LLVM and GCC since my previous proposal. On the LLVM side, the recent Function-Boundary Zeroization RFC proposed a per-function model for this sort of zeroization. LLVM already has register-zeroing support exposed through -fzero-call-used-regs. The new RFC proposes extending that infrastructure with stack zeroization, performed after register allocation and final stack layout. That would put the clearing logic where the compiler can account for spill slots and other storage introduced during code generation.

GCC’s STRUB contexts are another possible implementation route for a GCC-based Rust backend. They use cooperation between a function and its caller: the caller initializes a stack watermark, the function updates it, and the caller scrubs the recorded region after return or exception propagation. Together with GCC’s register-zeroing support, I think this sort of first-class zeroization at the compiler level can be supported.

The LLVM RFC proposes the following Clang interface:

  • [[clang::zeroize_on_return]], which requests function-boundary zeroization.
  • [[clang::sensitive]], which optionally identifies sensitive local storage for more precise stack zeroization.
  • __builtin_clear_sensitive(), which provides guaranteed explicit clearing of a named memory region.

You could imagine a corresponding Rust API like:

#[zeroize_on_return]
pub fn secret_key_op(key: &Key, input: &[u8]) -> Output {
    // Perform an operation using transient secret material.
    // ...
}

The intended behavior would be to clear the function’s own stack storage, including compiler-generated spills, and the covered residual register state at supported exits. Return values and state required by the calling convention would be preserved.

Each function would be responsible for its own cleanup. Annotating secret_key_op would not automatically protect an unannotated helper’s stack frame. Helpers which handle secrets would need to opt in themselves. This revises the scope of my original proposal to match LLVM’s design.

The LLVM discussion also places this behavior in the compiled callee, without changing the calling convention. That seems like a useful model for Rust: a protected function would retain its cleanup when called through a function pointer or FFI, but annotating a declaration could not retrofit protection onto somebody else’s compiled code.

For LLVM codegen, #[zeroize_on_return] would map to the existing zero-call-used-regs attribute together with the proposed zeroize-stack attribute. The corresponding Clang feature is [[clang::zeroize_on_return]]; its separate [[clang::sensitive]] annotation is for local storage. As in the LLVM proposal, “on return” would be shorthand for the supported function exits, with unwind behavior specified explicitly.

The mapping to GCC STRUB contexts would need care. GCC’s strub("at-calls") mode changes the function interface, while strub("internal") preserves the external interface using a wrapper whose own frame is not scrubbed. We’d need to establish whether that machinery can meet the same Rust contract, including wrapper temporaries and register cleanup. I’d treat STRUB as a candidate implementation mechanism, with LLVM’s proposed per-function semantics defining the starting point.

For targets or codegen backends which don’t support the requested zeroization, my preference would be to accept the attribute without effect, rather than fail compilation. I see this as an opt-in hardening feature with backend-dependent support.

All that said, I thought I'd raise awareness about these features and get the ball rolling on thinking about what a Rust API for them might look like. Curious to hear any feedback on this idea. I realize the proposed LLVM stack zeroization support is still under development, and hypothetical changes there might affect a more concrete proposal, hence why this is just a "Pre-Pre-RFC".

3 Likes

What about clearing memory which is padding in the return value (which might even be an explicitly in-place-initialized return value that is operated on substantially before the return), that might contain residue of temporary state?

4 Likes

Not meant as self-advertisement, but I’d still like to mention my (much less pragmatic) vertically integrated idea: https://dl.acm.org/doi/epdf/10.1145/3735948.3736157

A niche but still relevant point for a dedicated stack (and dedicated secret memory in general) is that the kernel may need to be aware of which memory may be secret-tainted, as it may require mitigations when accessing secret data or even having it mapped. Besides syscalls, crash cleanup is another scenario that comes to my mind (mitigations, zeroization policy, etc.). A dedicated secret stack can also leverage things like memfd_secret (exclusion from swap and such like the blog post mentions, but also exclusion from the kernel’s direct map).

However, it equally doesn’t address secret-tainted padding.

2 Likes

What about clearing memory which is padding in the return value

I’d consider this an open question for the backend implementations. In particular, we should distinguish preserving the returned value from preserving every byte of its storage. The latter can include residue we would prefer to erase.

Ideally, we could preserve the meaningful contents of the result while clearing padding which might contain residual state. That would need explicit compiler support accounting for the final layout and return convention.

1 Like

Clearing padding is a little complicated because there are two different cases: a) you return by value a struct/enum that has padding, and b) you return a reference to something with padding (or return via writing through a reference that's given as an argument).

In case a), there is no conceptual problem with clearing the padding because Rust's existing semantics don't put any requirement on what happens to the padding during a move or copy (and a return by value is technically a move or copy depending on whether the type implements Copy).

In case b), however, clearing the padding on function exit would contradict some existing guarantees usable by unsafe code (in particular, writing through a reference to a struct doesn't change any padding of that struct unless you overwrite the entire struct, in which case it might or might not change the padding); additionally, it might not be enough to prevent an information leak (e.g. if another thread is concurrently reading the same memory). If you want to be compatible with Rust's current operational semantics, you would have to instead do the clearing at the time that the function overwrites memory that is externally visible, at a type that has padding, and that might be quite hard to implement.

3 Likes

Do you know of any discussions trying to address this?

1 Like

I'm not familiar with existing discussions on this topic (my above post was based around thinking about the problem myself).

1 Like

Okay. I’m moderately (at best) familiar with rustc and wildly unfamiliar with LLVM. But from what I can tell, LLVM allows type aliasing so long as the relevant parts of the layout are compatible. So:

  • Rust’s AM guarantees padding is not clobbered unless the entire struct is assigned.
  • LLVM allows aliasing the same memory between a layout with explicit padding (like a byte array field) and a layout with implicit padding if they are equal in other regards.
  • Explicit padding is never overwritten no matter what.

I’m not suggesting a design right now, I just want to figure out whether this is technically possible:

  • Similar to the proposed attribute, some zero_padding function attribute is introduced. GCC has -fzero-init-padding-bits, for example.
  • With the attribute, all padding initializes to 0 alike GCC (may also require something like -ftrivial-auto-var-init, unsure).
  • Rust never changes the padding of a partial struct update, so zero-inits are preserved. If the struct is fully updated, the value it’s updated to was originally initialized with 0 padding and also was never clobbered by partial writes.
  • Within annotated functions, all writes are performed with explicit-padding LLVM layouts so that LLVM cannot apply optimizations that would clobber 0-padding. Reads may or may not need explicit padding, I’m not sure what kind of optimizations are enabled for reading undef padding.
  • Unannotated functions work as usual; all secrets must have been zeroized before returning to or calling them.

I’m not sure this is bullet-proof, but sounds doable in principle? I’m also aware it’s unrealistic to annotate all callees all the time, but I think this can be mitigated by good practice (keeping secret-processing code minimal, zeroizing asap, etc.).

Could you please link to that?

Anyways, there is a concept "store exactly as source code says, not trying to combine multiple writes" behind write_volatile so perhaps it could be reused.

From the point of view of what the language allows unsafe code to do, references in Rust don't enforce their type on the memory that they point to, which means that in order to avoid potentially breaking unsafe code, a piece of memory can only be treated by the compiler as though it has type T at the moment you write a T to it or read a T from it (and the rest of the time, the memory is untyped). If you're accessing just a single field, the access isn't at type T and thus what padding T may have is irrelevant.

1 Like

Thanks, I didn’t consider this is specific to references, so structs that are returned directly might require a similar hack to LLVM (though this may not be as “straightforward”).