Martin's Blog

CVE-2026-88772: Following a NetScaler DTLS Overflow Across Two Builds

CVE-2026-88772 is another NetScaler vulnerability reachable before a user logs in. This time the problem is in DTLS processing: data from a linked buffer chain is copied into a fixed-size destination without checking whether the whole chain fits. Citrix says it can lead to remote code execution or denial of service, and that exploitation has been observed.

The watchTowr analysis walks through the parser, the overflow, and a working exploit chain. I compared the two NetScaler 14.1 builds it examines, 73.30 and 73.37, to check the copy and the fix against the binaries themselves.

The change is straightforward. Build 73.30 keeps moving the destination pointer as it copies, with no cumulative size check. Build 73.37 counts down from 0x8c00 bytes and rejects an element that will not fit. The individual copies look ordinary. The problem is the total.

I checked that change statically. I did not enable DTLS, send malicious packets, or run the public exploit. The crash and code-execution results discussed below are watchTowr’s observations.

Which deployments are affected?

Citrix bulletin CTX697096 describes CVE-2026-88772 as a memory overflow that permits unauthenticated remote code execution and denial of service. For NetScaler 14.1, the bulletin lists builds before 14.1-73.37 as affected. It also states that DTLS must be enabled and that DTLS is enabled by default on a VPN virtual server unless it has been configured off.

There is an easy detail to miss here: VPN is not the only configuration to check. The bulletin also names virtual servers configured with type DTLS, including load-balancing virtual servers. Looking only for VPN virtual servers can leave an affected service out of the inventory.

I inspected 14.1-73.30 and 14.1-73.37. That establishes what changed between those two builds; it does not tell us which release first introduced the bug. For the wider affected-version range, use Citrix’s bulletin.

These are the exact archives and packet-engine binaries I compared:

build-14.1-73.30_nc_64.tgz
  SHA-256 acd260b4382a33e8c05ba604b5b9a80bb2c38c0bf161d8ba653aa4b8118c22d6

14.1-73.30 /netscaler/nsppe
  SHA-256 6ce73d12c24d2c23e77fbc8234091dd069b610b658455a7dc18b087ee8890f85

build-14.1-73.37_nc_64.tgz
  SHA-256 e0151fd7c44c6d1fa99872a1e01912e92a0df1b611cf9c3c91fc854701293a06

14.1-73.37 /netscaler/nsppe
  SHA-256 1a667ad8cf148571a2ffe1e8b368f5017e8375ac979ed76a835075c39d441c01

The hashes are here because addresses in a proprietary binary need a build to go with them. A function at 0x1434a80 is only a useful reference if we agree on which file we are reading.

Finding the packet engine

The packet engine, nsppe, is a few layers down in the update archive. The archive contains a compressed system image; the image is an ELF file; its mfs section contains a UFS2 filesystem. That is where /netscaler/nsppe lives.

NetScaler update archive
        |
        v
compressed ns-14.1-73.xx system image
        |
        v
ELF mfs section
        |
        v
UFS2 /netscaler/nsppe

I hashed the archives before extraction, left them unchanged, and parsed the filesystems from disposable copies. The recovered binaries have the hashes listed above.

For decompilation I used IDA Pro 9.4. The function starts at 0x1434a80 in 73.30 and 0x1436420 in 73.37. I then checked the relevant instructions with objdump.

IDA makes this code much easier to read, but its generated names and inferred types are still approximations. When the claim turns on whether a comparison happens before a copy, the assembly is where to check it.

Why this is DTLS code

Neither function has a useful source-level name. IDA also reports no direct callers, which would be unhelpful if that were the end of the story. The references are in method tables instead.

In 73.30, the function pointer appears at file offsets 0x2718f30, 0x2719058, 0x2719210, and 0x27193c8. The corresponding 73.37 entries are at 0x271af30, 0x271b058, 0x271b210, and 0x271b3c8. Nearby fields contain 0xfeff and 0xfefd, the DTLS version values.

That explains the missing direct callers: the tables provide indirect dispatch. It also connects the functions in these binaries to DTLS without relying entirely on the names used in the public write-up.

It does not establish the full path from an unauthenticated packet to the copy. Citrix describes the unauthenticated exposure, and watchTowr supplies the packet-processing and runtime analysis. The method tables corroborate which component we are looking at.

Build 73.30: copy, advance, repeat

The function copies a short header into the global scratch area, moves the destination pointer past it, and starts walking the linked buffer chain. Here is IDA’s output for the central loop in 14.1-73.30, sub_1434A80 at 0x1434a80:

sub_1B05B70(/*a1=*/ &qword_355BBC0, /*a2=*/ a1 + 1664, /*a3=*/ *(unsigned __int8 *)(a1 + 1677));
v8 = (char *)&qword_355BBC0 + *(unsigned __int8 *)(a1 + 1677);
do
{
  *(_DWORD *)(v6 + 248) = 0;
  sub_1B05B70(/*a1=*/ v8, /*a2=*/ *(_QWORD *)(v6 + 232), /*a3=*/ *(unsigned __int16 *)(v6 + 242));
  v8 += *(unsigned __int16 *)(v6 + 242);
  v6 = *(_QWORD *)(v6 + 96);
}
while ( v6 != 0 );

The helper selects a copy implementation through CPU dispatch and has memcpy semantics. Each iteration loads a source pointer and a 16-bit length, copies the bytes, advances v8, and follows the next pointer. Nothing in that loop asks how far v8 has travelled.

The assembly shows the same thing. These are virtual instruction addresses, with instruction bytes omitted and explanatory comments added. The syntax is AT&T: source operand first, destination second.

1434aea  mov    $0x355bbc0,%edi       # global scratch destination
1434aef  call   0x1b05b70            # copy initial header
1434af4  movzbl 0x68d(%r12),%eax     # header length
1434afd  lea    0x355bbc0(%rax),%rbx # destination after header

1434b10  movl   $0x0,0xf8(%r13)
1434b1b  movzwl 0xf2(%r13),%edx     # element length
1434b23  mov    0xe8(%r13),%rsi     # element data
1434b2a  mov    %rbx,%rdi           # destination cursor
1434b2d  call   0x1b05b70           # copy
1434b32  movzwl 0xf2(%r13),%eax
1434b3a  add    %rax,%rbx           # advance destination
1434b3d  mov    0x60(%r13),%r13     # next element
1434b41  test   %r13,%r13           # end of chain?
1434b44  jne    0x1434b10           # repeat

For following the values between IDA and the assembly, the offsets line up as follows:

Field Decompiled offset Assembly offset Meaning
Header length a1 + 1677 0x68d(%r12) Unsigned 8-bit header size
Element data v6 + 232 0xe8(%r13) Source pointer for the copy
Element length v6 + 242 0xf2(%r13) Unsigned 16-bit copy length
Next element v6 + 96 0x60(%r13) Linked-chain traversal

The loop tests whether another element exists. It never compares the growing destination position with the buffer capacity. Every length can fit in its 16-bit field while the total is far too large for the destination.

The check we need is simple:

header length + sum of all linked element lengths <= destination capacity

Build 73.30 does not enforce it here.

Build 73.37: count what is left

The fixed build keeps the same basic copy operation. It adds the part that was missing: start with the destination capacity, subtract the header, and check each element against what is left. Here is the first fixed loop in 14.1-73.37, sub_1436420 at 0x1436420:

sub_1B075E0(/*a1=*/ &qword_355DBC0, /*a2=*/ a1 + 1664, /*a3=*/ *(unsigned __int8 *)(a1 + 1677));
v7 = *(unsigned __int8 *)(a1 + 1677);
v8 = 35840 - v7;
v9 = (char *)&qword_355DBC0 + v7;
while ( 1 )
{
  a3 = *(unsigned __int16 *)(v5 + 242);
  if ( v8 < (unsigned int)a3 )
    goto LABEL_1080;
  *(_DWORD *)(v5 + 248) = 0;
  sub_1B075E0(/*a1=*/ v9, /*a2=*/ *(_QWORD *)(v5 + 232), a3);
  v10 = *(unsigned __int16 *)(v5 + 242);
  v9 += v10;
  v8 -= v10;
  v5 = *(_QWORD *)(v5 + 96);
  if ( v5 == 0 )
    goto LABEL_7;
}

In the assembly, r15d holds the remaining capacity. Follow it from the initial 0x8c00 through the comparison and subtraction:

143648a  mov    $0x355dbc0,%edi       # global scratch destination
143648f  call   0x1b075e0            # copy initial header
1436494  movzbl 0x68d(%r12),%eax     # header length
143649d  mov    $0x8c00,%r15d        # destination capacity
14364a3  sub    %eax,%r15d           # account for header
14364a6  lea    0x355dbc0(%rax),%rbx # destination after header

14364b0  movzwl 0xf2(%r14),%edx     # next element length
14364b8  cmp    %edx,%r15d          # remaining capacity minus length
14364bb  jb     0x143b953           # unsigned remaining < length
14364c1  movl   $0x0,0xf8(%r14)
14364cc  mov    0xe8(%r14),%rsi     # element data
14364d3  mov    %rbx,%rdi           # destination cursor
14364d6  call   0x1b075e0           # copy only after the check
14364db  movzwl 0xf2(%r14),%eax     # copied length
14364e3  add    %rax,%rbx           # advance destination
14364e6  sub    %eax,%r15d          # reduce remaining capacity
14364e9  mov    0x60(%r14),%r14     # next element
14364ed  test   %r14,%r14           # end of chain?
14364f0  jne    0x14364b0           # repeat with the next check

The decisive pair is cmp followed by jb, before the copy call. cmp sets flags without changing the operands; jb takes the unsigned below branch when the remaining space is smaller than the element length. If they are equal, the copy fits exactly and is allowed.

The rejection block at 0x143b953 sets error value 0x32 and increments error counters before following the failure path. Its first instructions are:

143b953  movl   $0x32,-0x174(%rbp)   # local error value
143b95d  incq   0x22d5754(%rip)      # error counter
143b964  mov    $0xffffffff,%r8d
143b96a  incq   0x22d739f(%rip)      # error counter

So the oversized element never reaches the copy call. Earlier elements may already have been copied, but they fitted; this check stops the next one from writing past the destination.

The two builds side by side:

Stage 14.1-73.30 14.1-73.37
Copy initial header Yes Yes
Account for header size Destination cursor only Subtract from 0x8c00 remaining capacity
Load each element length Yes Yes
Check cumulative capacity before copy No Yes
Reject an element larger than the remaining capacity No Yes
Subtract each successful copy No Yes

What watchTowr demonstrated

The function addresses, scratch size, missing bound, and added check all match watchTowr’s analysis. Its reported test case contains 173,652 bytes of linked data. Add the 13-byte header and the copy reaches 173,665 bytes: 137,825 bytes beyond a 35,840-byte destination. The arithmetic agrees.

The useful part of that research is that it follows the problem beyond the copy. It explains how the parser produces the excessive chain, shows the resulting corruption in a debugger, and follows it through register and instruction-pointer control to code execution.

I have checked the unchecked copy and the added bound in the two binaries. I have not reproduced that debugger session or the exploitation chain. The post does not publish hashes for the binaries behind its screenshots; the hashes above identify the files used in my comparison.

The public program is labelled a detection artifact generator. Read the code before treating it as one. It contains 73.30-specific addresses, a return-oriented programming chain, a memory-permission change, executable payload bytes, and code that writes to an arbitrary file. Running it can compromise or crash an appliance.

I inspected it and did not run it. Version and configuration review are enough to establish whether the vendor’s exposure conditions apply. This program belongs in an explicitly authorised disposable lab, not a routine production check.

Checking exposure

My lab did not expose this path. For the 29 September 2026 assessment, its documented configuration had a management address on a VirtualBox host-only network, with no VIP, SNIP, backend, Gateway/VPN virtual server, or other application virtual server. SSL VPN was disabled. The primary appliance had already been upgraded to 14.1-73.37 on 27 September; 73.30 was the vulnerable binary used for comparison.

That gives us a static comparison, but no configured DTLS service on which to reproduce the flaw. It says nothing about another appliance’s exposure. For that, check the deployment:

  1. Is the appliance running a version Citrix lists as affected?
  2. Does any reachable VPN virtual server have DTLS enabled, including the default when DTLS has not been explicitly disabled?
  3. Is any other reachable virtual server configured with type DTLS, including a load-balancing virtual server?
  4. Has it been upgraded to the fixed build recommended by Citrix?

Check both running and saved configuration. Turning DTLS off on VPN virtual servers does not address a separate virtual server configured with type DTLS; Citrix’s bulletin covers both.

This tells you whether the exposure conditions apply. It does not tell you whether the appliance has already been compromised.

What the patch tells us

Large binaries change in plenty of ways between releases. Finding a new comparison somewhere near suspected code is not enough. Here, the header, destination, source pointer, element length, and linked-chain traversal all remain recognisable. The new counter and branch sit around the same copy that previously had no cumulative bound.

That is why this patch is useful evidence. It addresses the precise failure described in the disclosure, and gives us a concrete property to test: reject a header plus chain larger than 0x8c00 before any copy writes beyond the buffer.

The obvious regression cases are totals just below the limit, exactly at it, one byte above it, and many small elements that exceed the limit together. Those tests would need to exercise the real DTLS path on disposable vulnerable and fixed appliances. I did not run them. Reading the fixed branch is useful; observing the same input rejected at runtime would answer a further question.

The comparison confirms the missing bound in 73.30 and its repair in 73.37. It does not establish that 73.37 has no other DTLS defects, or turn watchTowr’s RCE demonstration into one of my own.

For operators, the decision is already clear. Follow CTX697096 and upgrade affected appliances. The binary comparison explains why the fix matters; there is no need to run the public exploit to justify installing it.

#NetScaler #CVE-2026-88772 #DTLS #Memory Corruption #Reverse Engineering #Incident Response