Finding PHP2Shell in Kubernetes, Including the PHP Trivy Can't See
PHP2Shell is fixed in PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. How to find the Pods still running an older PHP with Radar, including images built on the official php image, where Trivy reports no PHP at all.

CVE-2026-109618 and CVE-2026-109619, widely called PHP2Shell, are unauthenticated remote code execution bugs in the PHP engine. PHP fixed them on October 21, 2026 in 8.2.35, 8.3.36, 8.4.27 and 8.5.12, and 8.1 and older get no official release. The bugs are in PHP itself, and PHP's advisory calls updating "the only reliable mitigation". On Kubernetes, that means a new image for every Pod serving an older PHP.
If you run Trivy Operator, you start with its reports. This is Trivy's report for the official WordPress image on a test cluster, opened in Radar and filtered for php:
Trivy found 1,259 vulnerabilities in that image and none in PHP. (This cluster's Trivy Operator skips unfixed, Low and Medium findings, for a reason given in the limits at the end.) The image runs PHP 8.2.25:
$ crane config wordpress:6.6.2-php8.2-apache | jq -r '.config.Env[]' | grep PHP_VERSION
PHP_VERSION=8.2.25Trivy doesn't report it because there's no PHP package to report. The official php images compile PHP from source into /usr/local, so it never enters the Debian or Alpine package database, and Trivy's PHP analyzer reads Composer files, which list your libraries, not the interpreter. For binaries, a Trivy maintainer wrote in 2024 that Trivy "only detects Go and Rust binaries". The official WordPress, Drupal and Nextcloud images are built FROM php, like any custom image that starts from it, and they inherit both the compiled PHP and the ENV PHP_VERSION line.
How PHP was installed decides whether Trivy reports it
| How PHP got into the image | Examples | Does Trivy report the PHP runtime? | Where the version is |
|---|---|---|---|
Compiled by the official php image | php:8.3-apache, php:8.3-fpm-alpine, and images built FROM them: wordpress, drupal, nextcloud, your own FROM php:... images | No | ENV PHP_VERSION in the image config, inherited by every child image |
| Installed from distro packages | apt-get install php8.2-fpm on Debian, dnf install php on RHEL-family distros | Yes, once the distro's security tracker lists the CVE. Debian's does: php8.2 on bookworm is fixed in 8.2.35-1~deb12u1 | The package version |
| Compiled some other way | Your own build stage | No | In the binary: php -v in the container is the closest check |
A distro package can carry the fix under an older upstream version. AlmaLinux backported the fixes into php-8.3.33-1.el10_2.alma.1 and warns that php -v won't show them. Distro advisory data covers that case, and it's what Trivy matches OS packages against. Syft and Grype handle source-built PHP differently: Syft's PHP interpreter cataloger reads the version string from the php and php-fpm binaries and Apache's libphp module, so Grype can match those images against its vulnerability data.
Finding PHP2Shell with Radar
Radar v1.18.0, released on October 22, handles the first two rows of that table: it searches Trivy findings and reads declared runtime versions. I prototyped both pieces the day PHP2Shell was disclosed, and Nadav and Eyal finished and hardened them overnight. Radar runs on your laptop against your kubeconfig and installs nothing in the cluster. Only the search needs Trivy Operator.
The test cluster in these screenshots runs four Deployments on PHP below the fix: cms/blog on wordpress:6.6.2-php8.2-apache, team-b/api on php:8.2.20-fpm, shop/web on php:8.3.10-apache, and billing/invoices, a Debian image with php8.2-fpm 8.2.32 installed from apt. Trivy Operator 0.35.0 scanned them with Trivy 0.75.0.
Search Trivy findings by CVE
Type the CVE into ⌘K:
Four Deployments run PHP below the fix, and the search returns one: billing/invoices, the only one whose PHP came from a package. Search matches CVE IDs, package names, SBOM components, failed check IDs and the scanned image, and labels a hit that matched inside a report in findings. Opening it shows the rows:
⌘K, the /api/search REST route and the MCP search tool share one search, so you and an agent get the same reports, limited by your RBAC. With auth enabled, a user sees a report kind only where they may list it. Being able to see a namespace's Pods isn't enough. Through REST and MCP, a hit also names the workload behind the scanned ReplicaSet and the ReplicaSet's desired replica count, when Radar can resolve them from its cache. To match a single finding there's a CEL report binding:
report.vulnerabilities.exists(v, v.vulnerabilityID == "CVE-2026-109618" && v.resource.contains("php"))The findings search has a time budget, so on a large cluster it can stop before checking every report. When it does, the result says some reports weren't fully searched, so an empty answer doesn't read as "not affected".
Ask an agent for declared runtime versions
The other three Deployments need get_runtime_versions, an MCP tool for AI agents, with no screen in the UI. For each image in the Running and Pending Pods you can see, it reads the image config for the digest the kubelet reports, on that node's platform, and returns the declared runtime versions with the workloads running the image. Without a kubelet-reported digest it reads the image named in the Pod spec and gives no fix verdict. It fetches the manifest and the config, never the layers. Called with runtime: php and fixed_in: 8.2.35,8.3.36,8.4.27,8.5.12 on the same cluster, trimmed to the images that declare PHP:
{
"images": [
{
"image": "php:8.2.20-fpm",
"resolvedFrom": "digest",
"declared": [{ "runtime": "php", "version": "8.2.20", "source": "env:PHP_VERSION",
"fix": "declared_below_fix", "fixedIn": "8.2.35" }],
"workloads": [{ "kind": "Deployment", "namespace": "team-b", "name": "api", "pods": 1 }]
},
{
"image": "php:8.3.10-apache",
"resolvedFrom": "digest",
"declared": [{ "runtime": "php", "version": "8.3.10", "source": "env:PHP_VERSION",
"fix": "declared_below_fix", "fixedIn": "8.3.36" }],
"workloads": [{ "kind": "Deployment", "namespace": "shop", "name": "web", "pods": 2 }]
},
{
"image": "wordpress:6.6.2-php8.2-apache",
"resolvedFrom": "digest",
"declared": [{ "runtime": "php", "version": "8.2.25", "source": "env:PHP_VERSION",
"fix": "declared_below_fix", "fixedIn": "8.2.35" }],
"workloads": [{ "kind": "Deployment", "namespace": "cms", "name": "blog", "pods": 1 }]
}
],
"totals": { "images": 11, "matched": 3, "belowFix": 3, "unknown": 8, "returned": 11 }
}The other eight images come back as no_declaration, including billing/invoices, the one Trivy flagged: "image config does not declare this runtime; its presence and version are unknown". In this cluster the runtime versions catch the three source-built PHPs and Trivy catches the Debian packages, which is why the tool's description tells the agent to search the Trivy reports as well.
The tool reads other runtimes' declared versions too. Docker's official Node, Python, Go, Ruby, Postgres, Redis and other images declare their versions in similar variables (NODE_VERSION, PYTHON_VERSION, PG_VERSION), so another runtime CVE needs a different runtime and its fixed releases. The tool reads only a fixed list of those keys and never any other environment value, since image env can hold baked-in credentials.
These cases come back as unknown or without a verdict:
- If the kubelet reported no registry digest (
resolvedFrom: tagorspec), or the node's platform wasn't readable, the image gets no fix verdict. - Each image is looked up with its Pods' own pull secrets, then anonymously. When Radar runs locally with auth off, it can also use your local Docker credentials. Node credentials aren't used, so an image the nodes pull through their cloud identity, such as ECR through a node IAM role, can come back
auth_required. - A declared version is what the image says, not proof of the binary.
- One call returns up to 50 images by default and 200 at most, and each image lists up to 20 workloads (
workloadsTotalhas the full count).totalscounts the images found, matched, below the fix and unknown (unreadable or undeclared), andnamespacenarrows a large cluster.
Check that the fix landed
After rolling shop/web to php:8.3.36-apache and billing/invoices to Debian's 8.2.35-1~deb12u1, the same call lists shop/web as declared_at_or_above_fix at 8.3.36, and the ⌘K search for the CVE returns nothing once Trivy Operator has rescanned the new ReplicaSet. That empty result only says Trivy's reports are clean: cms/blog and team-b/api still run PHP below the fix, and only get_runtime_versions shows it.
With its default vulnerabilityScannerScanOnlyCurrentRevisions: true, Trivy Operator also removed the old ReplicaSets' reports after the rollout. If you turn that off, the old reports stay, and the replica count in Radar's REST and MCP results helps tell them apart: a ReplicaSet at replicas: 0 usually belongs to an earlier rollout.
Checking by hand
Without Radar, the two underlying checks take kubectl, jq and crane. They stop at images and ReplicaSets; getting from there to Deployments means following owner references. This lists the unique images running in your Pods, sidecars and debug containers included, and prints the PHP version each one declares:
kubectl get pods -A -o json \
| jq -r '.items[].status
| (.containerStatuses // []) + (.initContainerStatuses // []) + (.ephemeralContainerStatuses // [])
| .[] | select(.state.running) | .imageID | sub("^docker-pullable://"; "")' \
| sort -u \
| while read -r ref; do
case "$ref" in *@sha256:*) ;; *) echo "no-registry-digest $ref"; continue ;; esac
if cfg=$(crane config "$ref" 2>/dev/null); then
v=$(jq -r '.config.Env[]? | select(startswith("PHP_VERSION=")) | ltrimstr("PHP_VERSION=")' <<<"$cfg")
echo "${v:-none} $ref"
else
echo "lookup-failed $ref"
fi
doneEach line starts with the declared version, none when the image declares no PHP_VERSION, or why it couldn't be read. The script reads imageID, the digest the kubelet reports for each running container, instead of the image named in the Pod spec. Upstream has already rebuilt the floating tags: as of October 22 (linux/amd64), php:8.3-apache declares 8.3.36, drupal:10-apache declares 8.4.27 and nextcloud:apache declares 8.5.12. A Pod that pulled one of those tags last month still runs the old digest, and with imagePullPolicy: IfNotPresent a restart on the same node keeps it. Look the tag up today and it reads as fixed.
For distro-packaged PHP, search the VulnerabilityReports:
kubectl get vulnerabilityreports -A -o json | jq -r '.items[]
| .report.vulnerabilities[]? as $v
| select($v.vulnerabilityID == "CVE-2026-109618" or $v.vulnerabilityID == "CVE-2026-109619")
| "\(.metadata.namespace)/\(.metadata.labels["trivy-operator.resource.kind"])/\(.metadata.labels["trivy-operator.resource.name"]) \($v.resource) \($v.installedVersion) -> \($v.fixedVersion)"'Limits of both checks:
- A missing report isn't a clean one. With Trivy Operator's default settings, three of the four test images got no report at all: their results were over the API server's 3 MiB request limit, and the operator logged
Request entity too large. Settingtrivy.ignoreUnfixed=trueandtrivy.severity=HIGH,CRITICALgot them under it; we also turned off SBOM reports (operator.sbomGenerationEnabled=false). - A report written before Trivy's database knew about PHP2Shell won't list it. With the Helm chart's defaults, reports are deleted after 24 hours (
scannerReportTTL) and the workload is scanned again. nonedoesn't mean no PHP: see rows two and three, and a multi-stageCOPY --from=phpcopies the binary without thePHP_VERSIONline.- Given a multi-arch index digest,
crane configreads the linux/amd64 variant. Official images declare the samePHP_VERSIONon every platform, but pass--platformif yours differ. lookup-failedusually means a private registry: log in withcrane auth loginor your Docker config. Manifest requests count against Docker Hub's pull limits, which may be the same account or egress IP your nodes pull with.PHP_VERSIONis what the image declares. For a workload on your shortlist, runkubectl exec -n <namespace> <pod> -c <container> -- php -v.
Radar is open source (Apache-2.0):
brew install skyhook-io/tap/radar
radarThen add it to your agent, for example claude mcp add radar --transport http http://localhost:9280/mcp. The MCP docs cover other clients. If Radar reports an image at or above the fix when you can show it isn't, please open an issue on GitHub.
Get the next issue in your inbox
Kubernetes deep-dives, new Radar releases, and what we learned shipping them.
About one email a month. No sales sequences. Unsubscribe anytime.
Keep reading
Read-Only Is Not a Safety Boundary
Two sentences get said in every meeting where a team wires an AI agent into a cluster: it's read-only, and just use the service account. Both are wrong later.
Give Your Agent Context Before You Buy an AI SRE
We ran the same agent and model on 50 live Kubernetes faults. With read-only cluster context it reached the answer about three times faster, and was slightly more accurate. Here's what it did without that context, and how to run the same test before you buy an AI SRE.
Radar MCP vs kubectl: A Kubernetes Agent Benchmark on 50 Faults
The same model diagnosed 50 live Kubernetes faults 3x faster on average through Radar's MCP server than through raw kubectl, with slightly higher accuracy. What we measured, why it happens, and where Radar still loses.