AI-Powered Application Penetration Testing—Scale Security Without Compromise Learn More

Pre-Authentication SQL Injection in FortiClient EMS 7.4.4 - CVE-2026-21643

Cybersecurity graphic with red grid, city skyline, and circuitry background highlighting a FortiClient EMS SQL flaw.

Share


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.

Executive Summary

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.

Vulnerability Overview

CVE IDTypeAttack VectorAuth RequiredImpactCVSS Fixed Version
CVE-2026-21643SQL InjectionNetworkNoneArbitrary SQL execution9.17.4.5

Vendor Advisory: FG-IR-25-1142

Background

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.

Root Cause Analysis

The Vulnerable Code Path

The request processing chain in FortiClient EMS 7.4.4 follows this middleware order:

HTTP Request -> Apache/mod_wsgi -> Django
  -> ApiLogMiddleware        (logs the request)
  -> SiteMiddleware          (reads Site header, sets vdom)
  -> PostgresConnection      (opens DB connection with search_path)
  -> AuthMiddleware          (checks session/credentials)
  -> BruteForceProtection    (rate limits login attempts)
  -> View/Controller

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:

# middlewares/site_middleware.py (decompiled from 7.4.4 bytecode)
def _get_header_site(self, request):
    site = request.META.get('HTTP_SITE')
    if site is not None:
        request.META['SITE'] = site.lower()
    return site is not None

def __call__(self, request):
    if not EmsConsts.SITES_ENABLED:
        request.META['SITE'] = EmsConsts.DEFAULT_VDOM
    elif not self._get_header_site(request):
        self._get_subdomain_site(request)

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__():

# models/utils/postgres_conn.py (decompiled from 7.4.4 bytecode)
class PostgresConnection:
    def __init__(self, vdom, ...):
        self.db_name = f"fcm_{vdom}"
        self.searchpath = f"SET search_path TO '{self._db_prefix}{self.db_name}', public, addons"

    def execute(self, query, ...):
        self._connection.execute(self.searchpath)  # Runs before every query

The format-string interpolation embeds the unsanitized vdom value directly into a SQL statement that executes on every database query.

Why It's Vulnerable

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:

Header:         Site: x'; SELECT pg_sleep(5)--
After .lower(): x'; select pg_sleep(5)--
db_name:        fcm_x'; select pg_sleep(5)--
Executed:       SET search_path TO 'fcm_x'; SELECT pg_sleep(5)--', public, addons
                                          ^                      ^
                                      breakout              commented out

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.

Why Only Version 7.4.4 Is Affected

Fortinet's advisory lists the following version matrix:

VersionAffectedSolution
FortiClientEMS 8.0Not affectedNot applicable
FortiClientEMS 7.47.4.4 onlyUpgrade to 7.4.5 or above
FortiClientEMS 7.2Not affectedNot 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):
api_logging.pyc
brute_force_protection_middleware.pyc
conditional_csrf.pyc
security_headers_middleware.pyc
site.pyc
subdomain_session.pyc

7.4.4 middleware stack (10 files):

api_log_middleware.pyc                (renamed from api_logging.pyc)
auth_middleware.pyc                   (new)
brute_force_protection_middleware.pyc
conditional_csrf.pyc
ems_common_middleware.pyc             (new)
error_handling_middleware.pyc         (new)
rate_limit_middleware.pyc             (new)
security_headers_middleware.pyc
site_middleware.pyc                   (renamed from site.pyc, +322 bytes)
subdomain_session.pyc

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.

The Fix in 7.4.5

Version 7.4.5 replaced the format-string interpolation with parameterized identifier handling:

# models/utils/postgres_conn.py (decompiled from 7.4.5 bytecode)
from psycopg.sql import SQL, Identifier

schema = f'{self._db_prefix}{self.db_name}'
self.searchpath = SQL('SET search_path TO {}, public, addons').format(Identifier(schema))

psycopg.sql.Identifier() properly double-quotes and escapes the schema name, preventing breakout regardless of the input value.

Exploitation

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.

Attack Scenario

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.

Exploitation Strategy

  1. Fingerprint: GET /api/v1/init_consts to confirm FortiClient EMS and check SITES_ENABLED
  2. Confirm: Inject pg_sleep(N) via the Site header on init_consts to confirm injection via timing delta
  3. Extract: Use error-based extraction (CAST errors leak query results in HTTP 500 responses) for instant single-request data exfiltration via init_consts

Lab Results

To 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):

Baseline:   1.019s  (HTTP 401, Site: default)
Injection: 21.050s  (HTTP 401, Site: x'; SELECT pg_sleep(10)--)
Delta:     20.030s  (~2x requested sleep)

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):

Baseline:   0.077s  (HTTP 200, Site: default)
Injection: 10.074s  (HTTP 500, Site: x'; SELECT pg_sleep(10)--)
Delta:      9.997s  (~1x requested sleep)

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:

{"result": {"retval": -7, "message": "relation \"system_settings\" does not exist\nLINE 4: FROM system_settings\n ^"}}

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.

Endpoint Characteristics

  • Lockout restrictions. Lab testing confirmed that both /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.
  • Error-based extraction. The 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.
  • Exploitation depth. The injection context supports stacked queries (full SQL command execution via semicolon separation), error-based extraction (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).

Impact Assessment

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:

  • Remote code execution. In the Virtual Machine image Fortinet ships, the PostgreSQL database user runs with superuser privileges, enabling OS command execution via COPY ... TO/FROM PROGRAM. Lab testing confirmed arbitrary file creation on the underlying host as the postgres system user.
  • Credential theft. Extraction of admin password hashes, API tokens, and JWT secrets from authentication tables.
  • Endpoint data exfiltration. Access to the full endpoint inventory: hostnames, IP addresses, OS versions, serial numbers, and installed software across all managed FortiClient deployments.
  • Configuration tampering. Modification of endpoint policies, deployment configurations, and security profiles pushed to managed endpoints.
  • Certificate extraction. Access to ZTNA certificates and SAML configuration data, potentially enabling lateral movement into connected Fortinet infrastructure.
  • Persistence. Creation of new admin accounts or modification of existing credentials for persistent access to the management console.

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.

Indicators of Compromise

Review Apache Access Logs

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:

  • Unusually long response times on pre-auth endpoints like /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.
  • HTTP 500 responses on /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).
  • Repeated requests to /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.
  • Repeated requests to login endpoints from unfamiliar source IPs, particularly if they coincide with periods of degraded EMS responsiveness (which can indicate connection pool saturation from injection).

The default Apache log location on FortiClient EMS appliances is /var/log/apache2/ or the path configured in the Apache virtual host configuration.

Check PostgreSQL Error Logs

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 via pg_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, setting log_statement = 'all' will capture everything, but be aware this has significant performance and storage overhead and should be treated as a temporary forensic measure.

Remediation

Patching

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.

Workarounds and Mitigations

  1. Restrict network access. Limit HTTPS access to the EMS web GUI to authorized management networks only. The vulnerability requires direct HTTPS access to the EMS web interface.
  2. Disable multitenancy. If multi-tenant mode is not required, disabling the Sites feature sets SITES_ENABLED=False, which causes SiteMiddleware to hardcode the default vdom and never read the Site header. The vulnerable code remains present but unreachable.
  3. WAF/reverse proxy filtering. Deploy a web application firewall or reverse proxy rule to strip or validate the Site header, blocking values containing single quotes, semicolons, or SQL keywords.

Methodology

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.

Conclusion

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.

References

Subscribe to our blog

Be first to learn about latest tools, advisories, and findings.

This site uses cookies to provide you with a great user experience. By continuing to use our website, you consent to the use of cookies. To find out more about the cookies we use, please see our Privacy Policy.