detection-engineering · threat-hunting · php

php2shell in the Lab, and Where a SOC Catches It

On October 21 the PHP project shipped emergency fixes for two memory-corruption bugs in PHP core, CVE-2026-109618 and CVE-2026-109619, named php2shell by the researchers who found them. Both are unauthenticated remote code execution, both rated CVSS v4.0 9.2, and both are fixed in PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35. The defensive advice is the same as it always is for a pre-auth RCE: patch.

We took it into a lab to answer two questions a SOC actually has to answer. Can it be exploited, and what does that look like on a host? And if it runs at one of our customers, would our detections catch it? This post walks through both, on Linux and on Windows, and ends with the two rules and the Sigma versions we wrote along the way.

The exploit work ran in an isolated lab. We don't publish offsets or a working exploit.

What the bugs are

The two CVEs are separate bugs that both end in code execution as the web-server user. 109619 is a stale-state bug: request-owned data outlives the memory it points at, and a later request on the same worker uses the dangling reference. Session upload progress is a confirmed path into it, which is where the php2shell name comes from and why the headline mitigation turns that feature off. 109618 is different. It's a corruption that happens when PHP hits an out-of-memory condition mid-request, before the request data is fully built, and cleanup then treats the half-built data as real.

The advisory puts mod_php at the top of the risk list and names php-fpm, php-cgi, FrankenPHP, and LiteSpeed alongside it. It's a bug in PHP core, and the Windows binaries were patched in the same release, so Windows installs — including the php-cgi.exe FastCGI worker behind IIS — are affected too. We won't walk the memory internals here; the advisory and the two GHSA writeups have them.

Recreating it on Linux

The target was a php:8.4.26-apache container running mod_php, with a locked-down attacker container next to it. Both sat on a Docker network set internal: true, so neither could reach the internet or the LAN. We checked: the victim answered the attacker and nothing else answered at all. A LimaCharlie sensor watched the host so we could read the telemetry afterward.

The exploit that worked carries its own information leak. It gets PHP to hand back pointers from session data in a response, works out the live addresses it needs from them, and then redirects execution into PHP's internal system handler. The leak is the part that matters. Without it you get a crashed worker; with it you get command execution.

First command, first try, it came back:

uid=33(www-data) gid=33(www-data) groups=33(www-data)

That's unauthenticated RCE as the Apache worker. The command was id, written to a file on the target and read back over HTTP.

One thing to do before you run anyone's php2shell PoC: read it first. One "working RCE" we looked at shipped a staging server that opened a reverse shell back to the author and sent the target's id output to their host. Run it and you've backdoored yourself, not the target. We only ran the pieces we'd read, and threw the rest out.

What it looks like on the host

Here's the part a SOC can use. On a clean, successful exploit the memory corruption is quiet: it happens inside the worker, and then the worker runs the attacker's command. What you see is the command. In our capture:

apache2 (www-data)  ->  /bin/sh -c "id ..."  ->  id

That shape is the same whether the attacker got in through php2shell, a dropped web shell, or plain command injection: a web-facing PHP worker spawns a shell and runs something. The endpoint sensor doesn't see the exploit request. The giant multipart POST, the thousands of fields, the upload-progress field all live in the request body, which is WAF and proxy territory, not EDR.

Our lab had no WAF or proxy in front of it, so we didn't capture these, but if you have that visibility the request should carry tells: a multipart/form-data POST with a PHP_SESSION_UPLOAD_PROGRESS field (the default name — it's configurable), sent with Transfer-Encoding: chunked, usually several in a row with different PHPSESSIDs and a burst of near-identical form fields (the heap spray). Expect a Set-Cookie: PHPSESSID= carrying binary or %00 bytes when the info leak lands. The one log line we did see, as the crashing workers bailed out, is Allowed memory size … in Unknown on line 0.

The corruption shows up as a second, different signal: a failed attempt crashes the worker. These exploits are tuned to a specific build, so a lot of attempts (scanners, the wrong version, mass exploitation that doesn't land) crash the worker instead of running a command. In our Linux runs the worker crashed and respawned under both signals — Apache logged AH00052: child pid N exit signal Abort (6) when PHP's allocator caught the heap corruption, and exit signal Segmentation fault (11) when the stale-pointer write landed in unmapped memory first. On Windows, which we didn't exploit, expect the equivalent as the php-cgi.exe worker recycles: a WerFault.exe spawn and an Application Error (Event ID 1000). Crashes have plenty of benign causes, so pair a worker-crash hunt with the matching web-server error log or request before you treat it as an attempt. Two things to watch, then: a web worker spawning a shell, which means something ran, and PHP workers crashing and recycling, which means someone may be trying.

Catching it on Linux: watch the shell, not the binary

There's a trap in writing the Linux detection for this. PHP's system() and exec() route the command through /bin/sh -c. So the tree is:

apache2  ->  /bin/sh -c "id"   (the shell)
              ->  id            (the command)

If you write the rule the obvious way, looking for a web worker spawning a recon binary like id, you'll miss it. id isn't the web worker's child; the shell is, and id is a grandchild. A rule that keys on the child binary is looking one process too shallow. Our own Linux webshell rule had exactly that shape, and the exploitation chain walked right past it. (Not every PHP exec goes through a shell, though: proc_open with an argv array and pcntl_exec run the binary directly, so this is the common case, not all of them.)

The fix is to read the command off the shell instead of hunting for the binary. sh -c "id" already tells you what ran. That's what our Linux rule keys on: a web-server or FastCGI parent (apache2, httpd, nginx, lighttpd, caddy, php-fpm, php-cgi — and, as we extend it for this advisory, FrankenPHP and lsphp) spawning a shell whose command line matches a delimiter-bounded recon verb. The Sigma version is below.

Windows: same bug, a different exploit story

For anyone running PHP on IIS: it's vulnerable, patch it. The same core code ships on Windows.

The exploitation story is different. As of this writing there's no public Windows RCE for this: every working exploit and serious PoC we reviewed targets Linux and depends on build-specific offsets, and Windows makes that harder with Control Flow Guard, ASLR, and a different allocator. We also couldn't get the public crash triggers to reliably crash php-cgi.exe under IIS; they were written against Apache on Linux.

So for the detection side on Windows we skipped the exploit and reproduced the part that matters: a web worker running a command. A small, deliberately-vulnerable script on IIS with php-cgi.exe, Sysmon with our config, and a LimaCharlie sensor gave us:

w3wp.exe  ->  php-cgi.exe  ->  cmd.exe /s /c "whoami"   ->  whoami.exe

The tree is the same as Linux: the command is on the worker-to-shell edge (here cmd.exe /s /c "whoami"), and the recon binary (whoami.exe) is a grandchild. You read the command off the cmd.exe line, exactly as you read it off sh -c on Linux. Our Windows rule keys on that edge with a broad recon-verb set, so a full sweep (whoami, ipconfig, hostname, net localgroup, tasklist, and the rest) lands, not just one or two commands.

Worth scoping for defenders: a PHP worker on IIS often runs as nt authority\iusr with SeImpersonatePrivilege, the usual first rung toward SYSTEM. It's an identity question, not a given — app-pool identity, anonymous-auth identity, and FastCGI impersonation all change who the worker runs as, and SeImpersonatePrivilege isn't on every PHP-on-IIS host. Treat a web shell there as a privilege-escalation risk to check, not a guaranteed path to admin.

The rules

Two rules came out of this: a Linux one, "Web Worker Shell Running Host Reconnaissance," and a Windows companion. Both key the same way on both platforms: a web-worker parent, a shell or interpreter child, and a delimiter-bounded reconnaissance verb on that child's command line. The "same bug, same rule shape" point is the one to carry past php2shell. The Sigma equivalents are below.

Before trusting the Linux one we replayed it across our fleet, which is the step that tells you whether a rule is production-ready or only lab-ready. It wasn't ready. The first draft matched the bare php CLI as a parent, and legitimate command-line PHP (a monitoring agent shelling out to hostname, cron jobs calling php -r) would have generated noise. We required a real web or FastCGI parent and the noise dropped out. That excludes one real thing, the PHP built-in dev server (php -S, which does run as bare php), and we took that trade knowingly because it isn't a production handler. The residual false positives to expect are web apps that legitimately shell out to a recon binary, usually hostname. The modern-network verbs (ip, ss) and the FrankenPHP/lsphp parents are recent additions — short verbs like ip/ss are exactly the kind that need checking against real traffic, so we replay them across the fleet the same way before leaning on them. Whatever rule you run, backtest it on your own data, find the benign pattern, tighten, and check it against your baseline before you turn it on.

Sigma versions

Two Sigma rules below, a Linux one and a Windows one. Both key on the same shape: a web-worker parent, a shell child, and a reconnaissance verb on the shell's command line. They fire on the plaintext-recon case, a shell running a named recon command, so an attacker who obfuscates the verb, or whose first command isn't recon, gets past them; they're a strong signal for the common case, not proof of compromise on their own. We deliberately leave high-frequency verbs like cat, ls, and ps out, since they run constantly in normal web apps. The regexes handle awkward command lines like id>/t/p and $(id); fit the parent and verb lists to your own environment before you enforce them.

title: Web Worker Shell Running Host Reconnaissance (Linux)
id: 67afd63c-418f-43a4-a4a9-feae3783a641
status: experimental
description: A Linux web-server or FastCGI worker spawning a shell whose command
    line runs host reconnaissance.
references:
    - https://www.php.net/archive/2026.php#2026-10-21-5
author: BHIS ActiveSOC
date: 2026-10-21
logsource:
    category: process_creation
    product: linux
detection:
    web_worker_parent_path:
        ParentImage|endswith: ['/apache2', '/httpd', '/nginx', '/lighttpd', '/caddy', '/frankenphp']
    web_worker_parent_fastcgi:
        ParentImage|re:
            - '(?:^|/)php[0-9]*(?:\.[0-9]+)*(?:-fpm|-cgi)[0-9]*(?:\.[0-9]+)*$'
            - '(?:^|/)lsphp[0-9]*(?:\.[0-9]+)*$'
    web_worker_parent_fpm_title:
        ParentCommandLine|startswith: 'php-fpm:'
    shell_child:
        Image|endswith: ['/sh', '/bash', '/dash', '/zsh', '/ksh', '/ash', '/csh', '/tcsh', '/fish', '/busybox']
    recon_verb:
        CommandLine|re|i:
            - '(?:^|[ \t;|&(/''"`{\\])(?:id|whoami|groups)(?:[ \t;|&)<>''"`}]|$)'
            - '(?:^|[ \t;|&(/''"`{\\])(?:uname|hostnamectl|hostname|lsb_release)(?:[ \t;|&)<>''"`}]|$)'
            - '(?:^|[ \t;|&(/''"`{\\])(?:ifconfig|netstat|route|ip|ss|arp)(?:[ \t;|&)<>''"`}]|$)'
    condition: (web_worker_parent_path or web_worker_parent_fastcgi or web_worker_parent_fpm_title) and shell_child and recon_verb
falsepositives:
    - A web application that legitimately shells out to a recon binary, usually hostname.
level: high
tags:
    - attack.discovery
    - attack.execution
    - attack.t1059.004
    - attack.t1033
    - attack.t1082
    - attack.t1016
title: Web Worker Shell Running Host Reconnaissance (Windows)
id: 170d3f2a-5734-4941-99f2-cc8b4a99ebe3
status: experimental
description: An IIS / web worker (w3wp.exe or php-cgi.exe, plus Apache/Tomcat)
    spawning cmd or PowerShell whose command line runs host reconnaissance.
references:
    - https://www.php.net/archive/2026.php#2026-10-21-5
author: BHIS ActiveSOC
date: 2026-10-21
logsource:
    category: process_creation
    product: windows
detection:
    web_worker_parent_path:
        ParentImage|endswith: ['\w3wp.exe', '\php-cgi.exe', '\httpd.exe', '\nginx.exe']
    web_worker_parent_contains:
        ParentImage|contains: ['\apache', '\tomcat']
    shell_child:
        Image|endswith: ['\cmd.exe', '\powershell.exe', '\pwsh.exe']
    recon_verb:
        CommandLine|re|i:
            - '(?:^|[ \t;|&(^"''`\\])(?:whoami|quser)(?:\.exe)?(?:[ \t;|&)<>^"''`]|$)'
            - '(?:^|[ \t;|&(^"''`\\])(?:hostname|systeminfo)(?:\.exe)?(?:[ \t;|&)<>^"''`]|$)'
            - '(?:^|[ \t;|&(^"''`\\])(?:ipconfig|arp)(?:\.exe)?(?:[ \t;|&)<>^"''`]|$)'
            - '(?:^|[ \t;|&(^"''`\\])netstat(?:\.exe)?(?:[ \t;|&)<>^"''`]|$)'
            - '(?:^|[ \t;|&(^"''`\\])net(?:\.exe)?[ \t]+user(?:[ \t;|&)<>^"''`]|$)'
            - '(?:^|[ \t;|&(^"''`\\])net(?:\.exe)?[ \t]+(?:localgroup|group)(?:[ \t;|&)<>^"''`]|$)'
            - '(?:^|[ \t;|&(^"''`\\])tasklist(?:\.exe)?(?:[ \t;|&)<>^"''`]|$)'
            - '(?:^|[ \t;|&(^"''`\\])nltest(?:\.exe)?(?:[ \t;|&)<>^"''`]|$)'
    condition: (web_worker_parent_path or web_worker_parent_contains) and shell_child and recon_verb
falsepositives:
    - A web application that legitimately invokes cmd or PowerShell for a recon command.
level: high
tags:
    - attack.discovery
    - attack.execution
    - attack.persistence
    - attack.t1059.003
    - attack.t1059.001
    - attack.t1505.003
    - attack.t1033
    - attack.t1082
    - attack.t1016
    - attack.t1049
    - attack.t1087.001
    - attack.t1069.001
    - attack.t1069.002
    - attack.t1057

What to do

Patch: 8.5.12, 8.4.27, 8.3.36, 8.2.35. For the end-of-life branches, PHP published source-only backports for 7.4, 8.0, and 8.1 (linked from the advisory) but no official releases, and nothing for branches older than 7.4. Distributions ship fixed packages on their own schedule, so check your distro's package revision, not just the upstream version number, and "it's internal only" doesn't help against a pre-auth RCE.

If you can't patch yet, the advisory's stopgaps reduce exposure but don't close the bug, and they're per-CVE and need server-level config plus a worker restart (ini_set and .user.ini won't take). For 109619, disable upload progress with session.upload_progress.enabled = Off, which leaves normal uploads working; on 8.5 the advisory also lists fatal_error_backtraces = 0. For 109618, set memory_limit = -1, enforced at the admin level so an application can't override it, which removes the controllable out-of-memory failure. At the WAF, blocking the upload-progress field helps but is partial, because the field name is configurable; blocking multipart POSTs on routes that don't take uploads is the stronger move. Keep post_max_size conservative, but don't treat it as a hard stop: a multipart request without a usable declared length can slip past the initial check, and a single part can carry an unrestricted number of headers whose in-memory form is far larger than the wire bytes — so even a request within post_max_size can exhaust memory_limit. Enforce the decoded request size at the frontend rather than trusting the body-size limit alone. And don't reach for RLIMIT_AS or RLIMIT_DATA to cap memory — the advisory warns they make mmap() fail once the limit is hit, which triggers the same vulnerable path. None of these closes the bug; patching is the only complete fix for the October 21 CVEs. One caveat as of this writing: PHP's October 23 advisory describes a separate, lower-severity upload-progress vector (CVE-2026-115560), reachable only with an unusual runtime config, that the October 21 releases do not fix — a full fix is promised in the next regular release — so keep session.upload_progress.enabled = Off even after you patch, until then.

Find the PHP you've forgotten about (appliances, hosting panels, bundled runtimes, a stray php-cgi.exe) and check its version, not just the web servers you already know about.

For detection, watch two things. A successful hit looks like a web worker (apache2, php-fpm, php-cgi.exe, w3wp.exe) spawning a shell or cmd/PowerShell that runs recon, and you read the command off the shell, not the binary, on both platforms. Attempts look like PHP workers crashing and recycling, which is a hunt signal to pair with your web-server error logs, not an alert on its own — and where it surfaces depends on the SAPI: the Apache error log and scoreboard for mod_php, lsphp stderr and worker-restart counts for LiteSpeed, the slow log and emergency-restart counter for PHP-FPM.

The short version

Patch your PHP; that's the real fix. If you're writing detections for it, the durable signal isn't the memory bug, it's what happens after: a web worker spawning a shell for a successful hit, and crashing workers for the attempts. The shell hop is the same on Linux and Windows, and keying on the shell's command line rather than the binary is the lesson worth keeping past this bug.

References