I Read OpenSSL for Fun and Found a Nonce Leak The article describes a single-character logic bug discovered in OpenSSL version 4.0.0's implementation of the SLH-DSA post-quantum signature algorithm. The bug occurs in the `slh_dsa_sign()` function, where a conditional check `if (opt_rand != add_rand)` incorrectly wipes the wrong memory buffer, causing the cryptographic nonce (stored in the stack buffer `add_rand`) to remain uncleaned after signing operations. This nonce leak violates basic cryptographic hygiene by leaving sensitive randomness exposed on the stack, potentially compromising security in post-quantum signing processes. I Read OpenSSL for Fun and Found a Nonce Leak https://blog.himanshuanand.com/2026/05/i-read-openssl-for-fun-and-found-a-nonce-leak/ Table of Contents I was poking around the OpenSSL source code recently. Not really hunting for anything specific one of the most heavily audited codebases , just curious about how the new post-quantum crypto stuff was wired up in version 4.0.0. I went in expecting to find nothing interesting. Instead I tripped over a single-character logic bug that leaks cryptographic randomness onto the stack on every signing call. Quick disclaimer: I am not a crypto person. I had to look up In current world look up means, asking LLM please explain me like a kid half of these acronyms while writing this. So if you also feel a bit lost when people start saying things like “FIPS 205 addrnd nonce” and your brain just freezes, you are in the right place. We will go slow. Let me walk you through what I found. What is SLH-DSA anyway ⌗ what-is-slh-dsa-anyway A tiny crash course before we get to the bug. SLH-DSA stands for Stateless Hash-based Digital Signature Algorithm. It is one of the post-quantum signature schemes that NIST standardized in FIPS 205. The “post-quantum” part means it is built to survive against future quantum computers that would shred classical schemes like RSA and ECDSA. The cool thing about SLH-DSA is that it only relies on hash functions. No fancy lattice math, no elliptic curves, just hashes all the way down. The not-so-cool thing is that signatures are huge think tens of kilobytes and signing is slow. Alongside SLH-DSA there is also ML-DSA. Same NIST batch, different math. ML-DSA uses lattices and is way faster but the API in OpenSSL looks almost identical for both. That detail matters, hold onto it. When you sign something with SLH-DSA in randomized mode, the algorithm needs a fresh random nonce called addrnd . This nonce gets mixed into the signature. It does not need to stay secret forever the signature itself is public anyway but it should not be left lying around in memory after we are done with it. That is just basic crypto hygiene. You wash your hands after handling raw chicken. You wipe nonces after signing. How I found it ⌗ how-i-found-it I was reading through providers/implementations/signature/slh dsa sig.c to learn how the provider plumbing worked. I had the equivalent ML-DSA file open in another tab for comparison because they share the same shape. Both files have a function that does roughly this: - Allocate a small stack buffer for randomness - Either copy in a test value from the context, or fill the buffer with fresh entropy - Call the actual signing routine - Wipe the buffer with OPENSSL cleanse Step four is the important one. When you are done with sensitive bytes, you scrub them. Otherwise they sit on the stack until someone else’s function call happens to write over them. Which might be never if your signing function returns and the program does something else on a different code path. Here is what slh dsa sign looks like, lightly trimmed lines 244 and 245 of the file in 4.0.0 : php if sig = NULL { if ctx- add random len = 0 { opt rand = ctx- add random; } else if ctx- deterministic == 0 { n = ossl slh dsa key get n ctx- key ; if RAND priv bytes ex ctx- libctx, add rand, n, 0 <= 0 return 0; opt rand = add rand; } } ret = ossl slh dsa sign ctx- hash ctx, msg, msg len, ctx- context string, ctx- context string len, opt rand, ctx- msg encode, sig, siglen, sigsize ; if opt rand = add rand OPENSSL cleanse opt rand, n ; return ret; Read that last if statement carefully. add rand is the local stack buffer. That is the variable we want to wipe, because that is where our fresh secrets sit. opt rand is a pointer that ends up pointing at one of three things: - ctx- add random , if the caller supplied a test value heap memory in the context - add rand , our stack buffer, if we generated fresh random - NULL , in deterministic mode So the check if opt rand = add rand says: if opt rand is NOT pointing at our stack buffer, wipe whatever it is pointing at. Which translates to: in the normal random signing path where opt rand IS pointing at the stack buffer, do nothing. That is exactly backwards. Three flavors of broken ⌗ three-flavors-of-broken Let me walk through what actually happens in each path. Path 1: normal random signing ctx- add random len == 0 and deterministic == 0 . The code generates fresh entropy into add rand , then sets opt rand = add rand . They point at the same place. opt rand = add rand is false. The cleanse never runs. The nonce sits on the stack waiting for someone to read it. Path 2: test entropy override ctx- add random len = 0 . The caller supplied test bytes through the context. Now opt rand = ctx- add random which lives on the heap. opt rand = add rand is true. Cleanse runs. Except n is still 0 because the code never assigned it in this branch. So we call OPENSSL cleanse opt rand, 0 . A cleanse of zero bytes. Useless. Path 3: deterministic mode deterministic == 1 . Neither branch in the if/else if runs. opt rand stays NULL and n stays 0. The check opt rand = add rand is true NULL is not equal to the stack address . Cleanse runs as OPENSSL cleanse NULL, 0 . Defined behavior in OpenSSL but completely pointless. So in three paths, three different ways of being wrong. The most common path leaves a real secret on the stack. The other two do nothing useful. A perfect score. Comparing with the sibling code ⌗ comparing-with-the-sibling-code This bug stings even more once you look at the sister file ml dsa sig.c in the same directory. ML-DSA has the exact same structure and gets it right: php ret = ossl ml dsa sign ctx- key, ctx- mu, msg, msg len, ctx- context string, ctx- context string len, rnd, sizeof rand tmp , ctx- msg encode, sig, siglen, sigsize ; if rnd = ctx- test entropy OPENSSL cleanse rand tmp, sizeof rand tmp ; Look at the differences from the SLH-DSA version: - The check is against ctx- test entropy the heap context value not against rand tmp the stack buffer - The cleanse always targets rand tmp the stack buffer with sizeof rand tmp a compile-time constant In ML-DSA the logic reads as: if we did not use the supplied test entropy, we must have used the stack buffer, so wipe the stack buffer. Easy. Correct. Boring. The SLH-DSA version reads like someone copied this pattern from somewhere and got the variables mixed up halfway through. Spider-Man pointing at Spider-Man. Left Spidey labelled “ml dsa sign”. Right Spidey labelled “slh dsa sign”. They look identical. Except one of them is leaking nonces and does not know it. The fix ⌗ the-fix It is a one-line change. Or two depending how you count. - if opt rand = add rand - OPENSSL cleanse opt rand, n ; + if opt rand == add rand + OPENSSL cleanse add rand, sizeof add rand ; Two reasons to use sizeof add rand instead of n : n is conditionally set. If a future refactor moves things around it is easy to land in a code path where n is zero and we silently cleanse nothing sizeof add rand is SLH DSA MAX ADD RANDOM LEN , evaluated at compile time. Always correct. Always the full buffer You always want to wipe the whole buffer anyway. Wiping just n bytes leaves the rest of the buffer untouched, which might still contain whatever previous garbage was there. Most of the time fresh entropy fills the whole thing, but defense in depth is cheap. Why does this even matter ⌗ why-does-this-even-matter I have to be honest here. By itself this bug is not a critical-severity find. It does not let an attacker forge signatures, recover private keys or directly do anything mean to your server. But. Cryptographic nonces leaking onto the stack is the kind of thing that becomes a real problem when chained with another bug. Some examples: Core dumps. If your process crashes and dumps core, the stack contents go straight to disk. If your crash dump handler ships those off to a Sentry-style service, your nonces just left the building Swap files. The OS pages your process to disk. The unwiped stack pages go with it. Now the nonces live on a spinning disk somewhere until the page is overwritten Information disclosure bugs. Pair this with any unrelated bug that lets an attacker read uninitialized stack memory, like an out-of-bounds read elsewhere in the same process, and now they have something interesting to look at Side channels. Knowing the exact addrnd value used for a signature lets an attacker do more precise work on side-channel attacks against the SLH-DSA hash inputs. Not a panic-button thing, but it is a useful primitive FIPS 140-3 compliance. Organizations in finance, healthcare or government often have hard requirements that “sensitive security parameters” must be zeroized after use. A randomized signing nonce qualifies. So if you are using OpenSSL 4.0.0 SLH-DSA in a FIPS context you are technically out of compliance The thing about leaking secrets to the stack is, it does not bite you until it does, and then it bites everyone at once. Better to stamp it out before that happens. Galaxy brain meme. Level 1: “I scrub my secrets”. Level 2: “I scrub my secrets but with the wrong condition”. Level 3: “I scrub my secrets but with the wrong condition AND zero bytes”. Level 4 glowing brain: “I scrub NULL”. Proof of concept ⌗ proof-of-concept I wrote a small standalone reproducer that mirrors the structure of slh dsa sign . It uses a magic byte 0xA5 in place of real random data so we can spot it on the stack. There are two versions of the function: a buggy one that mirrors OpenSSL 4.0.0, and a fixed one that mirrors the ML-DSA pattern. After each call we probe the stack to see what is still there. / PoC: SLH-DSA Stack Nonce Leak Compile: gcc -O0 -fno-stack-protector -o slh dsa poc slh dsa poc.c / include