Finding and Exploiting Citrix NetScaler Buffer Overflow (CVE-2023-3519) (Part 3)
Introduction
A lot has been written about the recent Citrix NetScaler buffer overflow. In the initial rush to get information and platform checks out to customers, some details may not have been fully explained. In this post we hope to rectify that by detailing the full process from the initial announcement to a working exploit.
For a brief background on the vulnerability, on July 18 2023 Citrix announced an unauthenticated remote code execution vulnerability in Citrix ADC and Citrix Gateway. No details or IOCs were provided and we began reversing the patch to determine if it was applicable for our platform. Our previous analyses are available here and here.
We have added a python script demonstrating our exploit for version <span class="code_single-line">13.1-48.47</span> of Citrix Netscaler to GitHub: https://github.com/assetnote/exploits/tree/main/citrix/CVE-2023-3519. Some tweaks are required for other versions, but we leave that as an exercise for the reader.
Patch Diffing
We started by downloading and configuring the two most recent versions of Citrix NetScaler, which were 13.1-48.47 and 13.1-49.13. From some of our previous work we knew that the Citrix Gateway component was handled by the <span class="code_single-line">/netscaler/nsppe</span> binary. This is the NetScaler Packet Processing Engine (<span class="code_single-line">nsppe</span>) and it implements a complete network stack along with multiple HTTP services. We took the patched (49.13) and unpatched (48.47) versions of these binaries and decompiled them with Ghidra. Because the binary is so large we had to tweak some of the Ghidra decompilation settings to ensure success.
We bumped up the decompiler resources under Edit -> Tool Options -> Decompiler to the following.
Cache Size (Functions): 2048Decompiler Max-Payload (Mbytes): 512Decompiler Timeout (seconds): 900Max Instructions per Function: 3000000After decompiling each binary, we generated a BinDiff file for each one with the BinExport Ghidra extension. These were then compared in BinDiff and we started looking at each function that had detected changes. For most of these functions, rather than compare them directly in BinDiff we took the decompiled code for the functions from Ghidra and compared them textually.
The first notable function we found was <span class="code_single-line">ns_aaa_saml_parse_authn_request</span>, which unfortunately turned out to be a red herring. While the patch did include a fix for a memory corruption vulnerability in this function, there was no immediately obvious way to pivot it to remote code execution. No CVE has been raised for the potential denial of service that is possible as a result of this vulnerability.
We kept looking, comparing each function identified by BinDiff until we came across <span class="code_single-line">ns_aaa_gwtest_get_event_and_target_names</span>. We saw what looked like an additional length check in the patched version of the code.
We traced the calls to <span class="code_single-line">ns_aaa_gwtest_get_event_and_target_names</span> with Ghidra to <span class="code_single-line">ns_aaa_gwtest_handler</span> which contained the second part of the URL, <span class="code_single-line">/formssso</span>.
Looking a bit further back in the call graph we found <span class="code_single-line">ns_aaa_gwtest_handler</span> was called by <span class="code_single-line">ns_vpn_process_unauthenticated_request</span>. Here we found the first part of the URL, <span class="code_single-line">/gwtest/</span>.
We now had the endpoint, <span class="code_single-line">/gwtest/formssso</span>, but didn’t know how to call it. To do this we looked at the Ghidra output for <span class="code_single-line">ns_aaa_gwtest_get_event_and_target_names</span>. We found that it expected an <span class="code_single-line">event</span> query parameter which needed to have a value of either <span class="code_single-line">start</span> or <span class="code_single-line">done</span>. It then checked for a <span class="code_single-line">target</span> query parameter and passed the value to the vulnerable <span class="code_single-line">ns_aaa_saml_url_decode</span> function. A cut-down version of this function is included below.
We sent through the following request and caused a server crash. The next step was to figure out a way to understand the crash without getting too frustrated at the lack of tooling.
Debugging Citrix NetScaler
The biggest problem was that the <span class="code_single-line">nsppe</span> binary we were trying to analyse is also responsible for all network traffic in and out of the VM. If we attach a debugger while in an SSH session the connection is immediately severed. This meant we had to do everything in the VM console window, which was small, didn’t support copy / paste and was occasionally spammed with log messages from other processes. It also meant we couldn’t use any GDB plugins like PEDA to aid exploit development.
Luckily for us, the VM included a copy of GDB and GDBServer. Normally GDBServer is used over TCP, however it also supports serial devices. We added a virtual serial device to our VM and tested it out. Sending data over the new serial device worked without issue.
However, when it came to GDB and GDBServer the connection would never work. We suspected it had something to do with the file not being a “serial device” on the MacOS side of the connection. There was no option in the GUI for VMware Fusion to configure the device any other way. But, we found we could edit the <span class="code_single-line">.vmx</span> file and changed the serial device to the following.
With this setup VMware listened on tcp port 12345 on the host and forwarded the connection to the serial device in the VM. We could now get a full debugging session working with GDB running locally with PEDA installed.
The first step was suspending the <span class="code_single-line">pitboss</span> monitoring process. This process automatically restarted <span class="code_single-line">nsppe</span> if it detected it not responding. To suspend <span class="code_single-line">pitboss</span> we attached to it with GDB and just left in the background.
We were then free to start debugging with GDBServer. We used the serial device <span class="code_single-line">/dev/cuau0</span> as the transport and attached to <span class="code_single-line">nsppe</span>.
On the host side we ran GDB, loaded in the <span class="code_single-line">nsppe</span> binary and called <span class="code_single-line">target remote 127.0.0.1:12345</span> to connect to GDBServer.
Although the setup was not perfect, there were still occasionally issues where single-stepping instructions at certain locations caused the program to behave differently. This was still much better than the tiny console window.
Dissecting the Crash
We set a breakpoint on <span class="code_single-line">ns_aaa_gwtest_get_valid_fsso_server</span> and sent through the payload. We stepped through the function call up to where it called the vulnerable function <span class="code_single-line">ns_aaa_gwtest_get_event_and_target_names</span>.
Stepping over this call frequently resulted in unrelated errors. To fix this we set a breakpoint just after the call at <span class="code_single-line">0xc7fa93</span> instead. When we ran the exploit again we saw a corrupted call stack but the application had not yet crashed. This can be seen in the snippet below, the <span class="code_single-line">backtrace</span> command shows the call stack was filled with <span class="code_single-line">0x41</span>, the <span class="code_single-line">A</span> character we used in the payload.
We knew the new length check was 128 bytes in the patched version, so we updated the payload with multiples of <span class="code_single-line">B</span>, <span class="code_single-line">C</span>, <span class="code_single-line">D</span> towards the suspected end of the buffer. Our goal was to find how much space we had to fill before overwriting a return address. A less haphazard approach such as a binary search may have been quicker here, but we had good visibility and the addresses did not change between runs. Eventually we ran the payload <span class="code_single-line">'A' * 160 + 'B' * 8 + 'C' * 8 + 'D' * 8</s[am> and saw <span class="code_single-line">0x4343434343434343</span> (the 8 <span class="code_single-line">C</span> bytes) filled the return address. This can be seen below.
This meant we had a buffer of 168 bytes, followed by the address we wanted to return to. Since the stack is marked as executable, we decided to jump to the start of the buffer at <span class="code_single-line">0x7fffffffc130</span>. We put together the following payload, four <span class="code_single-line">nop<span> instructions for the shellcode, the return address and the rest padded to fill up to 168 bytes.
Attempts were made to URL encode all bytes, but we would later learn that there is a bug in the URL decoding which affects bytes greater than <span class="code_single-line">0xa0</span>. We chose to only URL encode a few characters, adding troublesome bytes to a list as we encountered them. We ran the exploit, and continued execution from the breakpoint until the end of the function. Upon returning we found execution neatly at the start of the four <span class="code_single-line">nop</span> instructions.
Exiting Cleanly
In order to add a check for this vulnerability to our platform, the exploit has to execute without interrupting the service. We needed some shellcode that would clean up after the exploit and enable the application to continue normal operation. To do this we set a breakpoint at <span class="code_single-line">ns_aaa_gwtest_get_valid_fsso_server</span> and inspected the call stack before the overflow was triggered. This would help us understand where execution would continue from under normal circumstances.
We continued execution and stopped when we reached the shellcode. At this point we looked at the 20 64-bit words from the top of the stack to see if any matched the return addresses we saw in the previous backtrace. At <span class="code_single-line">0x7fffffffc210</span> we saw the pushed <span class="code_single-line">rbp</span> value followed by the return address of <span class="code_single-line">ns_vpn_process_unauthenticated_request</span>.
Subtracting the current <span class="code_single-line">rsp</span> value <span class="code_single-line">(0x7fffffffc1e0)</span> from the target <span class="code_single-line">rsp</span> value <span class="code_single-line">(0x7fffffffc210)</span>, gave us a difference of <span class="code_single-line">0x30</span>. We then added the following assembly instruction to the shellcode. This would increment the stack pointer, pop the stored base pointer and then continue execution of <span class="code_single-line">ns_vpn_process_unauthenticated_request</span>.
We ran the new exploit, stepped through the shellcode right up until the <span class="code_single-line">ret</span> instruction and looked at the call stack. As you can see below, everything looked good. At this stage we were able to run the exploit repeatedly without interrupting the service.
Writing an Exploit
We now had a reliable starting point, all we had to do was write shellcode that would execute arbitrary commands. The approach we settled on was to write a small webshell to a file and then call that with a separate request. To do this we modified the payload to start with the filename and file contents. We also had to update the return address to land after this point in the buffer. We now had the following shellcode.
Unfortunately, when we looked at the buffer, a null byte had been inserted midway through.
To fix this we added some padding, the last of which would be converted to a null byte. The shellcode was now the following.
The first stage of the exploit would be making the <span class="code_single-line">open</span> syscall to get a file descriptor. Since NetScaler is based on FreeBSD we would be using the x64 System V ABI calling convention. The first three arguments to the call would be in registers <span class="code_single-line">rdi</span>, <span class="code_single-line">rsi</span> and <span class="code_single-line">rdx</span>. The syscall number would be in <span class="code_single-line">rax</span> and it would be triggered via the <span class="code_single-line">syscall</span> instruction.
To begin we copy <span class="code_single-line">rsp</span> to <span class="code_single-line">rdi</span> and then subtract <span class="code_single-line">0xb0</span> so that it points to the start of <span class="code_single-line">/var/vpn/theme/x.php</span>.
Next we needed to set the <span class="code_single-line">flags</span> argument, we wanted <span class="code_single-line">O_CREAT | O_WRONLY</span> to create the file and open it for writing. A small gotcha when looking up these constants is to ensure you get the FreeBSD values and not the Linux ones. On Linux <span class="code_single-line">O_CREAT</span> is <span class="code_single-line">0x100</span>, but on FreeBSD it is <span class="code_single-line">0x200</span>.
For the final argument, we set the <span class="code_single-line">mode</span> to <span class="code_single-line">0x1ff</span> which corresponds to a <span class="code_single-line">777</span> file mode.
Lastly, we set the <span class="code_single-line">rax</span> register to the open syscall number, which on FreeBSD is 5. We could then execute the syscall.
Next we needed to make a <span class="code_single-line">write</span> syscall. Since <span class="code_single-line">rax</span> should hold the file descriptor returned from the <span class="code_single-line">open</span> syscall, we copied that to the <span class="code_single-line">rdi</span> register. We then did the same <span class="code_single-line">rsp</span> trick as before to get a pointer to the file contents into the <span class="code_single-line">rsi</span> register. And lastly, we put the file size in bytes (<span class="code_single-line">0x1a</span>) into the <span class="code_single-line">rdx</span> register.
Next we made a <span class="code_single-line">close</span> syscall. The first and only argument was in <span class="code_single-line">rdi</span> and is the file descriptor which was unchanged from the previous call. So all we needed to do was set the <span class="code_single-line">rax</span> register and execute the <span class="code_single-line">syscall</span> instruction.
At this stage, we now had the full payload which is shown below.
After executing this we could call the webshell as follows.
Note that the response from this is cached by NetScaler, the only way we could find to clear the cache was a restart or by also running <span class="code_single-line">/netscaler/nsapimgr_wr.sh -ys call=ns_ic_flush</span>. A bit more work would be required to make this work in one shot, but for the purpose of proving exploitability this was all we needed.
Final Thoughts
In this post we saw an almost textbook example of a stack-based buffer overflow. The initial flaw was an unbounded copy, but this was exacerbated by having no other mitigations. The stack was executable, address space was not randomised, there were no stack canaries and the Gateway application is bundled with the network stack rather than in a separate process with less privileges. With no special configuration required and the popularity of this appliance this vulnerability has had a huge impact.
We also saw the importance of good tooling. Our previous research on Citrix Gateway was definitely slowed by not having a decent debugging setup. And even in this case, where we had everything setup, the 30+ second restart time between crashes was a big dampener on how fast we could develop the exploit.
As always, customers of our Attack Surface Management platform have been notified for the presence of this vulnerability. We continue to perform original security research in an effort to inform our customers about zero-day and N-day vulnerabilities in their attack surface.
More Like This
Ready to get started?
Get on a call with our team and learn how Assetnote can change the way you secure your attack surface. We'll set you up with a trial instance so you can see the impact for yourself.