Arc Theory

Thinking

Proving a negative: auditing an Azure estate for PHP2Shell

SecurityAzure

On 21 October 2026, PHP shipped emergency fixes for two bugs in the runtime itself, together known as PHP2Shell. Either one lets an unauthenticated attacker run code on the server before a single line of application code executes, so login pages, framework validation, and application-level firewall rules are not in the way. The US NSA advised taking sites offline if they could not be updated at once. CERT-EU and the UK NCSC said much the same, and a number of European government sites went dark as a precaution. Exploitation attempts were reported within days by watchTowr, Cloudflare, and Amazon, though CISA had not added the CVEs to its Known Exploited Vulnerabilities list as of 22 October. Both bugs were found by two teams independently, each using AI-assisted code auditing.

When the advisory landed, I sat down with Claude Code to answer one question for Arc Theory, for our clients, and for the partners whose Azure estates we have helped secure. Are we exposed, and can we show it?

This is the story of that day. It is about the process more than the result: what we checked, how the tool was built, what went wrong, where I had to correct the machine, and what we found. If you run anything on Azure, the middle sections are for you. If you are the person who gets asked “are we patched?” in a meeting, the next section alone explains why the honest answer takes longer than a version lookup.

Why “check the PHP version” is not enough

The obvious response to a PHP advisory is to log in and run php -v. For PHP2Shell that answer is wrong often enough to be dangerous. Five things get in the way.

Trap Why it misleads What to look at instead
Backports keep old version numbers Ubuntu, Red Hat, and most control panels patched their packages without changing the version. A server can report 8.3.6 and be fixed. The package changelog naming the CVE ids, or a build date after the fix
The CLI binary is not the web binary php -v reports whatever is first in PATH. Requests are served by PHP-FPM or the Apache module, which can be a different package at a different version. Which binary is actually behind the web server
Patched on disk is not patched in memory FPM pools, Apache workers, and containers keep the old code until restarted. Worker start time compared with when the fix landed
Floating image tags FROM php:8.3-fpm only carries the fix if the image was rebuilt after 14:00 UTC on 21 October. The tag says nothing. The image creation time, or better, what is inside the image
Remote checks see very little The X-Powered-By header is hidden on most hardened sites and never reflects a backport. Public checkers either read that header or send multipart probes that can crash a worker. Inspection from inside the container or VM

None of these was hypothetical. The vendor fix tables were full of version-unchanged backports, every official-image tag we found was floating, and most of our sites hide the header. A version check alone would have been wrong somewhere in almost any PHP estate.

There is a sixth complication for older PHP. No release exists for 8.1 or earlier. Those branches got source patches and vendor packages only, so a server on 7.4 cannot be patched in the usual sense. It has to be carried by a vendor backport or upgraded.

What we built

The brief I gave Claude was short. One Azure tenant at a time, every subscription in it, find everything that could run PHP, inspect it, and report. No fixed list, because I was not sure about all the services running in every subscription, and a list is only ever as good as the memory of whoever wrote it. Read-only: the tool discovers and reports, nothing else. Azure CLI and GitHub CLI access, and no Docker on the machine.

The result is a Claude Code skill, a set of instructions and shell scripts that Claude follows to run the audit. It works in five steps.

Discover. One Azure Resource Graph query across every enabled subscription, returning every resource type that could host PHP: App Service sites and slots, Static Web Apps, Container Apps with their jobs and environments, anything else under the Microsoft.App provider, Container Instances, VMs, scale sets, and AKS. In outline:

resources
| where type startswith "microsoft.web/"
     or type startswith "microsoft.app/"
     or type in ("microsoft.compute/virtualmachines",
                 "microsoft.containerinstance/containergroups")
| project subscriptionId, resourceGroup, name, type, kind, properties

The catch-all under Microsoft.App turned out to matter. It is how a resource type the tool had never seen got noticed instead of dropped.

Classify. Each resource gets a class from its type, kind, and runtime string: platform PHP on App Service, custom container on App Service, Container App, Static Web App, Function App, VM, and an unknown bucket for anything else.

Inspect, by the deepest method the hosting type and the identity’s permissions allow. For App Service, read the runtime configuration, send one HEAD request for the header hint, and if permitted, SSH into the application container and run a probe. For Container Apps, read images and revisions, exec the probe into a running replica if permitted, and always read the image’s configuration straight from the registry. For VMs, run the probe through Run Command.

The probe is one POSIX shell script, shipped into the target as a compressed string and run with sh, so it works on Alpine’s busybox as well as on Debian and Ubuntu. It writes nothing. It prints what it finds as key-value lines, which is what the report is built from:

OS=Ubuntu 26.04
PID1_START=2026-10-23T18:02:11Z
LISTENING_TCP=3000 5173
WEB_SERVERS=none
PHP_BINARIES=none
PHP_PACKAGES_ANY=php-crypt-gpg
VERDICT=NOT_PHP

Trace images to source. For every container image that is not an official php: image pinned to a fixed version, find the Dockerfile in GitHub, read every FROM line, and read the last successful build run. A floating tag is patched only if the image was rebuilt after the fix and the registry agrees.

Report. One Markdown file per tenant, with a verdict and the evidence behind it for every resource, an action table sorted worst first, and an appendix listing everything that was not PHP, so a reader can see nothing was skipped. The verdict vocabulary is deliberately narrow.

Verdict Meaning
NOT_PHP No PHP runtime in this resource
PATCHED Web-serving PHP at or above the fixed release
PATCHED_BACKPORT Version below the fixed release, but the changelog names the CVEs
VULNERABLE Supported branch below the fixed release, no backport evidence
VULNERABLE_EOL Branch 8.1 or older, no backport evidence; needs an upgrade
RESTART_NEEDED Patched on disk, but workers started before the fix landed
LIKELY_* Same meanings, from a header or a tag rather than a probe
NOT_PROBED, UNKNOWN Could not determine, with the reason attached

“Probably fine” is not on the list.

What broke

The first live tenant held Static Web Apps and Function Apps and nothing else, so the discovery path got a real test and the inspection path did not. That is a smoke test passing for the wrong reason, and four inspection bugs survived it. They surfaced on the third tenant, the first with real targets.

What Cause Fix
Every inspector returned nothing --subscription was placed before the command group, and Azure CLI wants global options after it A small wrapper that appends the flag
A script crashed on an empty array macOS ships bash 3.2, which treats an empty array as unbound under set -u The ${arr[@]+"${arr[@]}"} idiom
Static Web App backends came back blank The CLI command refuses to run against Free-SKU apps The REST endpoint, which works on every SKU
Function Apps returned null for everything Flex Consumption apps are not modelled by this CLI version A plain ARM GET
Exec into a Container App denied Reader lacks Microsoft.App/containerApps/getAuthToken/action Detect that error, report it as a permissions gap, fall back to the image config
az webapp show denied The command fetches more than the ARM resource and needs publishxml/action A plain ARM GET, which Reader can do
An empty sandbox group looped twice macOS seq 0 -1 counts down A while loop

Two of these deserve a closer look. The permission errors were not failures of the tool. “Reader cannot exec” is a fact about the estate’s access model, and it belongs in the report next to whatever evidence was used instead. A tool that prints “exec returned nothing” hides it. A tool that prints “exec denied, needs this role” lets the owner decide whether to grant it.

And the fallback that permission error forced turned out to be the most useful component of the day. Without Docker locally and without exec, the question “what is in this image?” looked stuck. It is not. A container registry will hand over an image’s manifest and config blob to anyone with pull rights, without a single layer being downloaded. The config holds the environment, the command, the entrypoint, and the complete build history. For an official php: image, the PHP_VERSION variable gives the exact version. For any image, the absence of PHP in all of those says NOT_PHP, with whatever runtime is there as the evidence:

{
  "Env": ["NODE_VERSION=22.23.3", "PORT=8080"],
  "Cmd": ["node", "server/dist/index.js"],
  "History": ["...no PHP step anywhere..."]
}

This takes about a second per image, works anonymously against Docker Hub and Microsoft’s registry, and belongs in every container audit kit.

Two things the human caught

Both of my interventions were about scope, not code. Claude had decided something was out of scope, and it was wrong about why.

Waking things without asking. The Container Apps inspector sent a HEAD request to every app’s ingress to pick up the header hint. Two of the three apps in that subscription were scaled to zero. A HEAD request to a scaled-to-zero app starts a replica. That costs money, shows up in someone’s telemetry, and the apps belonged to a client. The tool had done it without asking. Waking is now behind a flag, and the skill tells Claude to ask first and name every app it would start. Without the flag, scaled-to-zero apps get the image-config verdict only, and the report says so.

Stopped sandboxes are not safe. The discovery query also returned Azure Container Apps Sandboxes, which in our case hold AI agents that execute code. I knew what they were. Claude classified them, noted that none exposed a port, and wrote them into the appendix as not web servers, out of scope.

I asked: what if a sandbox allows something you miss, and then when it is started, it is exposed?

That was the sharper point. Exposed ports and installed software are runtime state. The agents inside these sandboxes run as root with no permission model. One of them could install PHP and open a port five minutes after any audit. A stopped sandbox proves nothing about its disk. The tool’s reasoning, no exposed ports, therefore unreachable, therefore out of scope, was true of the present and said nothing about the future. The person who owns the system is the one who knows what can change about it.

So the tool changed. It now lists every sandbox with its state, exposed ports, and disk image, execs the probe into running ones, and reports stopped ones as NOT_PROBED unless the owner approves resuming them, in which case it resumes, probes, and stops again. The probe grew three fields: listening TCP ports, installed web servers, and any package whose name starts with php, so an agent that installs PHP or opens a port after the image was baked is caught.

I approved resuming two stopped agent sandboxes. Both came back clean. One had development servers listening on two ports that are not exposed, which is exactly the kind of thing the new fields exist to show. Two older, retired sandboxes I chose not to resume. They are in the report as not probed, with that reason and a note on residual risk.

That last field also produced the day’s one false alarm. Every sandbox image carries a Debian package called php-crypt-gpg, a PEAR library pulled in as a dependency of something else. There is no interpreter. The tool surfaced it and the report explains it. A tool that filtered it out to keep the report tidy is a tool nobody should trust.

The point outlives PHP2Shell. A container whose contents are fixed at build time can be judged by what it runs. One with an agent inside cannot, because the agent can change what it runs at any moment, so it stays in scope for every audit, stopped or not.

A side finding about Microsoft’s platform images

While testing the registry reader, we pointed it at Microsoft’s own App Service PHP images, the ones a Linux App Service on platform PHP actually runs. They are tagged with a date and a build number. Reading the image configs on 24 October:

  • Tags from _20261021.5 onward carry the fixed versions.
  • The _20261021.2 tags, dated the release day, still contained PHP 8.3.35 and 8.4.26. They were built two hours before the release.
  • A _20261020.9 tag, dated the day before the release, contained 8.3.36. It is a retag of the fixed build.

A rule of the form “tag dated on or after the 21st means patched” would have been wrong in both directions. Read the image config. Never trust the tag, even from the platform vendor. We did not find a Microsoft statement about the rollout across App Service, so we cannot say when an app on platform PHP picks the new image up, beyond the general rule that it happens on restart or platform recycle.

What we found

No PHP runtime exists anywhere in the estates we audited, with evidence per resource, and two older versions of sandboxes deliberately not probed.

I want to be careful about how that is said. “We don’t run PHP” was a belief when we started. By the time the reports were written it was a finding, with evidence per resource: a runtime string, an image config, a Dockerfile, a probe output. That is the difference between finding nothing and not looking. The tooling exists to prove a negative, and a negative is only worth anything because of how it was proved.

What remains untested, and what we would do differently

Honesty here is what makes the rest believable.

  • The App Service SSH probe and the Container Apps exec probe have never run against a live PHP app, because nothing we audited runs PHP. The same probe ran through the sandbox exec path and against a fake PHP binary at seven versions, but a clean result on an untested path should raise a question, not relief.
  • AKS is out of scope. The probe would run there through kubectl exec; no script does it.
  • Distroless images without sh cannot run the probe. The image trace is the fallback.
  • The vendor fix table the tool carries is a snapshot from 22 October. Anyone reusing the method should re-check the vendor advisories on the day.

If we did it again: build a fixture with at least one of each resource class before the first live run. Write the registry reader first. Treat anything with an agent inside it as in scope from the start. Put the permission requirements in the design rather than learning them from AuthorizationFailed. And keep a running list of what the tool does not cover, printed at the end of every report.

Why the code stays private

We are not publishing the skill. It is a point-in-time tool built for our own use. Published, it would be picked up as a general PHP2Shell checker, and a clean result from a tool that does not cover your case is worse than no result at all. The method is above, in enough detail to build your own.

Do your own research, starting from PHP’s advisories and the analyses below, and ask for help when you need it.

Sources

Start with a conversation.

If you have a proof of concept and need production, write to either of us. Engagements begin with a short assessment.