Zeroing Secrets in Rust Doesn't Survive the Optimiser
Write a Drop impl that zeroes out a byte array holding a private key, build in release mode, and dump the process memory afterwards. The key is still there. Not corrupted, not partially overwritten: the exact same bytes, as if the zeroing code never ran at all.
This isn't a bug in your code. It's the optimiser doing exactly what it's allowed to do. A write to memory that is never read afterwards, and about to be freed, has no observable effect under Rust's (and LLVM's) as-if rule, so the compiler is free to delete it entirely.
The naive version, and why it disappears
Here's the version most people write first:
struct Key {
bytes: [u8; 32],
}
impl Drop for Key {
fn drop(&mut self) {
for b in self.bytes.iter_mut() {
*b = 0;
}
}
}
Compile this in debug mode and the loop runs, plainly, in the binary. Compile it in release and LLVM's dead store elimination pass looks at self.bytes, sees that nothing reads it after the loop, and that the whole struct is about to go out of scope, and removes the loop's stores entirely.
Quick detour, because it's worth seeing the proof rather than trusting a blog post. Paste the struct into a tool like Compiler Explorer with optimisations on, or run cargo asm against the crate.
In the debug build, the zeroing loop shows up as four visible 32-bit stores. In a release build, the entire drop function often compiles down to nothing: no loop, no stores, sometimes no function call at all, because the whole thing has been inlined and then deleted.
The fix: a volatile write and a compiler fence
The fix has two ingredients. A volatile write, via std::ptr::write_volatile, tells the compiler this memory access has an externally observable side effect, so it cannot be optimised away no matter what reads follow it. A compiler_fence, separately, stops the compiler reordering other memory operations across that point.
use std::ptr;
use std::sync::atomic::{compiler_fence, Ordering};
impl Drop for Key {
fn drop(&mut self) {
for b in self.bytes.iter_mut() {
unsafe { ptr::write_volatile(b, 0) };
}
compiler_fence(Ordering::SeqCst);
}
}
The write_volatile call stops LLVM deleting that particular store. It doesn't stop LLVM moving other, non-volatile stores to happen before or after it, because volatile only pins that one access, not the surrounding code.
compiler_fence(Ordering::SeqCst) closes that gap. It's a purely compile-time barrier: it has no effect on CPU instruction reordering (for that you'd want a hardware fence), but it tells LLVM's optimiser not to reorder or merge memory accesses across the line. Together, the two calls mean the zeroing loop survives, in order, into the final binary.
Where the bytes can still be hiding
None of this guarantees the key only ever existed in one place. Rust moves values by copying bytes, and a move that happens before drop runs can leave a stale copy behind. A few other places can keep a plaintext copy long after you think it's gone:
- Whatever stack slot or register held the value before it was moved into the struct you eventually dropped.
- Registers spilled to the stack during an unrelated function call, and never overwritten afterwards.
- A
VecorStringthat reallocated while holding the secret; the old, smaller buffer is freed but never cleared. - Swap space, if the page was ever evicted from RAM, or a core dump or hibernation image taken while it was live.
Let the zeroize crate do this properly
Writing the volatile-write-plus-fence dance by hand, correctly, for every secret type in a codebase is exactly the kind of repetitive, easy-to-get-subtly-wrong task a crate should own. The zeroize crate does, and its implementation is checked against disassembly in CI, not just reasoned about.
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Zeroize, ZeroizeOnDrop)]
struct Key {
bytes: [u8; 32],
}
That derive generates the same volatile-write loop under the hood, using the same reasoning as above, and it's exercised against disassembly checks so a future LLVM release that gets cleverer about elision doesn't quietly reopen the hole. For a type you don't own, wrap it in Zeroizing<T> instead, which zeroes on drop and derefs to the inner type.
Proving it happened, in a test
The same optimiser that deletes your zeroing can delete your test for it, if the test just reads the bytes back and asserts they're zero without doing anything else with them. std::hint::black_box exists for exactly this: it stops the compiler treating a value as dead purely because nothing downstream uses it.
use std::hint::black_box;
#[test]
fn key_is_zeroed_on_drop() {
let key = Key { bytes: [0xAB; 32] };
let ptr = key.bytes.as_ptr();
drop(key);
let after = unsafe { std::slice::from_raw_parts(ptr, 32) };
assert_eq!(black_box(after), &[0u8; 32]);
}
This is the same trick zeroize's own test suite uses: read the memory back through a raw pointer after an explicit drop, and wrap the read in black_box so the whole check can't be folded away at compile time.
zeroize solves the compiler half of this problem: it guarantees the write actually reaches memory. It says nothing about whether that memory later gets swapped to disk, captured in a core dump, or copied into a debugger's stack trace before the fence runs. If that's in your threat model, you want mlock, not just a clean Drop impl.