TL;DR: Bishop Fox researchers expanded on Fortinet's disclosure of CVE-2026-35616 by identifying the root cause via the released hotfix. The vulnerability allows an unauthenticated attacker to bypass certificate-based authentication on FortiClient EMS 7.4.5-7.4.6 by spoofing HTTP request headers that the Django application trusts as equivalent to Apache mod_ssl WSGI environment variables. Combined with a certificate chain validation that performs only subject/issuer string matching with no cryptographic signature verification, an attacker can forge certificates and gain authenticated API access. Fortinet has released a hotfix and confirmed exploitation in the wild. Organizations should apply the hotfix immediately or upgrade to 7.4.7 when available.
Summary
CVE-2026-35616 is an authentication bypass in FortiClient EMS 7.4.5 and 7.4.6. The Django authentication middleware accepts client certificate information from both trusted WSGI environment variables (set by Apache mod_ssl) and user-controllable HTTP request headers, and Apache was never configured to strip the user-controllable variants. An attacker who can reach the EMS web interface over HTTPS needs no credentials to exploit this. Additionally, the certificate chain validation performs only Distinguished Name string matching against embedded Fortinet root CAs with no X.509 cryptographic signature verification, meaning an attacker can generate a passing certificate chain with openssl req using the correct DN strings.
Fortinet has confirmed this vulnerability is being exploited in the wild and has released a hotfix. Organizations running FortiClient EMS 7.4.5 or 7.4.6 should apply the hotfix immediately. A permanent code-level fix is expected in version 7.4.7. Additionally, Bishop Fox researchers developed a non-intrusive scanner that can be used to detect unpatched systems without exercising the auth bypass fully, which you can download here.
Background
FortiClient EMS uses Apache with mod_ssl to terminate TLS connections. When a client presents a TLS certificate during the handshake, Apache's mod_ssl module populates WSGI environment variables such as SSL_CLIENT_VERIFY (whether the cert was verified) and SSL_CLIENT_CERT (the PEM-encoded certificate data). The Django application then reads these variables in its authentication middleware to support certificate-based device authentication, which is how managed FortiGate appliances and FortiClient agents authenticate to EMS.
This certificate authentication was introduced as part of the cert_chain_approved and cert_chain security schemes that gate access to device management endpoints. The middleware evaluates these schemes alongside JWT, session, and basic authentication for each API endpoint based on a per-view security configuration defined through Django view decorators.
Our analysis builds on the same FortiClient EMS codebase we examined during our CVE-2026-21643 research, where we identified a pre-authentication SQL injection in version 7.4.4. The middleware architecture is familiar territory: the same AuthMiddleware, OpenApi decorator system, and PostgreSQL connection layer we reverse-engineered for that vulnerability are central to this one.
Vulnerability Analysis
The Vulnerable Code Path
When an HTTP request arrives at a FortiClient EMS endpoint that supports certificate-based authentication, the AuthMiddleware calls CertChainAuth.contains_certificate() to determine whether a client certificate is present. This method, decompiled from fcm/auth/cert_chain_auth.py, performs two checks:
@classmethod
def contains_certificate(cls, request) -> None:
return (
request.META.get('SSL_CLIENT_VERIFY') == 'SUCCESS'
or request.META.get('HTTP_X_SSL_CLIENT_VERIFY') == 'SUCCESS'
)
The first check, SSL_CLIENT_VERIFY, reads a WSGI environment variable set by Apache's mod_ssl. This is not user-controllable. The second check, HTTP_X_SSL_CLIENT_VERIFY, is Django's automatic mapping of the HTTP request header X-SSL-CLIENT-VERIFY. Under Django's request.META convention, any HTTP header X-Foo-Bar becomes HTTP_X_FOO_BAR. This second value is fully user-controllable: any HTTP client can include X-SSL-CLIENT-VERIFY: SUCCESS in a request.
If contains_certificate() returns True, the middleware proceeds to Certificate.validate_cert_chain(), which reads the certificate PEM data. This method, decompiled from fcm/models/utils/certificate.py, has the same issue:
# BRANCH 1: Checked FIRST - user-controllable HTTP header
if 'HTTP_X_SSL_CLIENT_CERT' in request.META:
client_cert = urllib.parse.unquote(request.META['HTTP_X_SSL_CLIENT_CERT'])
certificates = client_cert.split('-----END CERTIFICATE-----')
# Parses into client_cert, int_cert, root_cert
# BRANCH 2: Trusted mod_ssl WSGI vars (only used if header absent)
else:
client_cert = request.META.get('SSL_CLIENT_CERT')
int_cert = request.META.get('SSL_CLIENT_CERT_CHAIN_0')
# ...
The user-supplied X-SSL-CLIENT-CERT header is checked first and takes priority over the trusted WSGI environment variables. An attacker who sends both X-SSL-CLIENT-VERIFY: SUCCESS and a forged certificate chain in X-SSL-CLIENT-CERT passes the authentication gate and supplies the certificate data that will be validated.
Certificate Chain Validation: String Matching Only
The certificate chain is validated against EmsConsts.ROOT_CA_CERTS, the Fortinet root CAs embedded in the EMS installation. The validation in validate_cert_chain() compares only subject and issuer Distinguished Name strings. For a 2-certificate chain (client + intermediate):
int_cert.subject == root_ca.subject int_cert.issuer == root_ca.issuer client_cert.issuer == int_cert.issuer
For a 3-certificate chain (client + intermediate + root):
root_cert.subject == root_ca.subject root_cert.issuer == root_ca.subject (self-signed check) int_cert.issuer == root_cert.issuer "subca" in int_cert.subject (literal substring check) client_cert.issuer == int_cert.subject
No X.509 cryptographic signature verification is performed at any point. An attacker can generate self-signed certificates with openssl req -subj using matching DN strings, and the chain will pass validation. The required DN strings are extractable from any FortiClient EMS installation, FortiClient agent package, or FortiGate firmware image.
Post-Authentication Identity Assignment
On successful chain validation, CertChainAuth._set_user() assigns the client certificate's subject CN as the authenticated user identity with the certificate user role:
cls.set_user(
request,
name=cert_cn, # attacker-controlled
full_name=f"Certificate user: {cert_cn}", # attacker-controlled
role=Role.get_certificate_user_role(), # fixed role assignment
)
For endpoints using the cert_chain_approved security scheme, FabricDeviceAuth.authorize() performs an additional database lookup using the client CN as a serial number. If the CN matches an authorized device, access is granted. If the CN is unknown and the request is a POST, the device is auto-registered in a pending state.
Why This Happened
The vulnerability has two independent root causes, either of which would be sufficient to block the attack if fixed. The first is a reverse proxy trust boundary violation: the Django application accepts certificate verification status from user-controllable HTTP headers (HTTP_X_SSL_CLIENT_VERIFY) in addition to trusted WSGI environment variables (SSL_CLIENT_VERIFY), and Apache was never configured to strip the user-controllable variants. The second is the absence of cryptographic certificate validation: validate_cert_chain() checks only subject and issuer Distinguished Name strings against the embedded Fortinet root CAs, with no X.509 signature verification at any point in the chain. Even if the header spoofing were fixed, an attacker who could present a TLS client certificate through mod_ssl's SSLVerifyClient optional negotiation could pass chain validation with self-signed certificates bearing the correct DN strings, since no cryptographic proof of issuance is ever checked. The hotfix addresses only the first issue by stripping headers at the Apache layer; the string-only chain validation remains in place.
The Apache Configuration
Analysis of the unpatched ems-webserver.conf confirms the gap. Apache is configured with SSLVerifyClient optional and SSLOptions +StdEnvVars +ExportCertData, which correctly populate WSGI environment variables when a real client certificate is presented via TLS. The configuration includes RequestHeader set directives for some SSL variables (such as SSL_CLIENT_S_DN_CN), but no RequestHeader unset directives exist for any of the spoofable headers. The ZTNA worker routing logic uses RewriteCond %{SSL:SSL_CLIENT_VERIFY}, which correctly reads from the WSGI variable (not the HTTP header), so legitimate ZTNA routing is unaffected by header spoofing; spoofed requests fall through to the Django WSGI handler where the vulnerable code resides.
The Hotfix
Fortinet's hotfix consists of two components. The primary fix is in apply.sh, a shell script that patches the Apache virtual host configuration to add RequestHeader unset directives:
RequestHeader unset X-SSL-CLIENT-VERIFY RequestHeader unset X-SSL-CLIENT-CERT RequestHeader unset SSL-CLIENT-VERIFY RequestHeader unset SSL-CLIENT-CERT RequestHeader unset X-Bypass-SN-Check RequestHeader unset X-Forward-To-ZTNAWorker
These directives strip all spoof-able headers before they reach the Django WSGI layer. This is the primary and only fix for the authentication bypass: it prevents the attack regardless of any code changes in the application layer. The additional headers stripped (X-Bypass-SN-Check and X-Forward-To-ZTNAWorker) are internal trust signals used for ZTNA worker communication that were also never stripped from external requests.
The secondary component is an updated auth_middleware.pyc. Our byte-for-byte comparison of the hotfix against the original 7.4.5 auth_middleware.pyc confirms that check_request_authorization(), the core authentication dispatch function, is completely unchanged. The hotfix makes exactly two modifications: a new import (from django.conf import settings) and a session flush in __call__() that destroys the Django session and deletes the sessionid cookie after any certificate-based authentication. This is defense-in-depth against session fixation: without it, an attacker could spoof cert headers to establish a persistent session cookie, and then replay that cookie on session-based endpoints even after the Apache header stripping is deployed.
The vulnerable code in CertChainAuth.contains_certificate() and Certificate.validate_cert_chain() is completely untouched by this hotfix. The code-level fix to the authentication logic is presumably deferred to the 7.4.7 release.
Impact Assessment
A successful exploit grants an unauthenticated attacker authenticated API access with certificate-user role privileges across 16 cert_chain_approved endpoint definitions spanning 15 controllers. Our analysis of 88 controller bytecode files identified the full attack surface, which includes endpoints for quarantining and sending commands to all managed endpoints, reconfiguring EMS server settings and rotating JWT secrets, downloading ZTNA private keys and revoking certificates, creating ZTNA applications and rules, registering and authorizing FortiGate devices, and exporting complete software inventory and endpoint vulnerability data.
FortiClient EMS manages an organization's entire endpoint fleet. Compromise of the management server gives an attacker visibility into and control over every managed endpoint, making this a high-value target for lateral movement and persistence.
Detecting Vulnerable Instances
We have released an open-source scanner that detects whether the hotfix has been applied without sending any exploit payload:
CVE-2026-35616 Scanner
$ python3 ./cve-2026-35616-check.py 192.168.1.1 ====================================================================== FortiClient EMS CVE-2026-35616 Vulnerability Scanner Non-Destructive Detection ====================================================================== [*] Target: 192.168.1.1:443 [*] Testing for CVE-2026-35616 (non-destructive) [1] Sending baseline POST (no spoof headers)... HTTP 401 [2] Sending POST with X-SSL-CLIENT-VERIFY: SUCCESS... HTTP 500 [*] Analyzing responses... [+] Baseline: 401 — Certificate not found in request header. [+] Spoofed: 500 — Server encountered an error, please try again later. [!] Spoofed header changed server behavior [!] X-SSL-CLIENT-VERIFY is reaching Django (hotfix not applied) ====================================================================== RESULT: VULNERABLE to CVE-2026-35616 Affected versions: FortiClient EMS 7.4.5 - 7.4.6 Recommendation: Apply Fortinet hotfix (apply.sh) or upgrade to 7.4.7+ ====================================================================== $ python3 ./cve-2026-35616-check.py 192.168.1.1 # After hotfix ====================================================================== FortiClient EMS CVE-2026-35616 Vulnerability Scanner Non-Destructive Detection ====================================================================== [*] Target: 192.168.1.1:443 [*] Testing for CVE-2026-35616 (non-destructive) [1] Sending baseline POST (no spoof headers)... HTTP 401 [2] Sending POST with X-SSL-CLIENT-VERIFY: SUCCESS... HTTP 401 [*] Analyzing responses... [+] Baseline: 401 — Certificate not found in request header. [+] Spoofed: 401 — Certificate not found in request header. [-] Responses identical (header stripped by Apache) ====================================================================== RESULT: NOT VULNERABLE (hotfix applied) Apache is stripping X-SSL-CLIENT-VERIFY before it reaches Django ======================================================================
The scanner works by sending two POST requests to a certificate-authenticated API endpoint: one without any spoofed headers (baseline) and one with X-SSL-CLIENT-VERIFY: SUCCESS but no certificate data. On unpatched targets, the spoofed header changes the response from HTTP 401 ("Certificate not found in request header") to HTTP 500 (server error), because the header passes the contains_certificate() gate but the downstream validate_cert_chain() crashes on missing PEM data. On hotfixed targets, Apache strips the header before it reaches Django, so both requests return an identical HTTP 401. No certificate data is sent and no authentication is attempted.
Remediation
Patching
Apply the Fortinet-provided hotfix immediately. The hotfix adds Apache RequestHeader unset directives that strip spoof-able headers before they reach the Django application. Upgrade to FortiClient EMS 7.4.7 when available for a permanent code-level fix.
Workarounds and Mitigations
If the hotfix cannot be applied immediately, restrict HTTPS access to the EMS web interface to authorized management networks only. The vulnerability requires direct HTTPS access to the EMS web interface on port 443.
Methodology
Our analysis followed a four-phase approach after Fortinet published the advisory and hotfix:
Phase 1: Hotfix analysis. Obtained the hotfix package containing apply.sh and an updated auth_middleware.pyc. The apply.sh script revealed the attack surface immediately: it strips X-SSL-CLIENT-VERIFY, X-SSL-CLIENT-CERT, and related headers at the Apache layer, indicating a trusted-proxy header spoofing vulnerability.
Phase 2: Bytecode decompilation. Decompiled seven Python 3.10 .pyc modules from the FortiClient EMS 7.4.5 appliance image using xdis and manual reconstruction: auth_middleware, cert_chain_auth, certificate, fabric_device_auth, open_api, ztna_worker_api, and the hotfix auth_middleware. This confirmed the exact spoofing requirements: two HTTP headers bypass the authentication gate, and the certificate chain validation is purely structural with no cryptographic verification.
Phase 3: Attack surface enumeration. Decompiled the URL routing configuration (urls.pyc, approximately 600 URL patterns) and scanned 88 controller bytecode files for security scheme definitions to identify all endpoints accepting cert_chain and cert_chain_approved authentication. This identified 16 cert_chain_approved endpoints across 15 controllers, plus the Apache virtual host configuration analysis that confirmed no RequestHeader unset directives existed in the unpatched configuration.
Phase 4: Detection tooling. Developed a hotfix detection scanner that identifies unpatched targets through behavioral analysis of a certificate-authenticated endpoint, without sending any exploit payload.
Conclusion
CVE-2026-35616 is a textbook trusted-proxy header spoofing vulnerability. The Django application trusts HTTP headers that should only come from the reverse proxy layer, and Apache was never configured to strip them from external requests. The additional finding that certificate chain validation performs no cryptographic verification compounds the issue: even if the header spoofing were fixed, an attacker with knowledge of the Fortinet root CA DN strings could forge a passing certificate chain with standard openssl commands.
Organizations running FortiClient EMS 7.4.5 or 7.4.6 should apply the hotfix immediately and verify remediation using the Bishop Fox scanner. The hotfix is a necessary and effective mitigation, but the underlying code-level vulnerabilities in CertChainAuth.contains_certificate() and Certificate.validate_cert_chain() remain until version 7.4.7 ships.
Our affected Cosmos customers were notified of this vulnerability shortly after the vendor disclosure, and we continue to monitor for new threats to exposed services. If you're interested in learning more about managed services delivered through our Cosmos platform, visit bishopfox.com/services/cosmos.
For more vulnerability intelligence insights, visit the Bishop Fox Blog.
Subscribe to our blog
Be first to learn about latest tools, advisories, and findings.
Thank You! You have been subscribed.
Recommended Posts
You might be interested in these related posts.
Pre-Authentication SQL Injection in FortiClient EMS 7.4.4 - CVE-2026-21643
Fortinet FortiWeb Authentication Bypass – CVE-2025-64446
strongSwan CVE-2026-25075: Integer Underflow in VPN Authentication