This is part 3 of my PHP2Shell on NetScaler series. In part 1 I showed that NetScaler 14.1-73.46 ships PHP 8.1.34 and is vulnerable to CVE-2026-109618 and CVE-2026-109619. In part 2 I looked at how hard it would be to exploit.
Today Citrix updated its security bulletin CTX697263 with fixed builds:
- NetScaler ADC and Gateway 14.1-73.49 and later
- NetScaler ADC and Gateway 13.1-64.32 and later releases of 13.1
- NetScaler ADC 14.1-FIPS 14.1-73.49 FIPS and later
- NetScaler ADC 13.1-FIPS and 13.1-NDcPP 13.1-37.286 and later
The release notes for 14.1-73.49 say that it replaces 14.1-73.46 and addresses the vulnerabilities in CTX697263. That’s all: release notes don’t list security fixes, so there is no word about PHP or what was changed. So 73.46 is exactly the right build to compare with, and the binaries are the only place to see what Citrix did.
PHP 8.1 is end of life. The PHP project did publish the fix for 8.1, as a backport branch in their repository, but there is no new 8.1 build or release: no PHP 8.1.35. Anyone who still ships 8.1 has to take that source change and build PHP themselves. In part 1 I wondered if Citrix would backport the fixes to 8.1 or move to a newer, supported PHP version. Now we can check.
Jan Tytgat and I took build 14.1-73.49 apart and compared it with 14.1-73.46. We wanted to answer four questions, with evidence and not just the vendor’s word for it:
- Did Citrix backport the fixes to PHP 8.1?
- Is it the upstream fix, or did they write their own?
- What exactly changed in the code?
- Are the issues really fixed?
Disclaimer: this is our own static analysis of the firmware, not confirmed by Citrix. We analysed one build, 14.1-73.49, and did not run an exploit against it.
The short version
- Yes, Citrix backported the fix to PHP 8.1. Both the Apache module (
libphp.so) and the PHP command line binary in 14.1-73.49 contain the fix. 14.1-73.46 doesn’t. - It is the upstream fix, not a rewrite. The code matches the 8.1 backport in the PHP repository down to function names, format strings and which fields get cleared.
- The out of memory handler no longer does the
longjmpthat caused CVE-2026-109618, and the dangling pointers behind CVE-2026-109619 are now cleared at the end of every request. - What didn’t change: PHP still reports version 8.1.34. The kernel is still FreeBSD 11.4 without ASLR.
What the fix should look like
The upstream fix is one change in every PHP branch, titled “Avoid state corruption via bailout”. It does two things:
- CVE-2026-109618: when PHP runs out of memory it used to
longjmpout of the request (a “bailout”) while request data was only partly set up. The fix stops doing that and ends the process cleanly instead. - CVE-2026-109619: a fatal error or out of memory left global pointers pointing at freed request memory, and the next request in the same worker could use them. The fix resets that state when a request ends.
For 8.1 this change only exists in the backport branch, not in a release. That branch also adds two new functions, zend_mm_safe_error_ex and zend_new_array_singleton. Neither exists in an unpatched PHP 8.1, so they make a simple fingerprint: if a binary has both, the fix is in.
Is the fix there?
We extracted the root filesystem of both firmware images and checked both PHP binaries for the two functions:
14.1-73.46 libphp.so : (none) NOT PATCHED
14.1-73.49 libphp.so : zend_new_array_singleton, zend_mm_safe_error_ex PATCHED
14.1-73.49 bin/php : zend_new_array_singleton, zend_mm_safe_error_ex PATCHEDThe strings in libphp.so tell the same story:
| String | 73.46 | 73.49 | Meaning |
|---|---|---|---|
Fatal error: %s in %s on line %d | 0 | 1 | the new out of memory handler is compiled in |
php-8.1.34/Zend/zend_alloc.c | 1 | 0 | the bailout call in the out of memory handler is gone |
Allowed memory size of %zu bytes exhausted | 1 | 1 | the message is still there, it just goes through the new handler |
The zend_alloc.c path is a nice detail. It was an argument of exactly the bailout call that the fix removes. In part 1 I showed that call in the disassembly of 73.46. In 73.49 the call is gone, and the string went with it.
What changed in the code
We loaded both libphp.so builds in IDA and compared the functions the fix touches. All of them got bigger, which is the first sign that code was added:
| Function | 73.46 | 73.49 |
|---|---|---|
sapi_deactivate_module | 0x104 bytes | 0x134 bytes |
zend_shutdown_executor_values | 0x533 bytes | 0x583 bytes |
php_request_shutdown | 0x298 bytes | 0x2e4 bytes |
The code below is IDA decompiler output, lightly cleaned up. I kept IDA’s names for globals and added comments with what they are in the PHP source.
The out of memory handler (CVE-2026-109618)
This is the function PHP calls when an allocation would go over memory_limit. In 73.46 it still has the bailout:
// 14.1-73.46, zend_mm_safe_error
*(a1 + 296) = 1; // heap->overflow = 1
sigsetjmp(buf, 0);
zend_error_noreturn(E_ERROR, format, ...);
*(a1 + 296) = 0; // heap->overflow = 0
_zend_bailout("/usr/home/build/adc/usr.src/contrib/php/php-8.1.34/Zend/zend_alloc.c", 393);That _zend_bailout is the longjmp that jumps out through half initialised request state. In 73.49 the whole function is reduced to one call:
// 14.1-73.49, zend_mm_safe_error
void __noreturn zend_mm_safe_error(char *format, char limit)
{
zend_mm_safe_error_ex(format, limit);
}And the new handler prints the error and ends the process:
// 14.1-73.49, zend_mm_safe_error_ex (new)
void __noreturn zend_mm_safe_error_ex(char *format, ...)
{
char s[1024];
if ( zend_is_executing() ) {
file = zend_get_executed_filename_ex();
line = zend_get_executed_lineno();
} else if ( zend_is_compiling() ) {
file = zend_get_compiled_filename();
line = zend_get_compiled_lineno();
} else { file = NULL; line = 0; }
va_start(arg, format);
vsnprintf(s, 0x400, format, arg);
fprintf(_stderrp, "\nFatal error: %s in %s on line %d\n", s, file ? file+24 : "Unknown", line);
_exit(1);
}This is the upstream function line for line, including the format string and the _exit(1) at the end. On NetScaler, Apache uses prefork, so _exit(1) ends one worker process and Apache starts a new one. No more jumping back into a corrupted request.
Dangling pointers cleared (CVE-2026-109619)
sapi_deactivate_module frees the authentication and content type buffers of a request. In 73.46 it frees them, but the globals keep pointing at the freed memory:
// 14.1-73.46, sapi_deactivate_module
if (unk_846A38) _efree(unk_846A38); // auth_user
if (unk_846A40) _efree(unk_846A40); // auth_password
if (unk_846A48) _efree(unk_846A48); // auth_digest
if (unk_846A30) _efree(unk_846A30); // content_type_dup
if (unk_846A58) _efree(unk_846A58); // current_userIn 73.49 every pointer is set to NULL after the free, exactly like the upstream change to main/SAPI.c:
// 14.1-73.49, sapi_deactivate_module
if (unk_845D58) { _efree(unk_845D58); unk_845D58 = 0; } // auth_user
if (unk_845D60) { _efree(unk_845D60); unk_845D60 = 0; } // auth_password
if (unk_845D68) { _efree(unk_845D68); unk_845D68 = 0; } // auth_digest
if (unk_845D50) { _efree(unk_845D50); unk_845D50 = 0; } // content_type_dup
if (unk_845D78) { _efree(unk_845D78); unk_845D78 = 0; } // current_userThe same pattern shows up in two more places:
php_request_shutdownnow resets the superglobals ($_GET,$_POSTand so on) to undefined after destroying them, and clearsmodules_activatedat the end. In 73.46 they were destroyed but kept pointing at the freed values.zend_shutdown_executor_valuesnow clears a block of engine globals, the user error and exception handler slots, before it frees the object store. The 73.46 version doesn’t touch them.
Smaller arrays for request input
zend_new_array_singleton is new in 73.49. It allocates an array with room for a single element, and the code that builds $_GET, $_POST and $_COOKIE uses it. Deeply nested field names in a request no longer allocate large tables up front, which makes it harder to drive PHP into an out of memory error in the first place.
Upstream, or their own fix?
Every change we looked at matches the 8.1 backport in the PHP repository, down to details you wouldn’t reproduce if you wrote the fix yourself:
- the two new functions have the upstream names
zend_mm_safe_error_exis the upstream function, including the exact format string and_exit(1)zend_mm_safe_erroris reduced to the same one line wrapper- the same five fields are cleared in
sapi_deactivate_module, and the same globals are reset inphp_request_shutdownandzend_shutdown_executor_values
So Citrix took the upstream backport and compiled it into their own PHP 8.1.34 tree. That answers my question from part 1: they backported, they didn’t move to a supported PHP version.
What we couldn’t show in the binary
Some parts of the fix don’t leave a clear trace in compiled code:
- The session upload progress change. Upstream switches the upload progress callback off with
if (!PS(rfc1867_enabled) || true). The compiler turns that into an unconditional return, and there is nothing distinctive left to point at. The dangling pointers that path could leave behind are cleared by the changes above anyway. - About 40 integer overflow checks that now call
zend_mm_safe_error_ex. We didn’t decompile each one. They all end up in the new handler, and that handler is there. - The opcache part of the fix. It isn’t in the firmware, but that’s expected: NetScaler doesn’t load opcache. There is no
opcache.soand nozend_extensionline inphp.ini.
What didn’t change
The fix is in the code, not in the configuration and not in the platform:
php.iniis the same as in 73.46, withmemory_limitat 1084M andpost_max_sizeat 1500M.- The kernel is still FreeBSD 11.4. It still has none of the ASLR sysctls, and
exec_map_stackis still missing. httpdandphpare still not position independent.
So everything from part 2 about ASLR still applies. It just matters less now that these two bugs are fixed. The next PHP bug will land on the same end of life PHP and FreeBSD.
Are the issues fixed?
For the mechanisms behind both CVEs, yes:
- CVE-2026-109618: fixed. The
longjmpon the out of memory path is gone, and the process now ends cleanly with_exit(1). - CVE-2026-109619: fixed. The dangling pointers are cleared, the superglobals are reset, and the handler slots are cleared at the end of every request.
That is a strong argument from the code, not proof. We didn’t run an exploit against 73.49. We also only analysed 14.1-73.49. The 13.1 and FIPS builds should get the same check.
What to do
- Update to one of the fixed builds from CTX697263.
- Keep the management interface off the internet and only reachable from admin hosts, as in part 1. A fixed build doesn’t change that.
- Expect your scanner to disagree. It will still see PHP 8.1.34, see below.
The version number didn’t change
Both 73.46 and 73.49 report PHP 8.1.34, built from the same source path, /usr/home/build/adc/usr.src/contrib/php/php-8.1.34.
That isn’t something Citrix could have done differently. There is no official 8.1 release with the fix, and they can’t release a PHP 8.1.35 themselves: that version number belongs to the PHP project, and a home-made 8.1.35 would only cause confusion. Linux distributions have the same problem when they backport a fix, and they also keep the upstream version number.
But it has a practical consequence. Any security scanner that judges by the PHP version will keep flagging the patched 73.49 as vulnerable to PHP2Shell, because 8.1.34 is in the affected range. For a Linux distribution, scanners use the distribution’s own security data to know which package builds contain the backport. For NetScaler there is only the bulletin, and that goes by NetScaler build, not by PHP version.
So the scanner result is a false positive, but you can’t tell from the version number. That’s why we went through the effort of proving it in the code: the version string is not the patch state. What tells you if you are fixed is the NetScaler build number, compared with the builds in CTX697263.
Next: what else is in there?
This series started with a simple question: which PHP version does NetScaler run? It took taking the firmware apart to answer it, and that won’t scale to the next vulnerability. So in a next post I’ll look at whether you can automatically derive an SBOM (a software bill of materials) from NetScaler firmware: a list of every component and version on the appliance. That would make “am I affected?” a quick lookup instead of a reverse engineering project. As this post shows, it won’t tell you everything: a version is where the investigation starts, not where it ends.
References
- Citrix Security Advisory for CVE-2026-109618 and CVE-2026-109619 (CTX697263)
- Release notes for NetScaler ADC 14.1-73.49 (PDF)
- PHP 8.1 backport branch
- GHSA-w8hw-w9fx-j79w, RCE via OOM corruption and GHSA-6wmf-4rv5-p3rp, RCE via stale global state
- Part 1: Does PHP2Shell reach NetScaler? Taking the firmware apart to find out
- Part 2: PHP2Shell on NetScaler: to ASLR or not to ASLR?
A note on how this post was written: I used AI assistance. Claude helped with the analysis and made the first draft from our notes. It was not written automatically: Jan and I steered the investigation, reviewed the draft, corrected what was wrong and rewrote parts in our own words.
