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:
- Scrub anyway, with the strongest primitive you have —
memset_explicit(C23),explicit_bzero(glibc/BSDs), or your platform's equivalent. A surviving erase of the named buffer beats nothing; just know it covers that buffer and only that buffer. - Consider the hardening flags with clear eyes.
-fzero-call-used-regs=used-gpris deployable at scale (the kernel does), andstrubon GCC 14+ covers stack frames of annotated functions. Combined, they still leave the gaps above — but each one shrinks the residue window. - Verify at the binary, not the source. The only level where "is the secret gone?" is even well-defined is the compiled artifact. Taint-tracking tools in the style of Secretgrind, or binary analyzers like Binsec/Rel, can check what your compiler actually emitted.
- Treat every claim of "we zeroize memory" in a code review as a question, not an answer. Ask: which compiler, which flags, and who checked the assembly?
Secret erasure is a property of the binary. Today, no shipping compiler will promise it to you — so trust, but disassemble.