writing / zeroization-blogpost

Your compiler won't keep your secrets

2026 · 03 · 08 ·9 min ·compilers·security

You just finished handling a password. You know better than to leave it lying around in memory, so you scrub it before returning:

#include <string.h>

int check_password(const char *input) {
    char buf[64];
    strncpy(buf, input, sizeof(buf) - 1);
    buf[sizeof(buf) - 1] = '\0';
    int ok = verify(buf);
    memset(buf, 0, sizeof(buf));   // scrub the secret
    return ok;
}

Compile that with -O2 and look at the assembly: the memset is gone. Not moved, not weakened — deleted. The optimizer proved that buf is never read after the write, and a store nobody reads is dead code. This is dead-store elimination, one of the most basic optimizations in every production compiler, and its removal of scrubbing code is common enough to have its own weakness ID: CWE-14.

The uncomfortable part: the compiler is right. C and C++ semantics are defined against an abstract machine, and in that machine your program's observable behavior is identical with or without the memset. What the abstract machine cannot express is the thing you actually cared about — that after the function returns, no copy of the password remains anywhere in the process. Researchers call this the correctness-security gap: a transformation can be formally correct and still destroy a security property, because the property lives in the state of the real machine, which the language semantics deliberately ignores.

The fixes that don't fix it

This problem has been known for decades, and the ecosystem has accumulated partial answers. None of them close the gap.

memset_s was standardized in C11's optional Annex K precisely so the compiler couldn't remove it. In practice you can't use it: GCC, Clang, and MSVC toolchains never defined __STDC_LIB_EXT1__, and WG21's own analysis concluded that "in practical terms, there is no standard solution yet for C nor C++".

The library workarounds — OpenSSL's OPENSSL_cleanse, mbedTLS's volatile-function-pointer trick, glibc's explicit_bzero — are ad-hoc idioms that defeat today's optimizers by being too opaque to analyze. There is no language rule that says they must keep working; they are a bet that the optimizer stays sufficiently blind.

C23 finally standardized memset_explicit, a memset the compiler must not elide. But read the fine print of how it got there: when the C++ committee reviewed its predecessor proposal and was polled on whether the guarantee should extend beyond memory — to registers, caches, copies the compiler makes on its own — the result was neutral, and the wording adopted into C23 guarantees only that the memory write happens. A secret that lives in a register is out of reach by design.

That last point matters more than it looks, because the compiler makes copies you never wrote.

The copies you can't reach from C

Your source code has one variable holding the key. The compiled function has that variable in a register, an intermediate value derived from it in another register, possibly a spilled copy on the stack because the register allocator ran out of registers, and possibly another stack copy because the value had to survive a function call. When researchers pointed a taint tracker at OpenSSL, mbedTLS, and GnuPG — libraries that already use hardened erasure functions — they still found secret residue left behind by register spills, calling conventions, and helper functions. Their conclusion: "even these specially-crafted erasure functions do not provide high guarantees in practice."

No amount of careful C can fix this, because the copies are created below the level C can talk about. If the guarantee is going to exist, the compiler has to provide it. So what do compilers actually offer?

What compilers offer today: two halves that don't meet

-fzero-call-used-regs: the register half

GCC 11 and Clang 15 ship a flag that zeroes call-used registers — the scratch registers a function is allowed to clobber — right before returning, so whatever the function computed doesn't leak back to its caller in dead registers. Here's a function that mixes secret key material, compiled for arm64 at -O2 without the flag:

mix_baseline:
        eor     x8, x1, x0          ; x8 := key ^ msg — secret-derived
        mov     x9, #31765          ; multiplier constant (public)
        ...
        mul     x0, x8, x9
        ret                         ; x8 still holds key ^ msg in the caller

And with -fzero-call-used-regs=used:

        mul     x0, x8, x9
        mov     x1, #0
        mov     x8, #0
        mov     x9, #0
        ret

The scratch registers are cleared. Progress! But look closer and you can see what this mechanism is and isn't:

It zeroes registers, not secrets. The public multiplier in x9 gets scrubbed as eagerly as the secret in x8. The flag has no idea which values are sensitive — it can't skip the public ones to save cost, and more importantly it can't follow the secret ones anywhere. The secret-derived value in x0 flows to the caller uncleared (it's the return value; that's the point), and the mechanism has nothing to say about what happens to it next.

It never touches the stack. Force a secret into memory — say, an expanded key schedule whose address is passed to another function — and the strongest mode (all) diligently emits over forty register-zeroing instructions while leaving all 32 bytes of key material sitting in the function's stack frame:

        eor     x9, x0, x8
        stp     x0, x9, [sp, #8]    ; key schedule written to the frame
        add     x9, x0, x8
        mul     x8, x0, x8
        stp     x9, x8, [sp, #24]
        add     x0, sp, #8
        bl      use
        ...
        add     sp, sp, #64         ; frame popped — contents intact
        mov     x1, #0              ; ...41 register clears...
        ret                         ; 32 bytes of key schedule readable below sp

The next function call to reuse that stack region gets a free copy of your key schedule.

Callee-saved registers are out of scope — and they leak in both directions. A value that must survive a function call gets parked in a callee-saved register like x19. "Call-used" zeroing never clears it. Worse, the prologue pushes the caller's x19 into this function's frame (memory nothing will clear), and if the callee also wants x19, it pushes your secret into its own frame. Exit-time register zeroing can't reach copies that were spilled to memory mid-function. LLVM's maintainers know this: in the same issue tracker thread, one notes that prologue spills can put scratch registers straight into readable stack memory, and describes the flag's protection as feeling "a little bit like security theater," since a signal can dump the entire register file to memory at any instruction anyway.

The compilers don't even agree on what a "return" is. A tail call exits the function via a jump, not a ret. Under the same =used flag, Clang on arm64 clears the scratch register before the branch, but Clang on x86-64 emits nothing at all:

tail_caller:
        xorq    $23130, %rdi
        jmp     g@PLT                   # TAILCALL — no zeroing on x86-64

GCC skips zeroing at tail calls on both. This inconsistency is an open LLVM issue; the point isn't the specific bug but what it reveals — without a defined contract for which exits must be clean, every backend improvises.

To be fair to the flag: its documented goals are exploit mitigation (fewer live registers means fewer useful ROP gadgets) and general information-leak reduction — both best-effort, with the maintainers explicitly disclaiming any secrecy guarantee — and it's cheap enough that the Linux kernel builds with it. As hardening, it's a real win. As secrecy, nobody is promising you anything.

strub: the stack half

GCC 14 added the other half: stack scrubbing. Mark a function with the strub attribute and its used stack memory is zeroed when it returns — including when it exits via an exception, which the register flag doesn't model at all. The implementation is genuinely clever: the caller and callee cooperate through a "watermark" that tracks how deep the callee's stack actually reached, and the caller scrubs up to that line after regaining control.

But strub is the mirror image of -fzero-call-used-regs: it clears used stack memory and nothing else. Secrets that live only in registers are untouched. And it's GCC-only — Clang/LLVM has no equivalent.

So the current state of the art in shipping compilers is: one mechanism for registers (both compilers, best-effort, inconsistent at tail calls), one mechanism for the stack (GCC only, no registers), and neither has any concept of which values are actually secret. There is no way to say the thing you mean: "when this function returns, every copy of what it computed from the key — registers, stack, spills — is gone."

It's not impossible — it's just not shipped

The research community has built exactly this, twice.

The zerostack prototype (Simon, Chisnall & Anderson, EuroS&P 2018) patched Clang/LLVM to zero both the stack and registers of sensitive functions, with the enforcement pass running in the backend after register allocation — the point where the compiler finally knows which physical registers and stack slots a function really used, and after which no later optimization can quietly undo the scrubbing. Measured overhead on OpenSSL's X25519: under 1%.

At the formally verified end, the Jasmin compiler's zeroization extension (TCHES 2024) proves the property all the way down: at return from a protected routine, used stack memory, registers, and flags are clean — with the compiler's correctness proof extended to cover it. Same architectural conclusion: the guarantee is enforced at the bottom of the pipeline, not in source code.

Both systems agree on the lesson. Source-level scrubbing fails because it fights the optimizer on the optimizer's home turf. The property "no secrets remain at function exit" is a property of the final machine code, so the enforcement has to live where the machine code is decided.

What to do in the meantime

Until mainstream compilers offer that guarantee, the practical playbook is about reducing exposure and verifying the result:

Secret erasure is a property of the binary. Today, no shipping compiler will promise it to you — so trust, but disassemble.