Jan 13, 2026

Unauthenticated RCE in NetSupport Manager - A Technical Deep Dive

The main focus of the Security Intelligence Service is monitoring the Internet footprint of our clients. On request, CODE WHITE also performs vulnerability scanning from within corporate networks in an assumed breach mode. As part of this activity, we surprisingly often stumbled upon the remote control and support software NetSupport Manager, developed by NetSupport Ltd. The managed hosts were usually industrial computers responsible for high-level orchestration and control of critical processes in Operational Technology (OT) environments. For remote service and maintenance, the NetSupport Manager service was often allowed to pass internal firewalls, making it an ideal target from an attacker’s perspective to cross network segmentation boundaries and access critical environments.

Therefore, we took a deeper look at the NetSupport Manager software and found two 0-day vulnerabilities. When combined, the vulnerabilities can be exploited to achieve unauthenticated remote code execution.

But let’s start from the beginning of this odyssey…

The Analysis

As usual, we started with a fresh installation of the latest available NetSupport Manager version (14.10.4.0 at that time) with the vendor’s default settings in a recent Windows 11 VM. During installation, the user can choose to install various components of the software, the most relevant being:

  • The Client component (referred to in the following as client) required for remote control of the host.
  • The Control component that connects to hosts running the client software and allows remote control via a GUI.

Our analysis focused on the client component because only this component listens for incoming remote control connections. To prevent unauthorized access, clients can be configured to use various authentication providers, ranging from manually defined username / password combinations to Active Directory integration. For our setup, we decided to use the manual username / password list approach for simplicity.

A quick search for known vulnerabilities resulted in two hits: CVE-2007-5252, dating back to 2007, describes a buffer overflow in version 10.x of the client component. Another memory corruption vulnerability affecting the no longer maintained Linux client was reported in CVE-2011-0404. Based on these results, we prepared to get our hands dirty with some x86 assembly, decompiled C code, and proprietary binary protocols.

Communication Protocol

By default, NetSupport Manager installations use TCP port 5405 for all control-to-client communication. Attaching WinDbg to the client process client32.exe and setting breakpoints on common TCP socket communication functions, such as Ws2_32.dll!recv, we quickly spotted the low-level communication protocol handling in the DLL tcctl32.dll. The recovered message format is as follows:

Each command gets prepended with a two-byte header defining the actual command size, which can be up to 0x800 bytes. Keep this value in mind, as we will refer to it multiple times later! Next, the command type is encoded as a single ushort value followed by an optional ushort subtype. The trailing payload data and format highly depend on the individual command types. A summary of commands callable without authentication is given in the table below:

Command Type Subtype Connected Payload Data
CONNECT 0x5A - - Control host information
PING 0x7F - X 2-bytes to echo
HANGUP 0x5B - - -
BC_ADD_PORT 0x8B 0x01 - Broadcast port parameters
BC_END_PORT 0x8B 0x03 - -
BC_TCP_DATA 0x8B 0x06 - Broadcast data + metadata

There are many more commands implemented both in tcctl32.dll and pcicl32.dll, but we will stick to this reduced set for this blog post. Besides some generic commands, we found that commands with a type field of 0x8B were related to some undocumented broadcast feature. This broadcast feature caught our attention for several reasons. First of all, broadcast-related subcommands did not require authentication. Comparing various versions of NetSupport Manager, we noticed that the feature had been introduced in version 14 and, therefore, was not present in the versions for which CVEs had been reported in the past. Additionally, the feature seemed to be independent of the regular command processing, as can be seen in the following excerpt of the reconstructed command dispatching logic:

Command Broadcast Feature

After some hours of reverse engineering and a substantial amount of coffee, we got a rough idea of the command broadcast mechanism and its internal workings. As a basic understanding of this feature is crucial for understanding the vulnerabilities and subsequent exploitation covered later in detail, let us examine how we assume the intended usage of the broadcast feature.

Before sending any broadcast data, a broadcast port must be opened by sending a BC_ADD_PORT command. As a result, the client allocates a new UdpInputStream object and associated data structures shown in the following schematic picture:

Commands that are sent via the TCP connection on port 5405 are temporarily stored in a buffer referred to as RX Buffer in the following, with a fixed size of 0x800 – remember that value? As part of its construction, the UdpInputStream object opens a new listening UDP port specified in the BC_ADD_PORT payload data. To cache received broadcast data, a Broadcast Data Buffer is allocated. This buffer is divided into individual slots. The amount and size of these slots are defined by parameters in the BC_ADD_PORT data command. The slot parameters are stored in the UdpInputStream object and additionally in a small data structure we named SendWindow, that also stores the only reference to the allocated broadcast data buffer. To keep track of slot usage and broadcast message acknowledgments, three additional buffers are allocated. The size of these buffers is derived from the configured amount of slots.

Based on the reconstructed code, we assume that command broadcasts are primarily sent to the newly opened UDP port. However, it is also possible to send broadcasts over the already established TCP connection by issuing a BC_TCP_DATA command. We will focus only on this mechanism in the following. Besides the actual broadcast data, the BC_TCP_DATA contains a small metadata header defining things such as the slot into which the data should be written and the broadcast data size, which is redundant from our perspective. In the picture below, a BC_TCP_DATA command is sent via TCP containing data for the first slot with index zero.

After command retrieval and basic metadata checks, the actual broadcast data is copied to the defined slot. In order to keep track of the slot data processing, the data is prepended with a small header containing, besides some other fields, the original data size and an offset specifying which data has already been processed.

There is also some housekeeping going on after the data transfer but we will ignore this for now. In a next step, available data is copied from the active slot to a separate buffer we gave the name Aggregate Buffer. The aggregate buffer has a limited capacity of guess what - 0x800 bytes.

We did not mention the active property of a slot so far. At the beginning, the first slot in the broadcast data buffer is marked as active. The active slot index is increased by one every time, all data from the currently active slot has been copied successfully to the aggregate buffer. Because the aggregate buffer has limited capacity it is possible that not all data from the active slot fit into it. In this case, the active slot index is not incremented but instead the data is partially copied and the offset parameter in the slot metadata is adjusted accordingly. For our example, we assume that all data from slot index zero was copied resulting in the active slot being set to one.

The broadcast feature is probably used to control multiple hosts at the same time. Therefore, data in the aggregate buffer is interpreted like commands sent via TCP in the next step. First, the two byte size header is checked. If the aggregate buffer contains at least the same number of bytes as defined in the size header, the data is forwarded to the general command dispatcher FUN_00019260__ctl_handle_packet shown in the command protocol section earlier.

After the command has been processed, the data is shifted out of the aggregate buffer. The steps of command interpretation and processing is repeated as long as there is enough data in the aggregate buffer.

Now that we have a basic understanding of how the feature works internally, it is important to know that it is possible to have multiple open broadcast ports at the same time by sending BC_ADD_PORT commands with different configurations. The created UdpInputStream objects are managed in a FIFO buffer. New objects are appended at the end of the buffer while the objects of closed ports are removed from the beginning. BC_TCP_DATA commands are always handled by the currently active UdpInputStream object which is the first entry in the FIFO buffer.

The Vulnerabilities

While reverse engineering the command broadcast feature, we found surprisingly few parameter validation checks and our gut told us that this might be a problem. Our feelings were not wrong and we found two memory corruption vulnerabilities that we will explain in more detail in the following.

Heap-Based Out-of-Bounds Write (CVE-2025-34164)

As previously mentioned, when we open a new broadcast port by sending a BC_ADD_PORT command, we can define the size of the used broadcast data buffer in terms of the number of broadcast slots and their respective size. The calculation of the resulting broadcast data buffer size can be seen in the following screenshot.

The slot size sent in the BC_ADD_PORT command gets increased by the slot metadata size (0x10 bytes) and rounded up to the next 0x10 aligned value. This adjusted size is later multiplied by the number of slots before passing the result to a call to calloc to allocate the broadcast buffer on the heap. Both, the slot size and count parameters in the BC_ADD_PORT command are sent as ushort values. Because we are dealing with an x86 binary, the calculated broadcast buffer size is represented as a 32-bit uint value. The interested reader might already know in what direction we are heading. Because neither the adjusted slot size nor the slot count are validated, we can trigger a classic integer overflow by sending large values for both parameters:

In the screenshot above, we have sent a BC_ADD_PORT command specifying a slot size of 0xFFFF bytes. The resulting adjusted size has a value of 0x10010. We also defined the number of broadcast slots to be 0xFFF1. Both values are highlighted at [1]. At [2], we can see that during the multiplication we triggered an integer overflow. The result should have been 0x1 0000 FF10 but the value can not be represented by 32-bits. Therefore, the allocated broadcast buffer with a size of 0xFF10 is way too small. How does this look in memory you might ask? Something similar to the following:

The allocated buffer is even smaller than a single broadcast slot. So by sending broadcast data to any slot index except zero we can write data outside the intended buffer - great! However, we have the following restrictions:

  • All writes are relative to the allocated broadcast buffer on the heap whose position in memory is unknown.
  • Effectively, we can only write to very limited memory regions. First of all, we are bound to the grid defined by the slot size of 0x10010. The first 0x10 bytes of each slot containing the slot metadata are not directly writable. Additionally, as the length of commands sent via TCP is strictly limited to a maximum size of 0x800 bytes, we can effectively only write 0x7F6 bytes into a single slot. This limitation is also visualized in the figure above even though the relative sizes are not correctly drawn for visualization purposes.

Stack-Based Out-of-Bounds Read (CVE-2025-34165)

In the description of the broadcast feature, we mentioned that the BC_TCP_DATA command contains a size field. In theory, the data size could be simply computed by subtracting the BC_TCP_DATA specific metadata size from the total command size. However, the NetSupport Manager developers decided to use this separate and externally controllable parameter instead. What could possibly go wrong ;) The size parameter is properly checked against the available space in a slot. Therefore, we can not write outside an individual slot. However, the developers missed to implement a check to verify if the source buffer, namely the RX buffer, contains enough data. As we mentioned earlier, this buffer has a fixed size of 0x800 bytes and is located on the stack. So by sending a BC_TCP_DATA command with a broadcast data size with a value larger than 0x7F6, data outside the RX buffer gets copied to the defined slot.

The Exploit

Both described vulnerabilities can be used to effectively crash the client process because of either write or read operations of unmapped memory. To protect our clients and give the team at NetSupport Ltd. sufficient time to fix the issues, we reported both vulnerabilities shortly after their discovery. That was the reason why both assigned CVEs were originally rated only as Denial-of-Service vulnerabilities. But everyone loves shells more than process crashes right? At least we do. Therefore, we sticked our heads together and finally found a way to build an exploit with the given limited primitives that ultimately allowed unauthenticated arbitrary code execution.

A quick look at the enabled protection mitigations enabled by default on the client process client32.exe showed that we primarily had to deal with ASLR. So before even thinking about EIP control we had to break ASLR by leaking some memory addresses of the target process.

What the Heap?

The exploit primitives that we had at hand both operated on data on the heap. We expected the client process to use the modern Segment Heap but WinDbg told us that we had to deal with the legacy NT Heap implementation instead.

There are lots of great resources related to internals of the Windows NT Heap implementation. One of these resources that is definitely worth reading is Corelan’s blog post Windows 10 x86/wow64 Userland heap. For the impatient reader we will summarize some properties of the NT Heap that will be relevant for the following exploit development process:

  • The Low Fragmentation Heap (LFH) is activated after 18 allocations of the same bucket size.
  • Allocations of the Back-End Allocator (BEA) can be forced to be aligned next to each other with ascending memory addresses.
  • Freed chunks of the BEA can be reliably reallocated with a subsequent allocation of a chunk of the same size.

This is far from a comprehensive description of the Windows NT Heap behavior! But for more details, we kindly refer to the great research of Corelan and respective blog posts.

ASLR Bypass

With basic knowledge of the heap implementation in use, lets get back to the first exploit step: Breaking ASLR by leaking memory addresses. One thing that quickly sparked our interest was the fact that due to the slot size of 0x10010 bytes of the Out-of-Bounds (OOB) write primitive we had the ability to (over-)write content of the first slot (index = 0) when writing data to the last slot (index = 0xFFF0).

This does not only allow tampering with the original broadcast data stored in the slot index zero but with its metadata, as well. Therefore, by overwriting the size and offset fields in the slot metadata we can point the slot data location to an arbitrary (relative) memory location. The data at this location is still interpreted as some command sent to the client.

In order to leak data we searched for commands that when processed send a response back to the control containing data of the original command. We were lucky and found exactly such functionality implemented in a command that we gave the name PING. ECHO would have also been an appropriate name for it:

Besides the command type (0x7F), the command consists of two data bytes. These two bytes are stored in a global data structure we named ControlServer. The following check against a field at offset +0x84 of the ControlServer can be passed by setting the two data bytes to any value different from zero (0x0000). If we pass the check, a command response is built using the same response type (0x7F) and appending the two bytes from the original request. The response is then sent back to the control using the function FUN_73526f20__ctl_send. Great, exactly what we were looking for!

The address space allocation granularity in Microsoft Windows is 64KB, or 0x10000 bytes. (Check this blog post for the reasons why.) Therefore, leaking the upper two address bytes of a known value of a module would be sufficient to break ASLR. So we finally came up with the following high-level plan:

We leak the upper two bytes of the vtable pointer of a known object as value at a well-known address within tcctl32.dll via a crafted PING command. By recalculating the module base from the partial vtable pointer we can break ASLR. Should be easy, right?

Well, in fact it was after we had figured out that plan. Lets walk through the actual steps involved. We start our exploit with an unknown heap layout. We assume that the NT Heap manages a single heap segment at that point. The segment may be fragmented through some (de-)allocations happing during setup or previous control interactions with other controls.

To enforce linear allocation of new heap chunks larger than the size of an UdpInputStream object (0x964 bytes) in the following steps, we first drain the NT Heap’s free list by creating some UdpInputStream objects. We configure a single slot with a size of one byte in the respective BC_ADD_PORT commands. This way the additionally allocated buffers are very small and fall in bucket sizes for which the LFH is very likely already active.

Next, we create our OOB write object by opening a broadcast port configured with a slot size of 0xFFFF and 0xFFF1 slots. Because of the previous free list draining, all components of the created UdpInputStream object are aligned next to each other.

We allocate another UdpInputStream object as filler. The object is used to create a defined gap so that the next Heap allocation lines up with the slot grid of our OOB write object. By setting the number of slots to one for the filler object, we can control the gap with byte-granularity via the slot size parameter.

Lastly, we create one more UdpInputStream object. We will leak the vtable pointer of this object in the next steps. Because the vtable pointer will be corrupted during this process, we refer to it as the sacrificed object in the following.

To activate our OOB write object, we need to free all of the drain objects by sending the same number of BC_END_PORT commands. The final memory layout looks like the following:

We fill the active broadcast slot (index = 0) of our OOB write object with a BC_TCP_DATA command. The command is parameterized with a data size larger than the aggregate buffer size (>0x800 bytes) and a valid PING command in the contained broadcast data. Specifying such a large data size is only possible because of the missing size checks being the root cause of CVE-2025-34165. The overlong size is required to prevent incrementing the active slot as not all data fits in the aggregate buffer. Until now we missed mentioning a small but important implementation detail of the broadcast feature: The data in the aggregate buffer is only interpreted if we have sent a CONNECT command. Because we have not done so yet, the PING command already transferred into the aggregate buffer is not processed.

Lets move on and place our crafted PING command for the actual info leak. For this, we send a BC_TCP_DATA targeting the slot with index 2. We used the filler object to align the sacrificed object so that we can place the partial PING command shown in the following schematic image right in front of the most significant bytes of its vtable pointer. This data write will corrupt heap metadata of the chunk allocated for the sacrificed object. If we try to free this object the client process will crash! We will deal with this later.

Before overwriting the metadata of slot zero, we send a CONNECT command. This ensures that with the next BC_TCP_DATA command data available both in the aggregate buffer and active slot will be processed.

With the data set up so far and slot zero still being active, we modify the metadata of this slot by writing data to slot 0xFFF0. We craft the new metadata of slot zero to point to the placed fake PING command including the two most significant bytes of the sacrificed object’s vtable.

The BC_TCP_DATA command used for the metadata overwrite will also trigger the broadcast data interpretation and, if we have aligned all pieces correctly, will leak the two vtable bytes. Using the leaked data, we can calculate the base address of tcctl32.dll by subtracting the known offset of the vtable in the DLL.

Perfect, part one of our exploit is completed!

EIP Control

Now that we know the base address of a module and with a write primitive at our hands, a common exploitation technique to hijack the control flow in C++-based binaries is a vtable overwrite. By redirecting the vtable pointer of a known and controllable object to a fake one, execution of a virtual function on that object would result in execution of a gadget that we have carefully placed inside the generated fake vtable.

As part of our ASLR bypass, we have already overwritten the vtable pointer of an UdpInputStream object. So why not simply do this again? A quick look at the vtable of the UdpInputStream class was promising:

The vtable consisted only of two entries with the first one being the object destructor. This destructor is called for the active UpdInputStream object when we send a BC_END_PORT command. Again, the main idea was clear and sounded simple on a first glance.

Did we hit a dead end?

While building our info leak to break ASLR we briefly mentioned some heap metadata corruption. To be precise, we corrupted the metadata of two chunks: The first metadata belongs to the chunk holding the sacrificed UdpInputStream object and the second one belongs to the broadcast data buffer of our OOB write object. That’s quite a problem because of the latter, we can never free our OOB write object as the destructor would free all allocated memory including the broadcast buffer. With the heap chunk metadata being corrupted, this would lead to a process crash and we would be kicked out.

We checked if we could simply overwrite the vtable pointer of our OOB write object. However, we were not lucky as the vtable pointer was located at an offset within slot 0xFFEF that we could not write to.

Seemed like a dead end for a moment. Looking at the memory layout above suggests that the position of the UdpInputStream object, the corresponding broadcast and slot usage bitmask buffers are fixed and can not be broken apart. That’s in fact only true for the sequence of allocation calls. The positions of the individual allocations are still defined solely by the heap manager. For the ASLR bypass we enforced a strictly linear allocation layout. What if we could force the heap manager to break the allocations belonging to our OOB UdpInputStream apart? According to research done by Corelan, freed chunks of the NT Heap BEA can be reallocated with a subsequent allocation of a chunk of the same size. So we came up with the following plan to work around our current situation:

We start again by filling potential holes in the heap segment with some draining objects. The order in which the heap chunks are allocated is indicated by the numbers above them in the following schematic pictures. So we are again forcing a linear allocation of new chunks. Now, we allocate an additional UdpInputStream object with the same parameters as the drain objects. This object (number 3) will be referred to as hole object in following. The main idea is that the chunk holding the UdpInputStream object of our OOB write primitive, which we will create later, will “snap” into exactly this chunk. Next, we allocate a filler object similar to the one used in the ASLR bypass (number 4).

By sending pairs of BC_END_PORT and BC_ADD_PORT commands, we free and reallocate all of our used drain objects. The sequence of freeing followed by a reallocation prevents merging of free heap chunks that might mess up predictive reallocation of the same chunks. Finally, we free the hole object by sending one more BC_END_PORT command. The created heap layout should look similar to the following:

When allocating our OOB write primitive now (number 8), the heap manager will use the recently freed chunk of the hole object to serve the allocation of the UdpInputStream object. Adjusting the size of the filler object created previously therefore allows us to fine-tune the relative position of the OOB’s UdpInputStream object so that its vtable pointer falls into the writable section of slot 0xFFEF.

With all of this additional setup done, we can finally create the filler (number 9) and sacrificed object (number 10) required for our information leak.

The last step we have to do is freeing the first allocated filler object (number 4), as well as the reallocated drain objects (number 5 to 7).

Improved Write Primitive

The refined memory layout allows us to first leak the base address of tcctl32.dll and use this information to overwrite the vtable of our OOB write object. Therefore, the next question was what value to overwrite the vtable with? Well, we can not directly set the vtable pointer to a ROP gadget or something similar because there is one more indirection when looking up a virtual function. So we need to write a fake vtable to some known location and overwrite the vtable pointer of the OOB primitive with the address of that location. The only memory address that we know is the load address of the tcctl32.dll module. Therefore, we tried to find some global variables that are usually located in the .data section that we can control.

After some trial and error, the only potential candidate that we came up with was a global variable we gave the name g_UDP_ACK_DELAY. The value of this global variable is set to an ushort sent in the BC_ADD_PORT command. But two bytes are not enough to create a fake vtable with a ROP gadget. Did we hit a dead end again?

Well, sometimes you have to take the long road. Remember the SendWindow object we mentioned when introducing the broadcast feature? Here is a short reminder so that you don’t have to scroll all the way back to the beginning of this blog post:

The SendWindow is the tiny piece of data that glues the UdpInputStream and its broadcast data buffer together. If we could modify the SendWindow of our OOB write primitive, we could redirect data writes via BC_TCP_DATA commands to defined absolute memory addresses. Two controllable bytes would be sufficient to define a pointer to a 0x10000-aligned absolute memory address, e.g., in the .data section of tcctl32.dll. This way, we could place up to 0x7F6 bytes data at a well-known memory address which is enough to not only hold a fake vtable but also a ROP chain and shellcode, as well.

The interested reader might say: “Great idea dude, but there are more fields in the SendWindow you can not control. What about them?” Thanks for asking. When looking at the following WinDbg memory dumps we can see that by interpreting the data at an offset of g_UDP_ACK_DELAY-0x06 we actually can create a valid SendWindow data structure with a controlled broadcast buffer pointer. The data before the g_UDP_ACK_DELAY belong to a static bitmask so these values will not change.

However, to overwrite the SendWindow pointer in our OOB write primitive object, we again face some difficulties. To reach the respective field, we also have to overwrite a pointer to an UdpDataControl structure in the field udp_data_control, as well as a pointer stored in a field we gave the name send_window_buffer_??. Sadly for us, both data structures were in use so if we are not able to fix them it is again a dead end.

Looking at the decompiled code, we saw that the only usage of the udp_data_control field was right after copying the data from the RX buffer to the broadcast buffer slot when processing a BC_TCP_DATA command. A reference to a CRITICAL_SECTION object stored in a field of the UdpDataControl structure is used in a call to LeaveCriticalSection. This critical section is probably used as a synchronization primitive to prevent concurrent access to the state of the UdpInputStream objects.

Creating a fake CRITICAL_SECTION object in an entered state was not an option. Instead, we searched for existing critical sections that we could re-use. We were lucky and found a pointer to a critical section in the correct “entered” state stored in a global variable of tcctl32.dll. By subtracting the offset of the critical section in the UdpDataControl structure from the known address of the global variable in tcctl32.dll we could “construct” a fake UdpDataControl object. Despite being in an even more fragile and unintended state, this did not crash the client process. So one pointer is fixed, and one more needs to be addressed.

Fixing the send_window_buffer_?? was performed as follows. As can be seen in the following screenshots, the send_window_buffer_?? field is interpreted as a pointer to an ushort array. During adjustment of the broadcast features’s internal state after retrieval of a BC_TCP_DATA command the last written slot index is written to this buffer. So we only had to point the send_window_buffer_?? field to a writeable memory region with enough space and we were done. We chose to set it to the end of the .data section of tcctl32.dll with an offset based on the slot index used in the BC_TCP_DATA command.

What an odyssey so far. Lets summarize the steps required for EIP control: First we shape the heap for both, the information leak required for ASLR bypass and an overwrite of the UdpInputStream vtable pointer afterwards. We break ASLR by leaking the base address of tcctl32.dll using the information leak primitive. With knowledge of the tcctl32.dll base, we calculate the following:

  • The address of a 0x10000-aligned, unused buffer in the .data section of tcctl32.dll referred to in the following as exploit buffer
  • The address of the fake UdpDataControl structure by first calculating the address of the global variable holding a reference to the “entered” CRITICAL_SECTION and adjusting the offset.
  • The address of the fake send_window_buffer_?? by subtracting a large enough offset from the end of the .data section of tcctl32.dll

Then, we set the global variable g_UDP_ACK_DELAY to form a fake SendWindow instance pointing to the calculated exploit buffer by sending a crafted TCP_ADD_PORT command. Next, we overwrite the following parts of the OOB write primitive’s UdpInputStream object with a BC_TCP_DATA command targeting slot index 0xFFEF:

  • The vtable pointer with an address inside the exploit buffer (not filled, yet!)
  • The udp_data_control pointer with the previously calculated address
  • The send_window_buffer_?? pointer with the previously calculated address
  • The SendWindow pointer with the address of the created fake SendWindow instance

By issuing a BC_TCP_DATA command, we write a fake vtable with the address to which the control flow will be redirected to at offset 0x00. Finally, we kick off the control flow hijack by sending a BC_END_PORT command.

And finally, we were able to control EIP :)

ROPing for a Shell

We made major progress with finally controlling the EIP register. But where to redirect execution next? The exploit buffer to which we can write is located in the .data section and, therefore, is not executable. So we need to ROP our way to a shell. The first step when ROPing is to pivot the stack to a controlled buffer to keep the chain alive.

Checking the register contents, we found the following:

  • ECX and EDI hold a pointer to the OOB write primitive’s UdpInputStream object.
  • EDX pointed to our created fake UdpInputStream vtable.

We also looked for interesting content on the stack and spotted a pointer to the RX buffer at an offset of 0x08:

Even though we did not find a single ROP gadget that would have allowed us to pivot the stack in one shot, we came up with the following chain of JOP gadgets that did the trick.

rop = b""
# Stack pivot (fake UdpInputStream vtable)
rop += struct.pack("<I", tcctl32_base + 0x45a42) # [1] mov eax,  [ecx] ; pop ebp ; jmp  [eax+0x08]
rop += struct.pack("<I", tcctl32_base + 0x23f48) # [3] pop esp ; pop ebp ; retn 0x0004
rop += struct.pack("<I", tcctl32_base + 0x45c62) # [2] pop esi ; jmp  [eax+0x04]

The first gadget ([1]) loads the pointer to our fake vtable into EAX by dereferencing the first entry of the UdpInputStream object pointer stored in ECX. It also increments the stack pointer by 0x04 with a POP instruction. We keep the chain alive by jumping to the third entry of the created fake vtable loaded into EAX. Landing at [2], we increment ESP again by 0x04. So ESP is now pointing to the pointer to the RX buffer. With a jump to the third gadget stored in the second entry of our fake vtable ([3]), we can pivot the stack to the RX buffer by POPing the pointer stored on the stack into ESP. We increment ESP again by 0x04 with another POP to skip the BC_END_PORT command that we sent to kick-off the control flow hijack.

From now on, it was smooth sailing. We could have put our remaining ROP chain in the RX buffer but decided to pivot the stack one more time back to our exploit buffer with a simple POP ESP gadget sent with the last BC_END_PORT command:

cmd = b""
cmd += struct.pack("<I", tcctl32_base + 0x13a60)    # pop esp ; ret
cmd += struct.pack("<I", 0x41414141)                # Filler
cmd += struct.pack("<I", buffer_addr + 0x100)       # Address of ROP chain (placed before!)
nsm.send_raw(0x8b, 0x03, cmd)

Besides our ROP chain, we also placed a TCP reverse shell payload in the exploit buffer. To make the payload executable, we used a ROP chain that dynamically resolved the address of kernel32!VirtualProtect. Then, VirtualProtect is called on the shellcode buffer to make it executable and we finally redirect execution to it.

# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Actual ROP chain (buffer_addr + 0x100)
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Resolve kernel32.dll by calling GetModuleHandleW
rop += struct.pack("<I", tcctl32_base + 0x5869)     # pop ecx ; ret
rop += struct.pack("<I", tcctl32_base + 0x57094)    # IAT GetModuleHandleW
rop += struct.pack("<I", tcctl32_base + 0xdcf0)     # mov eax,  [ecx] ; ret
rop += struct.pack("<I", tcctl32_base + 0x691d)     # jmp eax
rop += struct.pack("<I", tcctl32_base + 0x1070)     # ret
rop += struct.pack("<I", buffer_addr + 0x20)        # Pointer to u"kernel32"
rop += struct.pack("<I", tcctl32_base + 0x5869)     # pop ecx ; ret
rop += struct.pack("<I", buffer_addr + 0x3d0)       # storage of kernel32 ptr (for later use)
rop += struct.pack("<I", tcctl32_base + 0x4c693)    # mov  [ecx], eax ; pop ebp ; ret
rop += struct.pack("<I", 0xFFFFFFFF)                # Filler

# Resolve VirtualProtect by calling GetProcAddress
rop += struct.pack("<I", tcctl32_base + 0x5869)     # pop ecx ; ret
rop += struct.pack("<I", tcctl32_base + 0x00057088) # IAT GetProcAddress
rop += struct.pack("<I", tcctl32_base + 0xdcf0)     # mov eax,  [ecx] ; ret
for _ in range(15):
    rop += struct.pack("<I", tcctl32_base + 0x316fb) # add esp, 0x20 ; pop esi ; pop ebp ; ret
    rop += b"\x51" * 0x28
rop += struct.pack("<I", tcctl32_base + 0x691d)     # jmp eax
rop += struct.pack("<I", tcctl32_base + 0x1070)     # ret

rop += struct.pack("<I", 0x42424242)                # <Placeholder> (replaced by kernel32.dll ptr in the step before!)
rop += struct.pack("<I", buffer_addr + 0x34)        # Pointer to "VirtualProtect"

# Call VirtualProtect on the shellcode buffer
rop += struct.pack("<I", tcctl32_base + 0x691d)     # jmp eax
rop += struct.pack("<I", buffer_addr + 0x3F4)       # <START OF SHELLCODE>
rop += struct.pack("<I", buffer_addr)               # lpAddress (Addr. of shellcode buffer)
rop += struct.pack("<I", 0x1000)                    # dwSize
rop += struct.pack("<I", 0x40)                      # flNewProtect (0x40 = PAGE_EXECUTE_READWRITE)
rop += struct.pack("<I", buffer_addr)               # lpflOldProtect

# End of ROP marker
rop += struct.pack("<I", 0x53535353)

# Add shellcode at the end
rop += shellcode

Even though the initial heap layout was not predictable, we found the exploit to be very reliable.

Timeline and Fix

CODE WHITE initially reported the vulnerabilities to NetSupport Ltd. on June 11th, 2025. Both vulnerabilities were mitigated in NetSupport Manager version 14.12.0000 released on July 29th, 2025. The fixes included additional parameter checks and enforced authentication for all commands related to the broadcasting feature. The description and CVSS score of CVE-2025-34164 was updated on November 3rd, 2025 after building a working exploit.