Vulnerability Research

CVE-2026-8037: Progress Kemp LoadMaster – Pre-Auth Root RCE

By SecureLayer7 Lab

15 min read

CVE-2026-8037: Progress Kemp LoadMaster — Pre-Auth Root RCE

CVE-2026-8037: Progress Kemp LoadMaster — Pre-Auth Root RCE via Uninitialized Heap and Missing Null Terminator

Progress Kemp LoadMaster is a widely-deployed enterprise load balancer and Application Delivery Controller sold as a virtual, hardware, or cloud-native appliance. In most environments it sits on the network edge, terminates external TLS, and routes traffic into backend web tiers — meaning a full compromise of the appliance is a full compromise of the traffic passing through it.

CVE-2026-8037 is a pre-authentication remote code execution vulnerability in that appliance’s management API. A single crafted HTTP request to the /accessv2 endpoint runs arbitrary shell commands as root on the box. No authentication is required. No user interaction is required. The chain composes a memory-safety defect (uninitialized heap memory + missing null terminator on a sanitizer’s output buffer) with a classic command-injection sink (system() executed against a string constructed by the sanitizer). This is a rare shape — the two bug classes are usually written about separately — and the combination is what makes the CVE structurally interesting rather than just severe.

The attack flow is:

attack flow of cve-2026-8037

What is Progress Kemp LoadMaster?

Progress Kemp LoadMaster is a Layer 4/7 load balancer and Application Delivery Controller marketed as a lightweight alternative to F5 BIG-IP and Citrix NetScaler. It is sold as a virtual appliance for VMware, Hyper-V, KVM, AWS, and Azure, as hardware appliances for on-premises data centers, and as a Kubernetes-native ingress. The appliance handles TLS termination, session persistence, health-checking, WAF integration, and backend routing for whatever web tier sits behind it.

Because LoadMaster typically terminates external TLS at the network edge, the appliance sees the plaintext of every request that passes through it. It holds the private key material for the external certificates, credentials configured for backend health probes, and — in typical deployments — has line-of-sight to internal management networks that its ostensibly-external position should not have. Root code execution on the LoadMaster is therefore a load-bearing security boundary for the environment it fronts, and one of the reasons enterprise load balancers have been high-value targets for state and financially-motivated actors alike over the last several years.

Vulnerability Overview

The LoadMaster management stack exposes a JSON API on a binary named access. One of the endpoints handled by this binary is POST /accessv2, which processes authentication attempts against the appliance’s local user database. The endpoint accepts a JSON body containing an apiuser and apipass field, sanitizes the two values so they can be safely embedded inside a shell command, and then constructs a validuser -b -u ‘<user>’ -p ‘<pass>’ invocation which it hands to system() for execution.

The sanitizer, escape_quotes(), is the vulnerable function. It contains two independent memory-safety defects:

  1. Uninitialized output buffer. The output buffer is allocated with malloc(), which does not zero the returned memory. On the glibc allocator used by LoadMaster, the first bytes of a freshly-malloc()-returned chunk contain allocator metadata and residue from previously-freed chunks — bytes the attacker will later be able to influence.
  2. Missing null terminator. After the escape logic writes the escaped bytes into the buffer, no *end = 0; line follows. The output buffer is left unterminated. Downstream code that assumes the string is C-style terminated — including the __sprintf_chk call that assembles the final shell command — reads past the end of the buffer into whatever bytes happen to sit in the next heap chunk.

The two defects compose: malloc leaves attacker-influenceable bytes in place; the missing null terminator ensures those bytes are read by the downstream sprintf; and the downstream sprintf’s output is passed to system(). The result is pre-auth command injection with a single HTTP request.

CVE-2026-8037 carries a CVSS 3.1 base score of 9.8 (Critical), vector AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (ZDI-26-342). The exploit is network-reachable, low-complexity, requires no authentication, and produces full compromise of confidentiality, integrity, and availability. In practice — because LoadMaster runs the vulnerable access service as root — the attacker is not merely inside the process but has full control over the appliance.

Root Cause Analysis

The escape_quotes() Function

The function’s purpose is to accept a raw string from the JSON body and return a version safe to embed inside a single-quoted shell argument. Shell single-quoting is simple: everything inside ‘…’ is literal, so the only character that needs special handling is ‘ itself, which is rewritten as ‘\” — close the current quote, insert an escaped single quote, and reopen the next quote.

Conceptually the function is:

// Reconstructed from watchTowr Labs' disassembly of the pre-patch build
char *escape_quotes(const char *user_input) {
    size_t len = strlen(user_input);
    char *out = malloc(3 * (len + 1) + 29);   // vulnerable allocation
    if (!out) return NULL;

    char *end = out;
    for (const char *p = user_input; *p; p++) {
        if (*p == '\'') {
            *end++ = '\''; *end++ = '\\'; *end++ = '\''; *end++ = '\'';
        } else {
            *end++ = *p;
        }
    }
    // (no *end = 0; here — this is the second defect)
    return out;
}

There are two defects visible in this snippet. The first is on the allocation line: malloc returns memory whose contents are undefined, and escape_quotes never zero-fills the buffer. The second is at the end of the loop: end is left pointing at whatever byte follows the last written escape character, and no null terminator is written.

From Defect to Primitive

The escaped string leaves escape_quotes() and flows into the command-assembly step:

// Conceptual reconstruction of the caller
char cmd[BUF_SIZE];
__sprintf_chk(cmd, ..., "validuser -b -u '%s' -p '%s'",
              escape_quotes(apiuser),
              escape_quotes(apipass));
system(cmd);

__sprintf_chk treats its %s arguments as C strings — that is, it reads bytes until it encounters a \0. Because the buffer returned by escape_quotes() has no null terminator, __sprintf_chk continues reading past the end of the escaped output, into whatever bytes sit in the adjacent heap chunk. Those bytes become part of the string that system() executes.

The attacker’s job therefore has two parts:

  1. Make escape_quotes()’s output buffer land adjacent to attacker-controlled bytes. This is the “spray” step — flood the allocator with chunks whose contents the attacker chose, so one comes to rest immediately after the unterminated buffer.
  2. Choose the sprayed content so that when __sprintf_chk reads past the buffer, the bytes it picks up form a command-injection payload.

Unterminated Buffer via the Four-Quote apiuser

The apiuser value ”” — four single quotes — is the primitive that ties the two defects together. escape_quotes() expands each ‘ into the four-byte sequence ‘\”, so four input quotes become sixteen output bytes. Those sixteen bytes sit comfortably inside the buffer’s allocation (3 * (4 + 1) + 29 = 44 bytes) — nothing overflows and nothing crashes. This is not an over-write: the escaped output never reaches the buffer boundary and never touches adjacent chunk metadata.

The important effect is the missing null terminator. After the loop writes those sixteen bytes, the buffer is left unterminated. Choosing exactly four quotes guarantees the buffer is fully written with escape data and no stray \0, so when __sprintf_chk later treats it as a C string it reads straight past the sixteen bytes and on into whatever sits in the adjacent heap chunk. The bug is an out-of-bounds read, not a write.

Heap Spray to Populate the Adjacent Chunk

The /accessv2 endpoint deserializes the JSON body permissively — extra keys are accepted, not rejected. The attacker inserts a wall of keys named g0, g1, g2, …, g60, each carrying an identically-shaped string like:

AAAAAAAAAAAAAAAA’; cat `/`etc/passwd #

Each of these strings gets copied into a heap chunk during JSON parsing. With enough sprays, one of them lands in the chunk adjacent to the escape_quotes() output. Modern heap allocators cluster same-sized allocations for cache efficiency — a behavior the attacker exploits deterministically by making all the sprayed strings the same length.

The sprintf Read-Past-Buffer

__sprintf_chk now walks the escaped apiuser buffer looking for a \0 and doesn’t find one. It walks off the end and into the adjacent chunk. The first bytes it encounters there are the attacker’s sprayed payload: AAAAAAAAAAAAAAAA’; cat `/`etc/passwd #.

Those bytes get pasted directly into the format-string output. The final cmd buffer contains something like:

validuser -b -u ”\”’\”’\”’\”AAAAAAAAAAAAAAAA’; cat `/`etc/passwd #’ -p ”

The single quote in the payload (AAAAA…’; …) closes the shell single-quoted string that was opened by the format template. ; cat `/`etc/passwd executes as a separate command. # comments out everything that follows on the line, so the rest of the format-template’s own tail — the closing ‘ -p ” — is neutralized.

system() Runs the Composed Command

The composed string is handed to system(). system() spawns a shell and executes the string. cat `/`etc/passwd — or any other command the attacker sprayed — runs as root, in the context of the LoadMaster’s access service account.

The response returned to the HTTP client is a normal authentication failure. The side effect — the shell command — runs entirely on the server side. The attacker sees nothing in the HTTP response by default; command output must be exfiltrated through a separate channel (writing to a file the attacker later fetches, calling out to an attacker-controlled DNS or HTTP endpoint, or invoking a reverse-shell binary).

Composing Two Bug Classes

Neither defect alone is exploitable. malloc returning uninitialized memory is uncomfortable but not directly abusable; missing a null terminator on a buffer used only by length-aware code is a latent bug that never fires. It is the composition — an unterminated buffer whose next-heap-chunk neighbor is attacker-controlled, being read as a C string by sprintf, whose output is executed by system() — that produces the RCE.

This composition is worth naming because it is the shape defenders should look for in adjacent code: any C function that returns a buffer without an explicit null terminator, whose caller invokes a length-agnostic string operation, is a candidate for the same bug. The escape_quotes() pattern is not unique to LoadMaster.

Patch Diffing

Progress fixed CVE-2026-8037 in LoadMaster GA 7.2.63.2 and LTSF 7.2.54.18. The fix is two lines. Reconstructed from watchTowr Labs’ binary diff between the pre- and post-patch access binaries:

Before (vulnerable, 7.2.63.1):

char *out = malloc(3 * (strlen(user_input) + 1) + 29);
// ...escape loop that writes to *end...
return out;

After (patched, 7.2.63.2):

char *out = calloc(1u, 4 * (strlen(user_input) + 1) + 28);
// ...escape loop that writes to *end...
*end = 0;
return out;
patch diffing - pre-patch vs patched

Two changes:

  1. malloccalloc(1u, …). calloc zero-fills the returned buffer at allocation. Any residual bytes from previously-freed chunks — the material that an attacker could later cause to appear in the adjacent chunk — are wiped out. The uninitialized-memory read is closed at the source.
  2. *end = 0; added. After the escape loop, the buffer is explicitly terminated. __sprintf_chk and any other length-agnostic caller now stops at the correct boundary rather than walking off into the next heap chunk.

A third, subtler change lives on the allocation line: the size expression changed from 3 * (len + 1) + 29 to 4 * (len + 1) + 28. The 4× multiplier accommodates the worst-case expansion, where every input byte is a single quote (each rewritten to four bytes: ‘\”) — the old 3× multiplier under-sized the buffer for all-quote input. This is a pure correctness fix that closes a latent short-buffer write; it is independent of the out-of-bounds-read primitive, which the calloc zero-fill and the added null terminator are what actually close.

The patch does not remove the escape_quotes → sprintf → system pipeline. That architectural choice — sanitize a user-controlled string and pass it through a shell for execution — remains in place. The fix closes the specific defect in the specific sanitizer function. If a future refactor of escape_quotes reintroduces either defect (or if any other sanitizer in the codebase has the same shape), the CVE family will return.

The Structural Question

There is a broader question worth asking about the shape of the fix. Progress patched a memory-safety bug in a shell-command builder. The durable fix is not a better sanitizer — it is not building a shell command at all. Modern practice for spawning subprocesses on Linux uses execve() (or posix_spawn()) with a char *argv[] array, in which each argument is a discrete C string that is never re-parsed by a shell. In that model there is no quoting to escape, no sanitizer to write, and no escape_quotes()-shaped function to get wrong.

The access binary was written against system() because system() is simpler to reach for. It carries a well-documented recurring risk — every classic command-injection reference from the last two decades identifies “avoid system() when the argument depends on user input” as a top-of-list guideline. A version of LoadMaster that uses execve(argv=[“validuser”, “-b”, “-u”, user, “-p”, pass]) cannot have this CVE. A version that patches this specific escape_quotes() bug still has adjacent surface where the same pattern can recur.

Static Analysis

The access Binary

access is one of several binaries that make up the LoadMaster management stack. It is a native Linux ELF, statically linked against a modified glibc, running as a persistent service under a systemd-managed unit. On a stock LoadMaster the process is owned by root; there is no privilege separation between the HTTP-facing endpoint and the shell-command-executing subprocesses it spawns.

The binary handles multiple endpoints under /access*. The vulnerable endpoint is /accessv2. Other endpoints on adjacent paths — the legacy /access, the newer /accessv3 — share code with /accessv2 and are worth diffing in the patched build to determine whether the fix in escape_quotes() fully protects all callers.

ghidra access binary escape quotes

The Wire Protocol

A minimal exploitation request is a POST to /accessv2 with a JSON body:

POST /accessv2 HTTP/1.1
Host: loadmaster.target.example.com
Content-Type: application/json
Content-Length: 2600

{
  "apiuser": "''''",
  "apipass": "any",
  "g0":  "AAAAAAAAAAAAAAAA'; id > /tmp/pwn #",
  "g1":  "AAAAAAAAAAAAAAAA'; id > /tmp/pwn #",
  ...
  "g60": "AAAAAAAAAAAAAAAA'; id > /tmp/pwn #"
}

The response is an ordinary authentication failure — HTTP 200 or 401 depending on version, with a small JSON body describing the failure. The side effect happens on the server side, out of band from the response.

The Wire Protocol

The JSON body’s suspicious features are not subtle. Any request whose apiuser field consists of four (or more) adjacent single quotes, or whose body contains a repeated block of same-length string values whose contents include ‘; …#, is a high-fidelity indicator that the exploit is being fired. WAF and IDS signatures around this shape are straightforward to write.

The Heap Layout

Behind the scenes, the JSON parser allocates a chunk per string value. All the sprayed gN values are the same length, so they cluster in the same heap-allocator size class. The escape_quotes output buffer is a different, slightly smaller size class, but on a live LoadMaster the two size classes end up sharing an arena, and neighborhood is likely enough that spraying on the order of sixty strings (the g0–g60 wall used here) brings adjacency into a very high probability.

The attacker does not need pixel-perfect heap control. The chain is loose enough to work reliably in production — and public reporting placed in-the-wild exploitation on June 29, 2026, the same day watchTowr’s writeup went public.

the heap layout

The Terminal Sink: system()

The final composed command is passed to system(). system() invokes /bin/sh -c with the composed string. The shell parses the string, honors the attacker’s ‘; as a command separator, executes the injected command, and honors the trailing # as a comment — all standard POSIX shell behavior, none of which the appliance can defend against once the composed string has reached this line.

The system() call is the appliance’s own footgun. Even a perfect sanitizer would leave system() reachable; a hardening pass that replaced system() with execve() would close the whole class of bug regardless of what the sanitizer does.

Conclusion

Impact

CVE-2026-8037 grants pre-authentication root code execution on a Progress Kemp LoadMaster appliance whose access API is enabled and network-reachable. The severity is not merely a function of the RCE — it is a function of where the RCE lands. LoadMaster typically sits at the network edge, terminating external TLS. An attacker with root on the appliance owns the plaintext of every session it fronts, holds the private key material for the appliance’s TLS certificates, and inherits any credentials the appliance was configured with for backend health probes, LDAP binds, or upstream authentication services. Persistence is trivial: the appliance runs a full Linux userland, and any implant dropped by the attacker survives across restarts unless the underlying firmware image is rebuilt.

Downstream, the compromised LoadMaster is a launch point into the backend network. Its position — internal to the perimeter, on the management VLAN, with pre-established connectivity to every server in its backend pool — is what network defenders typically call “post-exploitation gold.” A single successful exploitation of /accessv2 collapses the distance between “internet scan” and “inside the network with a persistent Linux implant on the traffic terminator” to one HTTP request.

Because the exploit is pre-auth, the attack surface is the entire internet-facing exposure of the appliance’s management port. Any LoadMaster whose /accessv2 endpoint has been publicly reachable at any point since the vulnerability existed must be considered potentially compromised, whether or not exploitation was observed. The fact that no alert fired is not evidence that no exploitation occurred; it is evidence that the appliance’s logging did not surface it.