Home › Security › Applied cryptography
algorithm low

Secure memory zeroization

Problem Programs that hold keys, passwords or tokens in RAM must keep them from reaching swap, crash dumps or other processes, and erase them when done, though compilers drop erasing writes and runtimes copy buffers.

Keep secrets in one locked, fixed-size, non-copyable region, erase them with writes the compiler must keep, and exclude them from dumps and swap.

wipe O(n) bytes; lock O(pages) plus one syscall
memory-safetysecretsmlockvolatile-writecore-dumpswap
Overview

A secret that is "deleted" is usually still in RAM. The compiler may delete the erasing memset as a dead store, a growing buffer leaves its old block behind, and the kernel may write the page to swap, a core file or a hibernation image. Defence is layered: the secret lives in one fixed-size region that is never copied, is locked out of swap, is excluded from dumps, and is overwritten with stores the compiler must keep.

Key facts

Use when

  • Software that holds a key or password for longer than one call, for example:
    • Credential tools: password managers and ssh-agent holding vault or private keys.
    • Server software: TLS/SSH servers, databases holding data-encryption keys (TDE), wallets holding signing keys.
    • Payment and embedded: terminals handling card data or PINs (PCI DSS demands protecting stored keys and limiting card data exposure; no requirement number checked here), DRM and anti-cheat modules holding content or session keys.

Avoid when

  • The key can stay inside an HSM or enclave; then memory hygiene is the device's job.
  • The secret is public or low-value (a session id with a 5 min life): the wipe and mlock budget is better spent elsewhere.

Deep dive

How it works

In: a secret as bytes and its owner's lifetime · Out: the secret usable only inside its region, then erased · State: one locked region per secret, a locked flag · Cost: wipe O(n), lock one syscall per range

  1. Allocate the secret once in a dedicated heap block with exact capacity (no Vec growth, no later realloc); the owner moves a pointer, never the bytes. sodium_malloc goes further: guard page after, canary before, mlocked, outside core dumps (libsodium docs).
  2. mlock(addr, len) the pages so they are never paged to swap. Whole pages are locked; unprivileged processes are limited by RLIMIT_MEMLOCK (ENOMEM when exceeded; CAP_IPC_LOCK lifts the limit) (mlock(2)). Locks are not inherited by fork children.
  3. Exclude from dumps: madvise(MADV_DONTDUMP) per range (Linux 3.4), prctl(PR_SET_DUMPABLE, 0) or setrlimit(RLIMIT_CORE, 0) per process. MADV_WIPEONFORK (Linux 4.14) gives a forked child zero-filled pages instead of the secret.
  4. Use the bytes through short borrows (&[u8]); compare in constant time; never format or log them.
  5. Erase with stores the compiler must keep: a volatile write per byte or word, then a compiler fence so later code is not reordered before it. C equivalents are explicit_bzero (glibc 2.25, BSDs), memset_s (C11 Annex K, optional), memset_explicit (C23), SecureZeroMemory (Windows), sodium_memzero.
  6. munlock only after the wipe (sodium_munlock zeroes first, then unlocks), then free the block.
  7. Stack copies are the weak point: wipe locals and clear the stack after a batch of operations (sodium_stackzero).

Alternative to steps 2-3: memfd_secret (Linux 5.14) maps pages that are removed from the kernel direct map, locked like mlock, and block hibernation while they exist. The kernel itself cannot access them, so a pointer cannot be passed to a syscall. It is hardening, not a 100 % guarantee (man page). Before Linux 6.5 it was off by default.

Code

use std::ffi::c_void;
use std::fmt;
use std::ptr::write_volatile;
use std::sync::atomic::{compiler_fence, Ordering};

extern "C" { // libc, already linked by std (Linux/macOS)
    fn mlock(addr: *const c_void, len: usize) -> i32;
    fn munlock(addr: *const c_void, len: usize) -> i32;
}

/// Fixed-size secret on the heap: the Box pointer moves, the bytes never do.
pub struct Secret<const N: usize> { buf: Box<[u8]>, locked: bool } // no Clone/Copy

impl<const N: usize> Secret<N> {
    /// Takes ownership of the caller's bytes and wipes the source.
    pub fn take(src: &mut [u8; N]) -> Self {
        let buf: Box<[u8]> = src.to_vec().into_boxed_slice(); // capacity == N, no growth
        for b in src.iter_mut() { unsafe { write_volatile(b, 0) } }
        compiler_fence(Ordering::SeqCst);
        Secret { buf, locked: false }
    }
    /// Best effort: fails with ENOMEM past RLIMIT_MEMLOCK (unprivileged).
    pub fn lock(&mut self) -> bool {
        self.locked = unsafe { mlock(self.buf.as_ptr().cast(), N) } == 0;
        self.locked
    }
    pub fn expose(&self) -> &[u8] { &self.buf }
    pub fn wipe(&mut self) {
        let mut it = self.buf.chunks_exact_mut(8);
        for c in &mut it { unsafe { write_volatile(c.as_mut_ptr().cast::<[u8; 8]>(), [0; 8]) } } // align 1: sound
        for b in it.into_remainder() { unsafe { write_volatile(b, 0) } } // every store must happen
        compiler_fence(Ordering::SeqCst); // and not be reordered past later code
    }
    pub fn ct_eq(&self, other: &[u8; N]) -> bool {
        self.buf.iter().zip(other).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
    }
}
impl<const N: usize> Drop for Secret<N> {
    fn drop(&mut self) {
        self.wipe();
        if self.locked { unsafe { munlock(self.buf.as_ptr().cast(), N); } } // after wipe
    }
}
impl<const N: usize> fmt::Debug for Secret<N> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("Secret(***)") }
}

fn main() {
    let mut raw = [0xA5u8; 32];
    let mut s = Secret::<32>::take(&mut raw);
    assert_eq!(raw, [0u8; 32]);                  // source wiped
    assert!(s.ct_eq(&[0xA5; 32]) && !s.ct_eq(&[0xA4; 32]));
    let locked = s.lock();                        // false is possible (RLIMIT_MEMLOCK)
    assert_eq!(format!("{:?}", s), "Secret(***)");
    println!("locked={locked}");
    s.wipe();
    assert!(s.expose().iter().all(|&b| b == 0)); // sound: read before drop
    // A Vec that grows reallocates: push past capacity leaves the old block
    // (holding the secret) freed but not wiped. Never read it; just avoid it.
    let mut v: Vec<u8> = Vec::with_capacity(32);
    v.extend_from_slice(&[7; 32]);
    let cap = v.capacity();
    v.push(8);                                    // cap exceeded -> new allocation
    assert!(v.capacity() > cap);
}

The run asserts that take wipes its source, ct_eq, Debug, and that wipe zeroes the buffer (read before drop; after drop it would be use-after-free). The Vec part shows only capacity growth: the old block is freed with the secret in it.

Complexity and constants

  • Wipe is O(n) stores; with volatile stores the compiler cannot vectorise or merge them, so the cost is stores, not bandwidth. Measured on Apple M4 Pro (release, minimum of 5 runs, black_box around every call so even the plain fill is kept):
bufferplain fill(0) (memset)volatile per bytevolatile per 8-byte chunk
32 B2.8 ns9.6 ns1.5-1.8 ns
4 KiB35-41 ns1.05 µs140-240 ns (two builds)
  • mlock costs a syscall plus pinning page-table entries; not measured here. Locked memory counts against RLIMIT_MEMLOCK, often a small default for unprivileged users.
  • sodium_malloc needs 3 or 4 extra pages of virtual memory per allocation and is slower than malloc (libsodium docs): allocate secrets from a few pools, not per request.

Efficiency

Cost: a wipe of a 32-byte key is a few ns, a 4 KiB buffer about 0.14-1 µs on Apple M4 Pro (measured above); the lock is one syscall per region. The bound is store count for the wipe and page-table work for the lock, not memory bandwidth.

Levers:

  • Store wide words — chunked volatile stores were 4-7x faster than per byte at 4 KiB (measured) — any buffer above a few dozen bytes; use [u8; 8] writes, which have alignment 1 and stay sound.
  • Lock one pool at start instead of per secret — one syscall and one page per many keys (mlock(2): locking is per whole page) — servers with many keys.
  • Short lifetime, derive on demand — fewer seconds and copies exposed, costs a KDF per use — keys derived from passwords.

Naive → standard → state of the art:

  • Naive: Vec<u8> secret, fill(0) at the end · may be optimised away, old realloc copies stay
  • Standard: fixed heap region, mlock, volatile/explicit_bzero wipe on drop, MADV_DONTDUMP · a few ns to about a µs per wipe, one syscall per region
  • State of the art: key never in the process (HSM, enclave, or sealed to hardware), memfd_secret for what must stay · off-device operations cost a round trip or an enclave transition

Implementation notes

  • to_vec().into_boxed_slice() on an exact-capacity vector does not reallocate, so no second copy appears; Box::new([0; N]) may build the array on the stack first in debug builds.
  • Rust moves are memcpy: an inline Secret<N> would be copied on every move, so the bytes sit behind a pointer (zeroize docs recommend heap storage; stack spills can still leave copies).
  • Wipe in Drop (runs on unwinding too), before munlock/free. abort and SIGKILL skip it, so dump and swap exclusion must stand alone.
  • prctl(PR_SET_DUMPABLE, 0) is reset when the process changes uid/gid or execs a setuid program (prctl man page); set it after privilege changes.
  • libsodium calls mlock defence in depth: on hosts with high-value keys also disable or encrypt swap and hibernation.

Pitfalls

  • Trusting memset before free: the as-if rule lets the compiler drop it (documented in the C standard library references and by libsodium).
  • volatile on the secret variable itself: it can force a register value into RAM for its whole life (explicit_bzero man page); put the volatile on the wipe, not the data.
  • Vec/String regrowth, clone, format!, serde and Debug output each create copies the wipe never sees; the zeroize docs say its Vec impl zeroes the capacity but cannot reach earlier reallocation copies.
  • Ignoring the mlock result: past RLIMIT_MEMLOCK it fails, and sodium_malloc then still returns unlocked memory (libsodium docs).
  • Stack and registers: mlock cannot cover them (libsodium docs).
  • Zeroed values can still leak through microarchitectural channels; the zeroize docs give no guarantee against them.

Variants and alternatives

Guarded allocator (sodium_malloc)when you also want overflow detection: canary, guard pages (guard pages) and mprotect to noaccess between uses.
memfd_secretLinux 5.14+, when kernel-level reads must be hard and hibernation blocked.
Encrypt in memory (key shielding)keep the secret as ciphertext under a key that lives elsewhere, decrypt per use; OpenSSH does this for keys held by its agent.
Envelope encryption / KMSthe process holds a data key only briefly; the master key stays in the service.

In production

  • Credential tools: OpenSSH ssh-agent sets RLIMIT_CORE to 0 in ssh-agent.c, and its key-shielding code keeps private keys encrypted in memory under a pre-key mapped with MAP_CONCEAL/MAP_NOCORE where available (sshkey.c).
  • Server and crypto libraries: libsodium ships sodium_memzero, sodium_mlock and the guarded heap; the Rust zeroize crate backs Zeroize/ZeroizeOnDrop through volatile writes plus atomic fences.
  • Operating systems: Linux memfd_secret, MADV_DONTDUMP, MADV_WIPEONFORK; glibc and the BSDs explicit_bzero; Windows SecureZeroMemory.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
libsodiumCjedisct1/libsodiumbrew install libsodium————sodium_memzero, sodium_mlock, sodium_malloc guarded heap
zeroizeRustRustCrypto/utilscargo add zeroize————volatile writes + fences; derive ZeroizeOnDrop
secrecyRustiqlusioninc/cratescargo add secrecy————redacting wrappers that zeroize on drop
glibc explicit_bzeroCman pagebuilt in (glibc 2.25+)————erase call the compiler may not remove

Sources

  • Linux man-pages, mlock(2). doc
  • Linux man-pages, madvise(2) (MADV_DONTDUMP, MADV_WIPEONFORK). doc
  • Linux man-pages, memfd_secret(2). doc
  • Linux man-pages, prctl(2) (PR_SET_DUMPABLE). doc
  • Linux man-pages, explicit_bzero(3). doc
  • libsodium documentation, Secure memory. doc · code
  • RustCrypto, zeroize crate documentation. doc · code
  • Microsoft Learn, SecureZeroMemory function. doc
  • cppreference, memset, memset_explicit, memset_s (C11 Annex K K.3.7.4.1). doc
  • OpenSSH portable, sshkey.c and ssh-agent.c. code

Used by

Systems (7):

Alternatives and related

Secrets managementAny system with database passwords, API keys, signing keys or tokens must keep them out of code, logs and images and rotate them safely. This guide answers where secrets live and how workloads obtain them.
constant time code · plannedWriting secret-dependent code without branches or memory lookups on secrets; constant-time compare and select
guard pages memory sandboxing · plannedReserve large virtual regions with guard pages so bounds checks become page faults
microarchitectural side channels · plannedCache timing (Prime+Probe, Flush+Reload) and speculative-execution (Spectre, Meltdown) leaks and their mitigations
key sealing · plannedEncrypting data so only a specific measured enclave/boot state can decrypt it (TPM/SGX sealing)
Envelope encryptionServices storing sensitive data must encrypt it at rest, rotate keys and delete data reliably, without sending every byte through a key management service.
trusted execution environments · plannedEnclaves/confidential VMs with remote attestation for computing on sealed data