TL;DR: Bishop Fox researchers expanded on Fortinet’s disclosure of CVE-2026-21643 by identifying practical exploitation paths. Our analysis shows attackers can abuse the publicly accessible /api/v1/init_consts endpoint to trigger the SQL injection before authentication. Because this endpoint returns database error messages and has no lockout protections, attackers can rapidly extract sensitive data from vulnerable FortiClient EMS 7.4.4 multi-tenant deployments.
FortiClient EMS is Fortinet's centralized management server for FortiClient endpoint agents. Organizations use it to deploy, configure, and monitor FortiClient installations across their endpoint fleet. FortiClient EMS has supported multi-tenant deployments since before version 7.4.4, allowing a single instance to manage multiple customer sites. Version 7.4.4 refactored the middleware stack and database connection layer as part of this feature's evolution and, in doing so, introduced a critical flaw: the HTTP header used to identify which tenant a request belongs to is now passed directly into a database query without sanitization, and this happens before any login check.
An attacker who can reach the EMS web interface over HTTPS needs no credentials to exploit this. A single HTTP request with a crafted header value is sufficient to execute arbitrary SQL against the backing PostgreSQL database. This gives attackers access to admin credentials, endpoint inventory data, security policies, and certificates for managed endpoints. Organizations running FortiClient EMS 7.4.4 with multi-tenant mode enabled should upgrade to 7.4.5 immediately. Single-site deployments are not affected.
| CVE ID | Type | Attack Vector | Auth Required | Impact | CVSS | Fixed Version |
| CVE-2026-21643 | SQL Injection | Network | None | Arbitrary SQL execution | 9.1 | 7.4.5 |
Vendor Advisory: FG-IR-25-1142
FortiClient EMS runs as a Django application served through Apache/mod_wsgi on port 443, backed by PostgreSQL via pgbouncer for connection pooling. The web GUI provides management interfaces for endpoint deployment, policy configuration, and monitoring.
FortiClient EMS has supported a multitenancy feature called "Sites" since at least version 7.4.0, allowing a single EMS instance to serve multiple virtual domains (vdoms). When enabled (SITES_ENABLED=True in the application configuration), incoming HTTP requests include a Site header indicating which tenant context the request targets. Django middleware reads this header and uses it to set the PostgreSQL search_path to the appropriate tenant schema before any queries execute. This ensures each tenant's data is isolated at the database level.
The SITES_ENABLED flag is accessible pre-authentication via GET /api/v1/init_consts, which returns it in the JSON response at data.consts.System.SITES_ENABLED. This allows both legitimate clients and attackers to determine whether a target is in multi-tenant mode without authentication.
Version 7.4.4 refactored the middleware stack significantly, renaming the site routing middleware (from site.pyc to site_middleware.pyc), adding several new middlewares (auth_middleware, api_log_middleware, ems_common_middleware, error_handling_middleware, rate_limit_middleware), and modifying the database connection layer in postgres_conn.py. This refactoring changed how the Site header value is processed and how the PostgreSQL search_path is set, introducing the vulnerable format-string interpolation that prior versions did not contain.
Prior versions of FortiClient EMS supported multitenancy but handled tenant routing differently at the database layer. The vulnerable code path in SiteMiddleware and postgres_conn.py is specific to the 7.4.4 refactoring, and was patched in 7.4.5.
The request processing chain in FortiClient EMS 7.4.4 follows this middleware order:
The critical detail: SiteMiddleware executes before AuthMiddleware. When a request arrives at a pre-auth endpoint like /api/v1/auth/signin, the database connection is already established with the attacker-controlled search_path before any credentials are checked.
In SiteMiddleware, the Site header is read and stored with no validation beyond lowercasing:
No allowlist validation against known vdoms. No character filtering. No regex. The raw header value (lowercased) flows directly into request.META['SITE'].
The value then reaches PostgresConnection.__init__():
The format-string interpolation embeds the unsanitized vdom value directly into a SQL statement that executes on every database query.
The SET search_path statement wraps the schema name in single quotes. An attacker who controls the vdom value can break out of the quoted string, terminate the SET statement, and inject arbitrary SQL:
The single quote closes the schema name string, the semicolon terminates the SET statement, and -- comments out the trailing ', public, addons. Everything between the semicolon and the comment marker executes as a separate SQL statement with the privileges of the EMS database user.
Fortinet's advisory lists the following version matrix:
| Version | Affected | Solution |
| FortiClientEMS 8.0 | Not affected | Not applicable |
| FortiClientEMS 7.4 | 7.4.4 only | Upgrade to 7.4.5 or above |
| FortiClientEMS 7.2 | Not affected | Not applicable |
Our filesystem comparison explains why. Version 7.4.4 was a significant middleware refactoring release. Comparing the middleware directories across versions shows the scope of the change: 7.4.3 middleware stack (6 files):
7.4.4 middleware stack (10 files):
The 7.4.4 refactoring added five new middleware files, renamed two others, and restructured the request processing pipeline. As part of this restructuring, postgres_conn.py grew from 11,697 bytes to 13,336 bytes (+1,639 bytes), which is where the vulnerable format-string interpolation of the Site header into SET search_path was introduced.
Version 7.4.5 fixed the interpolation (switching to psycopg.sql.Identifier()), but the refactored middleware architecture remained. The 7.2 and 8.0 product lines were never part of this refactoring cycle. They use either the pre-refactored database routing code (7.2) or a separately maintained codebase (8.0), neither of which contains the format-string SET search_path construction.
This makes CVE-2026-21643 a single-version vulnerability: the format-string interpolation was introduced in the 7.4.4 refactoring and patched one release later in 7.4.5. The vulnerable code existed in production for exactly one release cycle.
Version 7.4.5 replaced the format-string interpolation with parameterized identifier handling:
psycopg.sql.Identifier() properly double-quotes and escapes the schema name, preventing breakout regardless of the input value.
Disclaimer: This research was conducted independently following Fortinet's public advisory and patch release. All testing was performed against locally deployed lab instances. No production systems were targeted.
An attacker with HTTPS access to a FortiClient EMS 7.4.4 instance can determine whether multi-tenant mode is enabled by querying GET /api/v1/init_consts (no authentication required) and checking the SITES_ENABLED field in the response. If enabled, the attacker can inject SQL through the Site header on multiple pre-auth endpoints.
Lab testing confirmed two injectable endpoints: POST /api/v1/auth/signin (the login endpoint, subject to brute force lockout after 3 attempts) and GET /api/v1/init_consts itself (the public constants endpoint, with no lockout or rate limiting). The init_consts endpoint is the more practical attack vector because it allows unlimited requests and returns PostgreSQL errors in the response body, enabling instant error-based data extraction without relying on timing oracles.
GET /api/v1/init_consts to confirm FortiClient EMS and check SITES_ENABLEDpg_sleep(N) via the Site header on init_consts to confirm injection via timing deltaCAST errors leak query results in HTTP 500 responses) for instant single-request data exfiltration via init_constsTo validate the injection, we sent crafted Site headers containing pg_sleep() payloads to each endpoint and compared response times against a benign baseline. The Connection: close header was used on all requests to ensure fresh pgbouncer connections per request.
Testing confirmed two injectable endpoints with distinct timing characteristics:
POST /api/v1/auth/signin (login endpoint):
The ~2x delta is consistent with pgbouncer connection pooling executing the SET search_path statement on both connection acquisition and release, causing the sleep to run twice. This endpoint has brute force protection (3 attempts before lockout).
GET /api/v1/init_consts (public constants endpoint):
The init_consts endpoint shows a clean 1x sleep multiplier and returns HTTP 500 with PostgreSQL error details in the response body when injection is triggered. This endpoint has no brute force protection and no authentication requirement, making it the more practical attack vector.
The HTTP 500 response body on init_consts leaks raw PostgreSQL errors:
This enables error-based extraction: wrapping a query in CAST((<query>)::text AS int) causes PostgreSQL to return the query result in the error message, allowing instant single-request data exfiltration without timing oracles.
/api/v1/auth/signin and /api/v1/init_consts are injectable. The signin endpoint is subject to BruteForceProtectionMiddleware (default 3 attempts before lockout), but init_consts has no such restriction. An attacker with knowledge of the init_consts vector can extract data without triggering any lockout.init_consts endpoint returns PostgreSQL errors in the JSON response body. By injecting CAST expressions that force type conversion errors, an attacker can extract arbitrary query results in a single HTTP request per value. This is significantly faster and stealthier than timing-based blind extraction.
pgbouncer connection pooling. The SET search_path executes at connection acquisition time. pgbouncer's pooled connections cause double-execution on the signin endpoint (2x sleep multiplier) but single-execution on init_consts (1x multiplier). Long pg_sleep() values can saturate the connection pool, temporarily making the EMS instance unresponsive.CAST errors leaking data in HTTP responses, confirmed on init_consts), and blind boolean extraction (conditional pg_sleep() for bit-by-bit data exfiltration as a fallback).A successful exploit grants an unauthenticated attacker arbitrary SQL execution against the EMS PostgreSQL database with the privileges of the EMS database user. This enables:
COPY ... TO/FROM PROGRAM. Lab testing confirmed arbitrary file creation on the underlying host as the postgres system user.The pre-auth nature of the vulnerability and its position in the database connection layer (affecting every subsequent query in the request lifecycle) make it particularly severe for exposed EMS instances. FortiClient EMS manages an organization's entire endpoint fleet, making it a high-value target for attackers seeking broad access to an enterprise environment.
FortiClient EMS runs behind Apache/mod_wsgi, and Apache access logs are enabled by default. These logs record every HTTP request to the EMS web interface, including timestamps and response times. While the default log format does not include request headers (so the Site header value itself won't appear), you can look for indicators of exploitation:
/api/v1/auth/signin or /api/v1/init_consts. A pg_sleep() test injection produces response times of 5-20+ seconds on requests that normally complete in under a second./api/v1/init_consts. This endpoint normally returns HTTP 200. A 500 response indicates a database error, which is the expected result of SQL injection through the Site header (the injected SQL breaks the schema context for the subsequent application query)./api/v1/init_consts from a single source IP in rapid succession, particularly if they produce mixed 200/500 status codes. This pattern is consistent with error-based data extraction.The default Apache log location on FortiClient EMS appliances is /var/log/apache2/ or the path configured in the Apache virtual host configuration.
PostgreSQL's log_statement parameter defaults to none, meaning successful SQL statements (including successful injection via pg_sleep()) are not logged under default configuration. However, PostgreSQL's log_min_error_statement defaults to ERROR, which means any injection attempt that produces a SQL error will be logged along with the offending statement.
In practice, an attacker probing with malformed payloads or hitting edge cases in the injection syntax will generate errors that PostgreSQL records. Look for SET search_path statements in the error log that contain unexpected characters: single quotes, semicolons, SQL keywords like SELECT, pg_sleep, UNION, or COPY. Any search_path value that doesn't match the expected fcm_<alphanumeric_vdom_name> pattern is suspicious.
The PostgreSQL data directory and log location depend on the FortiClient EMS deployment configuration. On VMware appliances, the PostgreSQL logs are typically under the PostgreSQL data directory's log/ subdirectory.
Note: Successful time-based injection viapg_sleep()does not produce a PostgreSQL error and will not appear in the error log under default settings. If you suspect active exploitation and want full statement logging, settinglog_statement = 'all'will capture everything, but be aware this has significant performance and storage overhead and should be treated as a temporary forensic measure.
Upgrade to FortiClient EMS 7.4.5 or later. The patch replaces format-string interpolation with psycopg.sql.Identifier() parameterization, which properly escapes the schema name regardless of input. No configuration changes are needed after the upgrade.
SITES_ENABLED=False, which causes SiteMiddleware to hardcode the default vdom and never read the Site header. The vulnerable code remains present but unreachable.Site header, blocking values containing single quotes, semicolons, or SQL keywords.Our analysis followed a seven-phase approach after Fortinet published the advisory and patch:
Phase 1: Firmware extraction. Obtained FortiClient EMS VMware images for versions 7.4.3, 7.4.4, and 7.4.5. Converted VMDK files to raw disk images using qemu-img, identified the GPT partition layout, and mounted the LVM logical volumes to access the full filesystem for each version.
Phase 2: Bytecode decompilation. The EMS web GUI is a Django application shipped as Python 3.10 bytecode (.pyc files) located at /opt/forticlientems/fcm/fcm/. Built pycdc to decompile the bytecode back to Python source. Batch-decompiled key files across all three versions: controllers, middlewares, models, and URL routing.
Phase 3: Differential analysis. Performed three-way diffs (7.4.3, 7.4.4, 7.4.5) to isolate security-relevant changes from framework noise (bulk middleware refactoring introduced in 7.4.4). This revealed that postgres_conn.py was modified in 7.4.4 to use format-string interpolation for the SET search_path statement, and that the site routing middleware was renamed and refactored (from site.pyc to site_middleware.pyc). Version 7.4.5 replaced the format-string with psycopg.sql.Identifier() parameterization.
Phase 4: Root cause confirmation. Decompiled the full middleware stack (SiteMiddleware, AuthMiddleware, BruteForceProtectionMiddleware, ApiLogMiddleware) to map the request processing chain and confirm that SiteMiddleware executes before authentication.
Phase 5: Lab reproduction. Deployed FortiClient EMS 7.4.4 as a VMware appliance and enabled multi-tenant mode through the GUI to set SITES_ENABLED=True. Validated timing-based blind SQL injection against POST /api/v1/auth/signin using the Site header, then tested all pre-auth endpoint candidates to identify additional injection vectors.
Phase 6: Constraint mapping and endpoint enumeration. Identified operational constraints: brute force protection on the login endpoint (3 attempts before lockout), pgbouncer connection pooling causing double-execution on signin (2x sleep multiplier). Tested seven endpoint candidates and discovered that GET /api/v1/init_consts is also injectable, with no brute force lockout, a clean 1x timing multiplier, and PostgreSQL error details leaked in the HTTP 500 response body, enabling error-based data extraction.
Phase 7: Detection tooling. Developed a PoC script targeting init_consts for both timing-based detection and error-based extraction, along with a multi-endpoint scanner for network-wide vulnerability assessment.
CVE-2026-21643 is a textbook example of how routine code refactoring can introduce a critical vulnerability. The multitenancy feature in FortiClient EMS worked safely for multiple versions before a single change to the database connection layer in 7.4.4 replaced parameterized handling with raw string interpolation, opening a pre-auth SQL injection that gives an attacker full access to the management database. The fact that it was introduced and patched within a single version cycle suggests Fortinet caught it quickly, but any organization still running 7.4.4 with multi-tenant mode enabled should treat remediation as urgent.
Organizations running FortiClient EMS should verify their version and SITES_ENABLED status (accessible pre-auth via GET /api/v1/init_consts as described in the Exploitation section), and upgrade to 7.4.5 or later. If immediate patching is not possible, restricting network access to the EMS web interface or disabling multi-tenant mode eliminates the attack surface.
Organizations running platforms like FortiClient EMS should treat management infrastructure as part of their critical attack surface. Vulnerabilities in these systems can provide attackers with broad visibility and control over enterprise environments if left untested.
Bishop Fox network penetration testing helps organizations identify exposed services, validate real-world exploitability, and understand how attackers could leverage infrastructure weaknesses to gain deeper access. Learn more about how our team helps security programs uncover and remediate these risks.
Subscribe to our blog
Be first to learn about latest tools, advisories, and findings.