On 21 October 2026 PHP released fixes for two critical bugs, CVE-2026-109618 and CVE-2026-109619, both pre-auth remote code execution and both CVSS 4.0 9.2. Public reporting calls the pair “PHP2Shell”. The worst case target in the advisories is Apache with mod_php.

Illustration of a NetScaler appliance with a terminal above it showing php -v reporting PHP 8.1.34
Illustration (AI generated)

NetScaler ships PHP as well. So the first questions from customers are easy to guess: which PHP version does NetScaler run, are we affected, where on the appliance does PHP actually run, and can it be reached from the internet?

This post is my answer for one firmware build, 14.1-73.46. The short version: yes, it is vulnerable, and I can show that in the disassembly, not just from a version number. But the vulnerable PHP only runs behind the management interface. The internet facing Gateway doesn’t reach it.

Disclaimer: until there is an official statement from Citrix, this is what I could determine from the public documentation and my own static analysis of the firmware. It is not confirmed by the vendor. Treat it as informed analysis, act on it carefully, and check it against the Citrix advisory once there is one.

Update (21 October 2026): I understand that Citrix Support has said the following: Citrix is actively working on upgrading PHP to a fixed version. As a mitigation, Citrix recommends restricting access to the GUI on the NSIP, on a SNIP or VIP with management access enabled, and on the cluster IP in a cluster, to trusted and limited networks. That matches the advice below. This is not a public statement, and there is no committed date for the fixed build.

The short version

  • NetScaler 14.1-73.46 ships PHP 8.1.34. That is the last 8.1 release ever, and PHP 8.1 has been end of life since 31 December 2025. There will be no 8.1.35 with a fix.
  • The build is not patched. The two functions that the fix adds are missing, and the out of memory handler still has the vulnerable code.
  • Exposed: the management GUI (Apache mod_php on port 80, so the NSIP, any SNIP or VIP with management access, and the cluster IP in a cluster).
  • Not exposed: the Gateway and AAA data plane (PHP is switched off on that path) and the HTML5 SSH daemon (loopback only, and a single long running request).

Two bugs, one root cause

Both bugs come from the same area: what PHP does when it runs out of memory, and what state a worker process keeps between requests. The fix is one commit in every branch, titled “Avoid state corruption via bailout”. “PHP2Shell” is a nickname from public reporting, not an official PHP name.

CVE-2026-109618: RCE via out of memory corruption

(CVE, NVD, GHSA-w8hw-w9fx-j79w)

When PHP can’t allocate memory it doesn’t return an error up the call stack. It does a longjmp(), a “bailout”, straight out of the current request. In some code paths request memory is reachable before it is fully initialised. If an allocation fails at exactly that moment, the bailout leaves half built state behind and the request shutdown code then reads it. The allocation that fails doesn’t even have to be related to the corrupted object.

According to the advisory an attacker can force that out of memory condition with a crafted multipart POST, before any application code runs. The advisory confirms RCE against Apache mod_php with default limits and prefork workers. Remember that combination, it comes back below.

CVE-2026-109619: RCE via stale global state

(CVE, NVD, GHSA-6wmf-4rv5-p3rp)

A worker process keeps global pointers (error, session, object and extension state) into request memory. If a fatal error or an out of memory condition abandons an operation before its reset runs, those pointers survive into the next request on the same worker, and get used before that request initialises them. With a prepared heap that is a use after free.

The confirmed path is session upload progress, which is on by default and doesn’t need session_start(). The request that leaves the state behind and the request that uses it must land on the same worker. More workers and worker recycling lower the odds, they don’t close the hole.

What the fix changes

The patch stops using longjmp() for out of memory. It now ends the process at the next safe point (zend_mm_safe_error_ex(), which calls _exit(1)), so under prefork a single child dies instead of corrupting state. It also disables session upload progress, keeps only the multipart headers it needs, starts nested input arrays smaller (zend_new_array_singleton) and resets the stale globals at shutdown.

Those two new exported functions, zend_mm_safe_error_ex and zend_new_array_singleton, are a nice fingerprint: a patched build has both.

Fixed versions are 8.2.35, 8.3.36, 8.4.27 and 8.5.12. For 8.1 there is only a minimal backport branch in the PHP repository, not a release.

NetScaler ships an end of life PHP

Both PHP binaries in the firmware report 8.1.34, and the source paths embedded in the binary say php-8.1.34 as well, so there is no doubt about the version.

That is not just an old version. 8.1.34 is the final 8.1 release, from 18 December 2025, and the whole 8.1 branch went end of life on 31 December 2025. Build 14.1-73.46 was made on 7 October 2026, so it shipped a PHP that had been end of life for more than nine months, two weeks before these CVEs were published.

So for NetScaler there is no patched PHP release to move to. A fix depends on Citrix backporting the change into their own PHP build, or moving the firmware to a PHP version that is still supported.

Why I took the firmware apart

Customers run NetScaler on the two supported lines, 13.1 and 14.1, with many builds each, and in practice also on end of life lines like 12.1 and 13.0. According to the Citrix lifecycle, 14.1 is supported until 2030, 13.1 reached end of maintenance on 15 September 2026 and is supported until 15 September 2027, and 15.1 only exists as a Linux based Tech Preview.

I can’t stand up a VM for every build, so I wanted a static approach: get the PHP binaries and configuration straight out of the firmware image, work out the method on one build and then repeat it for the others.

A NetScaler firmware is a FreeBSD system. The outer build-*.tgz is an installer bundle, and the root filesystem is a UFS2 image embedded in the kernel file (ns-*.gz). So I wrote two small tools:

  • nsfw_extract.py, a read only UFS2 reader that carves the filesystem image out of the kernel and extracts files as they are laid out on the appliance, with a manifest of every inode. It takes under a second and doesn’t need a FreeBSD machine or mounting anything.
  • patchcheck.py, which reads the ELF symbol table and only calls a build patched when both fix functions are exported.

Is it patched?

From the firmware I extracted /libexec/libphp.so (the Apache module), /bin/php (the command line binary), /etc/httpd.conf and /usr/local/lib/php.ini.

The symbol check is quick:

text Download
file         : libexec/libphp.so
control syms : ['_zend_new_array_0', 'php_request_shutdown', 'zend_hash_discard']
fix syms     : (none)
VERDICT      : NOT PATCHED  (zend_mm_safe_error_ex and zend_new_array_singleton absent)

The control symbols are there to prove the reader actually sees PHP’s symbol table. But a missing symbol is a strong hint, not proof. So I loaded the module in IDA, found the out of memory handler through its error string ("Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)" at 0x534606) and decompiled the function that uses it, zend_mm_safe_error at 0x3374C0:

c Download
// libphp.so  zend_mm_safe_error @ 0x3374C0 (decompiled, annotated)
void __noreturn sub_3374C0(__int64 heap, const char *fmt, int limit, __int64 size)
{
    *(_DWORD *)(heap + 296) = 1;                  // heap->overflow = 1
    saved = unk_8472F0; unk_8472F0 = buf;         // zend_try: save the bailout target
    if ( !sigsetjmp(buf, 0) )
        zend_error_noreturn(E_ERROR, fmt, limit, size);   // "Allowed memory size ... exhausted"
    unk_8472F0 = saved;                           // zend_end_try
    *(_DWORD *)(heap + 296) = 0;                  // heap->overflow = 0
    _zend_bailout(".../php-8.1.34/Zend/zend_alloc.c", 393);   // longjmp: the vulnerable path
}

That _zend_bailout at the end is the longjmp() behind CVE-2026-109618, and the binary even tells you where it came from: zend_alloc.c line 393. A patched build calls zend_mm_safe_error_ex() and _exit() at this point. So the vulnerable code is physically in this binary.

The configuration doesn’t help

The php.ini makes the out of memory condition easy to reach:

ini Download
memory_limit        = 1084M        ; finite and predictable, what the published exploit relied on
post_max_size       = 1500M
upload_max_filesize = 1485760000   ; bytes
max_file_uploads    = 20
max_input_vars      = 1000
max_execution_time  = 30
; session.upload_progress.enabled not set, so the default (On) applies

Session upload progress at its default is the confirmed path for CVE-2026-109619. And Apache runs prefork with StartServers 5, MaxClients 30 and MaxRequestsPerChild 10000: a child handles up to 10000 requests, and reusing workers is exactly what the stale state bug needs.

Jan Tytgat reviewed this php.ini directive by directive and came to the same conclusion: this configuration favours the attacker.

Where it is exposed, and where it isn’t

This is what decides the advice to customers. Is the vulnerable PHP only reachable from the management interface, or also from the Gateway that sits on the internet?

PathReached viaPHPExposed
Management GUINSIP or management SNIP, port 80mod_php, engine onyes
Gateway and AAA logonpacket engine to 127.0.0.1:81engine off, .php deniedno
HTML5 SSH daemonon-box only, 127.0.0.1:9999CLI, one long running processno

The NetScaler web server has two virtual hosts, and they are very different.

Exposed: the management GUI on port 80

The *:80 virtual host has PHP on, and its rewrite rules send a long list of URL prefixes into PHP, before any login:

text Download
# etc/httpd.conf
 783: <VirtualHost *:80>
 784:     DocumentRoot "/netscaler/ns_gui"
          (engine is on: php.ini:212 engine = On, not overridden in this vhost)
1084:     RewriteRule ^(/login/.*)$  /admin_ui/php/index.php${URIEscape:$1} [L]
1085:     RewriteRule ^(/menu/.*)$   /admin_ui/php/index.php${URIEscape:$1} [L]
1087:     RewriteRule ^(/nitro/.*)$  /admin_ui/nitro/nitro.php${URIEscape:$1} [L]
1089:     RewriteRule ^(/rapi/.*)$   /admin_ui/php/index.php${URIEscape:$1} [L]
1099:     RewriteRule ^/api(.*)$     /admin_ui/php/index.php/netscaler/api${URIEscape:$1} [L]

mod_php parses the request body while it handles the request, so a multipart POST to one of these paths reaches the vulnerable code before any NetScaler authentication runs. This is the worst case from the advisory, and it is reachable wherever the management web server is: the NSIP, and any SNIP or MIP with management access enabled.

Not exposed: the Gateway and AAA data plane

Gateway and AAA logon traffic is proxied by the packet engine to a second virtual host on loopback, 127.0.0.1:81. That one is locked down three times over:

text Download
# etc/httpd.conf
1141: <VirtualHost *:81>                         # bound to 127.0.0.1 via Listen
1142:     DocumentRoot "/netscaler/ns_gui"
1144:     php_flag engine off                     # (1) no PHP in this vhost at all
1147:     <Directory />
1154:         Require all denied
1155:         <FilesMatch \.php$>
1156:             Require all denied               # (2) any .php returns 403
1157:         </FilesMatch>
1158:     </Directory>
1160:     Alias /logon/ "/var/netscaler/logon/"   # Require local, static content only

The third layer is the content itself: the Gateway logon package (LogonPoint.tgz) contains no PHP at all, only CSS, JavaScript, fonts and themes. All 570 PHP files in the GUI package are under admin_ui, the management side.

I wanted to be sure that “engine off” really means PHP never touches the request body, so I also looked at the Apache handler in the binary, php_handler at 0x45FAA0 (sapi_apache2.c):

c Download
// libphp.so  php_handler @ 0x45FAA0 (decompiled, trimmed to the check)
v3 = r->handler;
if ( strcmp(v3, "application/x-httpd-php")
  && strcmp(v3, "application/x-httpd-php-source")
  && strcmp(v3, "php-script")
  && (!allow_text_html || strcmp(v3, "text/html") || (r->flags & 1) == 0) )
{
    sub_4608D0(a1, v19);
    return 0xFFFFFFFF;          // DECLINED: not a PHP request, hand it back to Apache
}
// only after this check does php_handler call
//   php_apache_request_ctor() @ 0x4609C0
//     -> php_request_startup() @ 0x2FB7D0

A request for static logon content is not a PHP request, so the handler declines it before php_request_startup, and the body parsing and the out of memory path are never reached. On the :81 path the vulnerable code simply isn’t entered.

Not a real RCE surface: the HTML5 SSH daemon

NetScaler also runs the PHP command line binary. websocketd starts php /netscaler/websocketd/websocket-ssh-deamon.php as one long running process on port 9999. It is a Ratchet / ReactPHP websocket server behind the browser based SSH console.

php Download
// netscaler/websocketd/websocket-ssh-deamon.php
$server = IoServer::factory(
    new HttpServer(new WsServer(new WsSSHConnector())),
    9999,
    ( ((int)exec("uname -a | grep -c 'Linux'") > 0) ? '192.0.0.2' : '127.0.0.1' )
);                                             // "Listens only on loopback interface"
$server->run();                                // one process, one event loop

It only listens on loopback (127.0.0.1 on the FreeBSD appliance, the internal address 192.0.0.2 on the Linux based BLX), and the GUI proxies to it over localhost. It is also a single long running request: the process initialises once and then runs an event loop. There is no next request to pick up stale state, so CVE-2026-109619 doesn’t apply. The worst realistic outcome of the out of memory bug here is a crashed SSH console.

What about nFactor?

Julian Jakob raised a fair question here, because nFactor is known to touch PHP. An older fixed issue, NSHELP-39390 (fixed in 14.1-47.48), reads: “When nFactor authentication is enabled, login to NetScaler might fail due to a PHP error on the login page.” nFactor is the default for Gateway and AAA logon these days, so does that put the vulnerable PHP on the data plane after all?

According to the firmware it doesn’t, but there is one detail that matters. There are two nFactor front ends, and only one of them is PHP:

  • The management GUI login is PHP, and it does nFactor. The controller admin_ui/php/application/controllers/Login.php handles the next factor and challenge steps (nextfactorpassword, loginchallengeresponse), with the views login_nextfactor_view.php and login_challenge_view.php. That runs on the port 80 management virtual host. So NSHELP-39390, a PHP error on the login page with nFactor enabled, is about the admin login of the management GUI.
  • The Gateway and AAA nFactor front end is not PHP. The login schemas are XML (LoginSchema.tgz contains no PHP files), the logon UI is the static LogonPoint package (no PHP either), and the authentication itself is evaluated by the packet engine and the aaad daemon, not by a PHP interpreter.

So nFactor on a Gateway or AAA virtual server doesn’t bring PHP to the data plane. nFactor for the management GUI does use PHP, but that is the interface that was already exposed. If you use it, the pre-auth login page itself is a PHP code path, which is one more reason to keep the management interface off the internet and well separated. It refines the picture, it doesn’t change it.

What to do now

Until there is a fixed build:

  1. Don’t expose the management interface to the internet. That means the GUI on the NSIP, on any SNIP or VIP with management access enabled, and on the cluster IP if you run a cluster. There was never a good reason for any of those to be reachable from the internet, with or without this bug. If yours is, fix that first. This is also what Citrix Support reportedly recommends.
  2. “Management only” limits the damage, it doesn’t make you safe. An internet attacker on a Gateway VIP doesn’t reach the vulnerable PHP. But anyone on the management network does: an attacker who already has a foothold somewhere and moves sideways, an insider, or a flat network where “management” and “users” aren’t really separated. Pre-auth RCE on the box that handles your remote access is a very attractive target. Put the management interface on a separate network and only allow named admin hosts.
  3. Rule out the exception. It takes an unusual configuration to put the vulnerable PHP on the data plane, for example a load balancing or content switching vserver that forwards to the management web server on port 80. If you have something like that, treat the appliance as exposed from the internet.
  4. Harden the PHP settings as a stopgap, see below. It reduces the exposure, it doesn’t remove it.
  5. Watch for the Citrix advisory and install a fixed build when it is there.

Hardening the PHP settings

Jan Tytgat suggested hardening the PHP settings, based on the mitigations in the advisories. He went through the NetScaler php.ini itself, which is a good starting point, but his recommendations follow the generic advisory rather than the appliance. They start with “update to PHP 8.2.35 or later”, which you can’t do on an appliance, and it includes a setting that doesn’t exist in 8.1. So I checked his suggestions against this build. These are the ones that apply:

ini Download
; CVE-2026-109619: remove the confirmed path
session.upload_progress.enabled = 0

; CVE-2026-109618: make the out of memory condition harder to reach
post_max_size           = 10M
upload_max_filesize     = 10M
max_input_nesting_level = 16

; only as a last resort: remove the predictable per request limit
; memory_limit = -1

His list also had fatal_error_backtraces = 0, but that setting only exists in PHP 8.5, so it does nothing here.

Some things to keep in mind on a NetScaler:

  • Firmware updates through the GUI. The current limits are around 1.4 GB, probably so that you can upload a firmware image through the GUI (the 14.1-73.46 bundle is 1.2 GB). Limiting them to a small size makes sense, but note that this blocks firmware updates through the GUI. Revert the limits when you are ready to install the fixed build.
  • memory_limit = -1 is a trade off. Without a limit there is no predictable out of memory point, but a single request can then use all the memory of the management plane.
  • Don’t use RLIMIT_AS or RLIMIT_DATA as an alternative limit. A failing allocation is exactly what triggers the bug.
  • Where to change it. httpd.conf points PHP to /usr/local/lib/php.ini on the root filesystem that comes from the firmware. At boot, /etc/rc replaces a fixed list of files in /etc with your copy from /nsconfig if there is one. httpd.conf is on that list, php.ini is not. So I wouldn’t count on a change to php.ini surviving a reboot. A setting in an httpd.conf under /nsconfig (php_admin_value / php_admin_flag in the port 80 virtual host) is the route the boot script offers, but then your copy also replaces the httpd.conf of later firmware, so remove it again after upgrading to a fixed build. I haven’t tested either on a live appliance, and I doubt Citrix supports it.
  • Apache has to restart before the new values are used, the running workers keep the old configuration. The web server on NetScaler is a normal Apache: the firmware starts it with /bin/apachectl start, monit watches it and uses the same script to start and stop it, and that script also supports restart and graceful. So from the shell, apachectl graceful should be enough. I haven’t tried that on a live appliance.
  • Use valid values. Per directory settings that produce a warning while PHP starts a request are one of the paths for CVE-2026-109619 in the advisory, so a typo here can make things worse instead of better.

Can’t you just build a patched PHP 8.1 yourself?

Yes, you could. PHP published a minimal backport branch for 8.1 with the fix, and NetScaler runs a FreeBSD base, so you can build a patched libphp.so for the same FreeBSD and Apache version.

But you shouldn’t, unless it is in your lab to prove the fix works. On a production appliance you would replace a system file of a security appliance with your own build: it has to match the Apache and PHP build of the appliance exactly, it is unsupported, and if it goes wrong you lose the management GUI you need to fix it. Not recommended. Wait for Citrix.

Conclusion

NetScaler 14.1-73.46 ships PHP 8.1.34 and it is vulnerable to PHP2Shell. Not because of the version number alone: the out of memory handler in the binary still does the longjmp that the fix removes.

The exposure is smaller than “pre-auth RCE in PHP” suggests, and that is by design, not by luck. PHP only runs on the management GUI. The Gateway and AAA traffic ends up in a virtual host where PHP is switched off in three ways, and the Gateway portal doesn’t contain any PHP. The SSH helper runs on loopback and isn’t affected by the cross request bug.

That is a real limitation, but not a reason to relax. The management network is exactly where an attacker wants to get, and the PHP in question will never get an upstream fix. So: keep the management interface off the internet, separate it from user traffic, and keep asking Citrix for a fixed build. I’ll update this post when I have run the same check on the other firmware lines.

References

A note on how this post was written: I used AI assistance. Claude helped with the analysis and made the first draft from my notes. It was not written automatically: I steered the investigation, reviewed the draft, corrected what was wrong and rewrote parts in my own words.