.png)

php2shell (CVE-2026-109618 / CVE-2026-109619) is a memory-corruption RCE in PHP, and the next one like it is probably a few weeks away. You can't block the exploit trigger at the host level, but you can make sure a compromised PHP worker can't start a shell, load a payload or touch your cloud credentials. That works for this CVE and for the next PHP one, because it doesn't depend on how the attacker got in. And it matters now: exploitation reportedly started before the details were public.
Yesterday calif.io published php2shell, and the last 24 hours of my feed have been the same post over and over: "critical PHP vulnerability, patch immediately."
PHP runs an estimated two thirds of the websites on the internet. So yes, it's serious. But "patch immediately" is advice for a world where this happens once every three or four years. We don't live there anymore. Bugs like this used to be rare, and with AI-assisted bug hunting they now show up much more often, especially in open source code. No company has the people to run an emergency patch cycle every few weeks. Tomorrow it's another PHP bug, the day after it's Java, then Node.js, and the media will tell us again to drop everything.
And the patch race is worse than it looks. Cloudflare reported that exploitation of php2shell started before the details were public. So for some of you the question was never "can we patch fast enough?" By the time the advisory landed, the window had already been open. If the first sign of a bug is someone already using it, the only thing that helps on day zero is limiting what a compromised worker can do.
A WAF helps here, and WAF vendors pushed rules within hours. That buys you time to patch on a normal schedule, but it's one layer in front of the app. This post is about the other case: you don't have a WAF in front of this workload, or you don't want it to be your only line.
The write-up describes a memory-corruption primitive inside a PHP worker. The trigger is a handful of normal-looking multipart requests. The public write-up has no payloads or IOCs, so I'm not going to pretend I can show you the exploit in a log.
What I can tell you is what a PHP worker with attacker-controlled memory can do next. It's a short list:
HTTP request (multipart, looks normal)
|
v
memory corruption inside php-fpm / apache2 worker
|
+--> system()/exec()/popen() --> /bin/sh -c "curl ... | sh"
+--> putenv(LD_PRELOAD) + mail() --> native code, bypasses disable_functions
+--> memfd + exec --> payload with no file on disk
+--> open /proc/self/mem --> patch the interpreter at runtime
+--> file_put_contents --> webshell in the docroot
+--> http://169.254.169.254 --> instance role credentials --> your cloud account
Every one of those is a normal Linux operation: an exec, an open, a connect. And a PHP web worker has almost no legitimate reason to do most of them.
That's the whole idea. We stop caring about the vulnerability and start caring about what the process is allowed to do.

The Stream.Security runtime agent (eBPF-based, runs on Linux and Windows hosts and Kubernetes nodes) already reports process, file and syscall events. Detection rules on top of those are a few lines of JSON.
The core rule is "a PHP web worker started a shell":
{
"name": "Shell Spawned by PHP Web Worker",
"severity": "high",
"custom_config": {"log_type": "process"},
"condition": {
"main_filter": {"operand": "and", "filters": [
{"field": "event_type", "match_type": "is", "value": "process_exec"},
{"field": "parent_process_name", "match_type": "regex",
"value": "^(php-fpm[0-9.]*|php-cgi[0-9.]*|lsphp[0-9.]*|frankenphp|apache2|httpd)$"},
{"field": "process_name", "match_type": "in", "value": "sh,bash,dash,ash,zsh,ksh,busybox"}
]}
}
}This works because PHP's system(), exec(), shell_exec(), passthru(), popen() and string-form proc_open() all go through /bin/sh -c. So the PHP worker is the direct parent of the shell, and the full command sits in the shell's arguments. One hit means PHP code ran a shell command.
A few things we learned writing these rules, because they're the kind of detail that decides whether a rule is useful or just noisy:
mail() will page you. PHP's mail() runs /bin/sh -c "/usr/sbin/sendmail -t -i". Exclude it with an anchored regex (^...$), not a contains, or an attacker just appends sendmail to the payload.LD_PRELOAD hides behind that exclusion. The classic putenv("LD_PRELOAD=...") plus mail() trick loads the attacker's library inside the sendmail shell. So you need a separate rule on the LD_PRELOAD environment of anything a PHP worker starts, otherwise your own mail exclusion becomes the blind spot.proc_open() and pcntl_exec() skip the shell. They exec the target directly, so a shell-only rule never fires. A second rule watches for curl, wget, nc, socat, interpreters and recon tools with a PHP worker as the direct parent./proc/self/fd/, /dev/fd/ or memfd: by a PHP worker is a critical alert from day one. Watching the memfd_create syscall instead is noisy, because opcache on newer PHP calls it at startup..php, .htaccess or .user.ini catches webshell drops, and also every WordPress plugin update and template cache. Expect to allowlist cache directories per app before this rule means anything.We also have a rule for a PHP worker opening /proc/<pid>/mem for write, which is a known way to patch the interpreter and get around disable_functions. If that one fires, treat it as an incident.
Detect using API logs:


Detection tells you at 3 a.m. that something happened. The newer part of the agent is prevention: you describe what a workload must not do, and the kernel enforces it.
You don't have to write the rules below by hand. You tell StreamForce, our built-in agents, what you want to stop ("PHP workloads must not run shells or reach instance metadata"), and it builds the rule. While it does, it takes into account everything Stream.Security already knows about your infrastructure: which hosts and pods run PHP, which processes they actually start, what they connect to. The result is a rule and a set of exclusions fitted to your environment, not a generic template. The JSON below is what that output looks like, so you can see exactly what will be enforced. A rule makes the one offending system call fail with EPERM. The PHP worker keeps running; with the kill action only the offending child process dies. If the app never does the blocked thing, there is no functional impact.
Two things to know before the examples:
{
"name": "PHP RCE: block fileless exec in PHP workloads",
"hook": "exec",
"action": "deny",
"match": { "fileless": true },
"scope": { "images": ["your-registry/php-app"], "image_match": "name" }
}Any exec of a binary with no link on disk (memfd, or a file deleted before exec) is denied. PHP apps don't do this.
This is the rule that matters most, and the one most likely to touch legitimate application behavior, so read the next section before enabling it.
{
"name": "PHP RCE: PHP workers must not spawn shells or network tools",
"hook": "exec",
"action": "deny",
"match": {
"ancestor_binaries": [
"/usr/local/sbin/php-fpm", "/usr/sbin/php-fpm8.3", "/usr/sbin/apache2", "/usr/sbin/httpd"
],
"child_binaries": [
"/bin/sh", "/bin/bash", "/usr/bin/curl", "/usr/bin/wget",
"/usr/bin/ncat", "/usr/bin/socat", "/usr/bin/python3", "/usr/bin/perl", "/usr/bin/base64"
]
},
"scope": { "images": ["your-registry/php-app"], "image_match": "name" }
}(List every php-fpm and Apache path that exists in your image; matching uses the path as invoked.)
Children are matched by the path as invoked and by the executable it resolves to inside the container. /bin/sh also catches the dash or busybox behind the symlink, and ln -s /bin/sh ./x doesn't help the attacker.
Because it's an ancestor match, it covers the worker and everything it starts. And because the shell is blocked, the LD_PRELOAD + mail() trick dies too: its library loads inside the sh that mail() spawns, and that sh never starts.
Could it affect legitimate behavior? A reader can reasonably guess that a generic rule like this will hit some parts of a PHP application. The usual suspects:
That is what a rule written without knowing your environment looks like. Rules written by StreamForce aren't written that way (more on that right below). If the app in your environment does send mail through sendmail, StreamForce finds that while it builds the rule and adds it to the exclusions, so the mail subsystem keeps working and a shell with any other command line is still denied. The same goes for the other rows: they are only a problem if they actually exist in your environment.
If you want a hard, no-shell version, there is also a lower-impact variant of the rule without the shell paths. It still blocks downloaders, reverse-shell tools and payload decoding, but lets sh -c through. That's why there's a second rule:
{
"name": "PHP RCE: kill reverse-shell command lines in PHP workloads",
"hook": "exec",
"action": "kill",
"match": { "args_contains": ["/dev/tcp/", "base64 -d", "mkfifo"] },
"scope": { "images": ["your-registry/php-app"], "image_match": "name" }
}The command line can only be read after the exec happened, so this one kills the new process (the shell). The PHP worker is untouched. It catches the bash /dev/tcp reverse shell that the lower-impact variant of Rule B lets through.
{
"name": "PHP RCE: PHP workers must not reach instance metadata",
"hook": "connect",
"action": "deny",
"match": {
"dst_cidrs": ["169.254.169.254/32"],
"ports": [80],
"ancestor_binaries": ["/usr/local/sbin/php-fpm", "/usr/sbin/apache2"]
},
"scope": { "images": ["your-registry/php-app"], "image_match": "name" }
}This is the one that protects your cloud account, not just the host. An RCE in a web worker on an EC2 instance or an EKS node without IRSA is one curl away from the instance role's credentials. A generic version of this rule would also hit apps whose SDK gets credentials from the instance role (S3 uploads on EC2, for example). Which apps those are is something StreamForce can see from the traffic in your environment, and audit mode confirms it before anything is enforced.
sh as the ancestorThe official php:*-fpm and php:*-apache images start through docker-php-entrypoint, a /bin/sh script that ends in exec "$@". The ancestor mark survives an exec in place, so php-fpm itself inherits the sh mark.
Now picture a rule like "nothing descended from a shell may connect out." In those images that denies every connection the app makes: database, Redis, external APIs. Always use the PHP or Apache binary as the ancestor, never the shell. We found this one the way you'd expect.
Upgrade to the October 2026 PHP security release, on your normal schedule instead of a fire drill. Until then, the write-up's mitigations are session.upload_progress.enabled=0 for PHP-FPM and memory_limit=-1. Be careful with the second one: it removes PHP's per-request memory cap, so in Kubernetes a hostile request can grow until the pod gets OOM-killed. That trades this exploit for a denial-of-service risk. Disabling upload progress has no such downside if you don't use it.
/proc/self/mem block right away, they should be clean on any PHP app.Setup is short. Installing the agent takes about a minute, and writing the first rules takes about another. Want to see it in action? Try it on a staging PHP pod, run system('id'); from a test page, and watch the audit log.
Stream is the AI-native platform built to fight AI-enabled attacks. It autonomously prevents, detects, hunts, and remediates exposures and threats across production at machine speed - driving risk toward zero. It replaces the fragmented stack of scanners, runtime agents, exposure tools and playbooks with one live model of production.Defending Production needs a new approach: Stream is the only Autonomous Production Defense Platform that works across your entire production estate. It runs on a patented CloudTwin®, a high-fidelity security data harmonization layer that models Cloud, SaaS, identity, runtime, AI, network, perimeter, on-prem, security controls, and the behavior running on top of them into one live model of production: real-time, fully correlated, continuously updating. Not a snapshot. And it does not stop at boundaries - the boundaries that fragment every other tool are the same boundaries an attacker moves across. Inside CloudTwin they are one system.