A Security Vendor Got Owned by SQL Injection
CVE-2026-21643 is an unauthenticated SQL injection in FortiClient EMS 7.4.4. A Python f-string gave attackers access to every managed endpoint.
Fortinet makes the products that companies buy to protect their networks. Last week, one of those products got owned by the oldest web vulnerability in the book.
CVE-2026-21643 is a SQL injection in FortiClient EMS with a CVSS score of 9.1. It requires no authentication. On April 13, CISA added it to the Known Exploited Vulnerabilities catalog, confirming that attackers are actively using it. The root cause is three lines of Python where someone replaced a parameterized query with an f-string during a code refactor.
What Is FortiClient EMS?
FortiClient Enterprise Management Server is the central console that pushes security policies, software updates, and configuration changes to every FortiClient agent installed on an organization’s endpoints. Laptops, desktops, servers. If you manage FortiClient across your fleet, EMS is the brain.
That also makes it a single point of failure. An attacker who takes over EMS can steal administrative credentials, exfiltrate the entire endpoint inventory, tamper with security policies, and (depending on the database configuration) execute operating system commands on the server itself.
According to Shodan, roughly 1,000 FortiClient EMS instances are directly exposed to the internet. The number running the vulnerable version with multi-tenant mode enabled is unknown, but the ones that are vulnerable are extremely high value targets.
What Is CVE-2026-21643?
CVE-2026-21643 is a SQL injection vulnerability (CWE-89) in FortiClient EMS version 7.4.4 when running in multi-tenant mode. Versions 7.2.x and 8.0 are not affected. Fortinet patched it in version 7.4.5.
The bug lives in the database connection layer. FortiClient EMS runs on PostgreSQL, and when a request comes in, middleware reads the Site HTTP header to determine which tenant’s database schema to use. That header value gets interpolated into a SET search_path SQL statement.
In version 7.4.3, this was handled safely. In 7.4.4, a refactor of the middleware stack introduced Python f-string interpolation:
searchpath = f"SET search_path TO '{self._db_prefix}{self.db_name}', public, addons"
The self.db_name value comes directly from the Site header. No sanitization. No escaping. No parameterization. The user controls part of the SQL statement.
The fix in 7.4.5 replaced this with psycopg.sql.Identifier(), which properly handles special characters. That is the entire fix. The vulnerability existed because three lines of code used string formatting instead of parameterized queries.
We wrote about this exact problem two months ago. Here it is happening inside a security vendor’s own codebase.
How the Attack Works
The primary injectable endpoint is GET /api/v1/init_consts, a public endpoint that returns system configuration constants. It requires no authentication.
A malicious request looks like this:
GET /api/v1/init_consts HTTP/1.1
Host: target.example.com
Site: x'; SELECT pg_sleep(10)--
The Site header value breaks out of the quoted string in the SET search_path statement with a single quote and semicolon, executes arbitrary SQL, and comments out the trailing syntax with --. Textbook injection.
The attack chain from there:
-
Confirm the target. Send a normal request to
/api/v1/init_constsand check ifSITES_ENABLEDreturns true. If it does, multi-tenant mode is active and theSiteheader is being processed. -
Extract data. Use error based extraction. Injecting
CASTstatements with intentional type mismatches causes PostgreSQL to throw errors that leak query results in the HTTP 500 response body. No blind injection needed. -
Escalate to remote code execution. The PostgreSQL user running FortiClient EMS typically has superuser privileges. This means
COPY ... TO/FROM PROGRAMis available, which lets the attacker execute arbitrary operating system commands directly from a SQL query.
Step 3 is what turns a SQL injection into full server compromise. The attacker never needs to log in, upload a shell, or exploit a second vulnerability. SQL injection alone is enough to go from zero access to running commands on the host.
Active Exploitation
The timeline:
| Date | Event |
|---|---|
| Early March 2026 | Bishop Fox publishes technical analysis with vulnerable code and injection vector |
| Late March 2026 | Defused Cyber detects first active exploitation attempts |
| April 4, 2026 | Fortinet acknowledges exploitation in advisory FG-IR-26-099 |
| April 13, 2026 | CISA adds CVE-2026-21643 to Known Exploited Vulnerabilities catalog |
| April 27, 2026 | Federal civilian agencies must have patched by this date |
The combination of a detailed public writeup, a pre authentication attack surface, and the high value of the target made exploitation inevitable. Once Bishop Fox published the exact endpoint and header to inject, the barrier to entry dropped to zero.
If you are running FortiClient EMS 7.4.4 in multi-tenant mode, upgrade to 7.4.5 now. If you cannot patch immediately, restrict network access to the EMS administrative interface. It should never be exposed to the internet directly. If it is right now, take it offline until you can update.
How to Detect Exploitation Attempts
HTTP logs. Look for requests to /api/v1/init_consts with unusual Site header values. Any Site header containing single quotes, semicolons, or SQL keywords (SELECT, UNION, COPY, pg_sleep) is malicious.
PostgreSQL logs. Enable log_statement = 'all' temporarily and review SET search_path statements for unexpected syntax. Error messages involving CAST or type conversion failures may indicate data extraction in progress.
Network monitoring. Watch for outbound connections from the PostgreSQL process to unfamiliar hosts. This may indicate post exploitation activity: data exfiltration or reverse shells established through COPY ... TO PROGRAM.
If you find evidence of exploitation, assume the attacker has the database contents. That includes administrative credentials, endpoint inventory, security policies, and certificates. Rotate everything.
The Broader Lesson
This vulnerability was introduced during a routine code refactor. Someone changed a safe query to an f-string. It passed code review. It passed QA. It shipped in a production release of a security product used by enterprises worldwide.
The fix was replacing f"SET search_path TO '{value}'" with a parameterized identifier. Three lines of code. A junior developer who has done one SQL injection lab would have caught it.
SQL injection is not a sophisticated attack. It is a solved problem with a known solution (parameterized queries) that has been documented since the late 1990s. And yet it keeps showing up, because developers under deadline pressure take shortcuts, and code review does not always catch them. Even at security companies. Especially during refactors, when working code gets rewritten and previously safe patterns get replaced with unsafe ones.
If you take one thing from this: review your database queries after every refactor. Grep your codebase for string formatting in SQL statements. The command takes ten seconds:
grep -rn "f\".*SELECT\|f\".*INSERT\|f\".*UPDATE\|f\".*DELETE\|f\".*SET" --include="*.py" .
The vulnerability that just cost Fortinet their reputation took three lines to introduce and three lines to fix. The gap between those two events was measured in compromised organizations.
The Web Application Exploitation course on Endolum Academy covers SQL injection from first principles through advanced techniques, with labs where you exploit real vulnerable applications. The first 5 chapters are free. No credit card, just log in and start breaking things.