When I wrote the first post about PHP2Shell on NetScaler, I had no idea it was going to be a series. But it has been an interesting deep dive, and every part answered a question by taking the firmware apart. Which PHP version does NetScaler run? Does it have ASLR? Did Citrix really fix it?
That worked, but it doesn’t scale. The next vulnerability will start with the same one: do we run this, and which version?
The official advice for PHP2Shell assumed you know that already. The Dutch National Cyber Security Centre (NCSC-NL) keeps a php2shell repository on GitHub, and its first recommended action is to identify your PHP applications and their versions, starting with internet facing systems. It also points out that PHP is embedded in appliances and management interfaces. The UK National Cyber Security Centre (NCSC) says the same, and adds that you should consider taking a system offline if you can’t mitigate it quickly.
In my eyes, knowing what you have is one of the most important things in IT security. It’s also not the most exciting part. Companies like to invest in a SOC, and in tooling such as a SIEM, EDR, threat intelligence feeds and vulnerability scanners. But all of that builds on knowing what you run. It’s the foundation, even if it isn’t sexy.
For a closed appliance, that is the hard part. NetScaler customers couldn’t even tell which PHP version they had until someone looked inside, and the same goes for most firewalls, load balancers, VPN gateways and storage appliances. The industry answer to “know what you run” is an SBOM, a software bill of materials. So I tried: can you derive a usable SBOM from closed firmware yourself?
The experiment is generic. NetScaler is the test case because I already know its firmware from parts 1 to 3, so I can check the result against what I found by hand.
Disclaimer: this is my own static analysis of the firmware (build 14.1-73.46), not confirmed by Citrix. An SBOM only contains metadata (names, versions, hashes), no Citrix code. And a component or a CVE match in an SBOM means that a version is present, not that the appliance is exploitable. More on that below.
The short version
- Yes, you can derive a usable SBOM from closed firmware, but not with a standard tool alone. On NetScaler, Syft, a common SBOM tool, found 6 components with a version, and only after I switched off a part that crashed. A firmware aware pass found 34.
- Fed into a vulnerability scanner, the SBOM flags PHP2Shell on PHP 8.1.34 in seconds. In part 1 that took taking the firmware apart.
- Two vulnerability feeds disagreed on exactly the component that mattered. One doesn’t list PHP 8.1 as affected at all, because 8.1 is end of life and never got a fixed release.
- A version match is where the investigation starts, not where it ends. The patched build from part 3 still reports PHP 8.1.34, so an SBOM would flag it too.
The easy case: software you build yourself
If you build software yourself, an SBOM is almost free. In my own nginx with ModSecurity container image it comes out of the build without extra work:
docker/build-push-actionwithsbom: truelets BuildKit generate an SBOM during the build and attach it to the image.anchore/sbom-action(Syft under the hood) writes a CycloneDX file.actions/attest-sbomsigns the SBOM against the image digest.- Docker Scout scans the image, and a scheduled workflow scans it again every day.
Because the pipeline rebuilds on every upstream release, the SBOM is always current. That is how it should be: the SBOM is made at build time, by whoever does the build.
What should be in an SBOM is described in the NTIA Minimum Elements For an SBOM from 2021, and the 2026 update by CISA and partners adds component hashes, licenses, the tool that made the SBOM and how it was generated. The common formats are CycloneDX and SPDX.
The hard case: a closed appliance
Customers of closed appliances usually don’t get an SBOM, and NetScaler customers are no exception. So the question is: can you derive one from the firmware image alone, so that next time you can look it up instead of reverse engineering under pressure?
A derived SBOM will always be less complete than one from the build. Statically linked code is hidden, stripped binaries lose their version strings, and code without a manifest is easy to miss. I wanted to know how big that gap is.
The first finding came before any tool ran: there is no package database at all. No FreeBSD pkg database, nothing a scanner can simply read. Everything has to come from the files themselves.
How I did it
I used NetScaler 14.1-73.46, the same build as in part 1, and CycloneDX as the format, like my own pipeline:
- Extract the full root filesystem from the firmware: 5,217 files.
- Run Syft on it, the same tool my pipeline uses.
- Do a firmware aware pass that knows where NetScaler keeps things: composer manifests for the PHP code, library names and version strings in the C libraries, the FreeBSD ABI note in the binaries.
- Merge both, and check every component that only one side found or where the versions disagree.
- Run the Grype vulnerability scanner on the merged SBOM, and check the results against a second feed, cve.now.
What Syft found, and what it missed
Syft didn’t even finish. Both a build from source and the official 1.55.0 release crashed on this firmware in the cataloger that detects the PHP interpreter, on a regular expression Go doesn’t accept. So the one part that would have found the PHP version is exactly the part I had to switch off.
With that cataloger disabled, Syft found 12 components, 6 of them with a version: bash, curl, httpd, OpenLDAP, OpenSSL 3.0.19 and yuicompressor. Two of those my own pass had missed: OpenLDAP 2.6.8 (from ldapsearch) and a bundled yuicompressor-2.4.7.jar.
What Syft missed:
- All 18 PHP packages of the websocket daemon. NetScaler ships the composer manifest in the old composer 1 format, a plain list. Syft only understands the composer 2 format and reports “No packages discovered”.
- PHP 8.1.34 itself, because its cataloger crashed.
- The base: FreeBSD 11.4, and a Linux compatibility layer with glibc 2.9.
- Most C libraries: a second OpenSSL, zlib, PCRE, SQLite, two versions of libxml2, and OpenSSH.
Merged, that gives 34 components: 28 only from my pass, 4 from both and 2 only from Syft. Neither side was enough on its own. That 6 versus 34 is what it costs not to get an SBOM from the vendor.
One component I couldn’t pin down: libcrypto.so.8 contains both “OpenSSL 1.0.2z” and “OpenSSL 1.1.0” and no dated banner. FreeBSD 11.4 shipped OpenSSL 1.0.2, so that is the likely version, but a string can’t prove it. And 1.0.2 and 1.1.0 have different vulnerabilities.
What the scanner found
Grype matched 377 vulnerabilities to 15 component versions: 44 Critical, 166 High, 135 Medium and 32 Low. And there it was:
php 8.1.34 CVE-2026-109618 Critical
php 8.1.34 CVE-2026-109619 HighThat is the whole point in two lines. With this SBOM, “do we run a PHP version PHP2Shell applies to?” takes seconds instead of a reverse engineering session.
The merge paid off too. guzzlehttp/psr7 1.7.0 matched six advisories. That package is only in the SBOM because my pass could read the composer 1 manifest. With Syft alone, those six would never have shown up.
Two feeds, two answers
As a second opinion I checked the same components against cve.now. For the PHP packages both agreed exactly: the same three packages (guzzlehttp/psr7, symfony/http-foundation and symfony/routing) with the same number of advisories.
For PHP itself they didn’t agree:
- Grype flagged PHP 8.1.34, through the NVD version range, which runs from 7.3.0 up to 8.2.35.
- cve.now lists only the branches that got a fix: 8.2, 8.3, 8.4 and 8.5. There is no 8.1 range, so 8.1.34 doesn’t match.
From part 1 we know the vulnerable code is in PHP 8.1. There is just no 8.1 fix, because 8.1 is end of life. A feed built around fixed versions only lists the branches that got one, and silently leaves out the end of life branch. So if you trust only that view, you conclude “8.1 isn’t listed, nothing to look at”. That is exactly backwards: it is the version most worth looking at, because there is no fix.
End of life doesn’t mean no bug. It means no fix.
A version is where it starts
Every match above means that a version is present that a vulnerability applies to. That is a reason to look, not proof. The code may never be reached, a component may not be loaded, or the vendor may have fixed it without changing the version number.
That last one is not hypothetical. In part 3 Jan Tytgat and I showed that the patched build 14.1-73.49 still reports PHP 8.1.34. An SBOM of 73.49 would flag PHP2Shell exactly like one of 73.46. Bash 3.2.57 and the ambiguous OpenSSL 1.0.2z are the same kind of case: old version strings that may or may not reflect what is actually in the binary.
So the SBOM makes the first question fast. Answering the second one, “is it actually exploitable here?”, is still work. For PHP2Shell that was parts 1 to 3.
How old was the firmware when it shipped?
Versions have release dates, so the SBOM also shows how current the firmware was. The kernel and the main binaries of 14.1-73.46 were built on 7 October 2026, and the build was released the day after.
| Component | Version | Released | Age at build |
|---|---|---|---|
| libxml2 | 2.9.10 | October 2019 | about 7 years |
| OpenSSH | 9.7p1 | March 2024 | about 2.5 years |
| SQLite | 3.48.0 | January 2025 | about 1.7 years |
| curl | 8.17.0 | late 2025 | about 1 year |
| libxml2 | 2.13.9 | 2025 | about 1 year |
| PHP | 8.1.34 | December 2025 | about 10 months, branch end of life since 31 December 2025 |
| OpenSSL | 3.0.19 | January 2026 | about 8 months |
| Apache httpd | 2.4.67 | May 2026 | about 5 months |
That is a mixed picture, and I want to be fair about it. Most of the stack is refreshed regularly: curl, SQLite, OpenSSL, httpd and one copy of libxml2 are all within about a year. This is not a neglected image.
But a few components are far behind. The old libxml2 2.9.10 sits next to a current 2.13.9 copy, and fixes for its critical matches existed long before October 2026. PHP2Shell is a different story: it was published two weeks after this build, so Citrix couldn’t have shipped that fix. The problem there was shipping PHP 8.1, a branch that was already end of life nine months before the build.
What I’d look at next
377 matches is a worklist, not a list of holes. On an appliance, what matters most is whether a component sees attacker input. A 9.8 in a library that never does matters less than a 9.2 on the management interface. Ordered that way, the components on the management web path come first: Apache httpd, OpenSSL and the old libxml2 copy. The 113 glibc matches look alarming, but glibc 2.9 belongs to the Linux compatibility layer, which needs checking before anything else.
Each of those would need the same treatment as PHP2Shell before anyone calls it a finding. If any of them turns out to be real, Citrix hears about it first.
Making it repeatable
The firmware aware pass started as a manual job. I turned it into a generic script that walks an extracted root filesystem and derives everything itself: the FreeBSD base from the ABI note, the libraries from their names and version strings, and every composer manifest in both formats. On 14.1-73.46 it produces exactly the same 34 components as the manual version, and Grype gives the same 377 matches.
Nothing in the script is NetScaler specific. ABI notes, library version strings and composer manifests look the same in any FreeBSD or Linux based firmware. The only vendor specific step is getting the root filesystem out of the image. I’ve only run it on NetScaler so far, so that is a claim to test, not a result.
So the whole chain is three steps: extract the firmware, generate the SBOM, scan it. The obvious next tests are a different NetScaler build, and firmware from a different vendor.
Conclusion
Can you derive a usable SBOM from closed firmware? For NetScaler, yes. And it turns “am I affected?” from a reverse engineering project into a quick lookup, at least for the first question.
Three things I take away from this:
- A build time SBOM is free and complete, a derived one is work and partial. My own pipeline gets one for nothing. For NetScaler it took extraction, two tools and a manual merge to get to 34 components instead of 6. The best fix would be an SBOM from the vendor with every build. That goes for every appliance vendor, not only Citrix.
- No single tool or feed is enough. Syft found things my pass missed and the other way around, and two vulnerability feeds disagreed on exactly the component that mattered.
- End of life is the trap. Appliances tend to carry old components for years. NetScaler ships an end of life FreeBSD and an end of life PHP, and the end of life PHP is exactly what a feed built around fixed versions can miss.
Next: every build since 2007
One firmware build is a snapshot. Jan Tytgat and I are now working on a scanner that keeps an SBOM inventory of NetScaler firmware from 2007 until now: 312 builds so far, from 9.1 to 15.1. Scanning all of them with Grype shows how the number of known vulnerabilities changes from one build to the next.

This gives some interesting insights, more about that soon.
References
- Part 1: Does PHP2Shell reach NetScaler? Taking the firmware apart to find out
- Part 2: PHP2Shell on NetScaler: to ASLR or not to ASLR?
- Part 3: Did Citrix really fix PHP2Shell on NetScaler? Taking the patched firmware apart
- NCSC-NL: php2shell operational information (GitHub)
- UK NCSC: Critical security issue affecting PHP
- NTIA: The Minimum Elements For a Software Bill of Materials (PDF)
- CISA: Updated Minimum Elements for a Software Bill of Materials (2026)
- Syft and Grype
- nginx with ModSecurity, my own build time SBOM example
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.
