Put Your Unpatchable PHP in a Box

SERIES · NO PATCH COMING
2 / 2 View series

I spent part of this year helping a client whose path to PHP 8.2+ was a real climb — a large monolith, a couple of extensions with no clean 8.x equivalent, and a release calendar that wasn’t clearing for months. While we scoped the actual port, the stopgap question got sharp: how do you keep the old interpreter serving traffic without getting owned in the meantime? One answer sounds unhinged, and I kept circling back to it anyway — wrap the legacy PHP at the web-server level inside a modern, patched PHP runtime. Keep a fixed PHP 8.3 pool out front and let it, not the old engine, be the thing that touches the network; the legacy code runs behind it as a CLI process, and no file upload ever reaches it directly. Does that add latency? Is it even workable?

Yes, it adds latency, and yes, it’s workable. And once you sit with it, it isn’t as crazy as it sounds. It’s CGI, reborn — and for these two CVEs it does something the part-one mitigations don’t: it removes the attack surface instead of guarding it. The price is real, and most shops shouldn’t pay it. But the reasoning is worth walking, because it’s the same reasoning that points at where your port is going anyway.

This is part two of a short series on surviving the October 2026 PHP release (CVE-2026-109618 / CVE-2026-109619, both CVSS 9.2, no fix for 7.x). Part one is the stopgaps you deploy this week. This one is the architecture you reach for when “guard the vector” isn’t enough and you want the vulnerable code to simply never run against untrusted input.

Why it works at all

Here’s the load-bearing fact, the same one that drove every mitigation in part one: both bugs live in the web-SAPI request path, not in PHP generally.

The multipart parser in main/rfc1867.c is registered as the POST body handler for the fpm, apache, and cgi SAPIs. session.upload_progress hangs off that same parse. The stale-state bug feeds on per-request startup and shutdown work — INI diagnostics, session init — that the web SAPIs do on every request.

The CLI SAPI does none of it. Run php script.php and the interpreter does not read an HTTP body, does not populate $_POST or $_FILES from a multipart stream, does not run upload-progress, does not do the web request-init that the second CVE reads leftover state during. CLI hands you argv and STDIN and gets out of the way.

So the idea decomposes into two guarantees:

  1. The only thing touching untrusted HTTP is the patched 8.3 front. It parses the body, handles the upload, manages the session — on an interpreter that has the fix.
  2. The legacy interpreter only ever runs as CLI, fed already-parsed, structured input. It never executes the vulnerable parse because CLI doesn’t have it.

And the second CVE gets closed for free by a detail of the design. The stale-state bug needs leftover state to survive into the next request on the same worker. One CLI process per request is a fresh process that runs once and exits. There is no reused worker carrying state forward. You didn’t block the trigger; you deleted the precondition.

That’s the whole trick. The vulnerable engine is still in your stack — it just stops being the thing on the network.

The catch: you’re rebuilding CGI, and that’s the work

The elegance is in the security story. The cost is in the plumbing, and it’s substantial.

Laravel 6 and CodeIgniter 3 assume a web SAPI. They expect populated superglobals, header() and setcookie() doing something, a session layer, output buffering, and a $_SERVER shaped the way mod_php or fpm shapes it. Run the app under CLI and none of that is there. You need a shim that reconstructs the request environment from whatever the front parsed, runs the app, and translates its output back into an HTTP response the front can emit.

A sketch of the legacy-side shim, reading a job the front serialized to STDIN:

<?php
// legacy-worker shim — runs under the CLI SAPI on the vulnerable PHP.
// assert(php_sapi_name() === 'cli');  // the entire security argument in one line
$job = json_decode(stream_get_contents(STDIN), true);

// Rebuild the request world the framework expects. The front already parsed
// the body on patched 8.3; we receive structured data, never a multipart stream.
$_GET     = $job['query']   ?? [];
$_POST    = $job['post']    ?? [];
$_COOKIE  = $job['cookies'] ?? [];
$_SERVER  = $job['server']  ?? [];          // method, path, headers, remote addr
$_FILES   = $job['files']   ?? [];          // temp paths the front already wrote

ob_start();
require __DIR__ . '/legacy-app/public/index.php';   // boots Laravel/CI as usual
$body = ob_get_clean();

// Hand headers + body back to the front, which turns it into real HTTP.
fwrite(STDOUT, json_encode([
    'status'  => http_response_code(),
    'headers' => headers_list(),
    'body'    => $body,
]));

Getting that reconstruction faithful is the bulk of the effort, and the subtle mismatches — a header the framework reads off $_SERVER that you forgot to forward, a $_FILES shape that’s almost right — are where the app breaks in ways that look like application bugs.

One trap to call out, because it’s the obvious shortcut and it’s wrong: do not reach for php-cgi to skip the shim. php-cgi is a web SAPI. Hand it the raw request body on STDIN and it will parse multipart/form-data through rfc1867.c — the exact code you’re trying to never run. The isolation only holds with true CLI plus a disciplined shim that never feeds the legacy side a multipart stream. The moment a raw body reaches a web SAPI on the old interpreter, the box is open.

The latency question

Firing a process per request sounds expensive, and people’s instinct is that fork/exec is the cost. It isn’t — spawning the interpreter is single-digit milliseconds. The tax is framework boot: cold-booting Laravel 6 per request — autoloader, config, service providers, no warm opcache — is tens of milliseconds, and it gets worse under load because every request pays it. CodeIgniter 3 is lighter, but it’s the same shape of cost.

Two ways to bring it down, in increasing order of payoff and effort:

Warm the opcode cache across process starts. Point opcache.file_cache at a persistent directory so each CLI invocation skips recompilation even though the in-memory cache dies with the process. Cheap, and it takes the edge off a fork-per-request model.

; php.ini on the legacy CLI runtime
opcache.enable_cli=1
opcache.file_cache=/var/cache/opcache
opcache.file_cache_only=1    ; CLI has no shared memory segment to reuse

Stop forking at all: a persistent legacy worker pool. Instead of one process per request, run a handful of long-lived CLI processes that each sit in a loop, read a job off a socket, run it, reset, and wait for the next. That’s a homegrown Octane / RoadRunner for the legacy runtime, and it removes the boot tax entirely because the framework is booted once per worker, not once per request.

<?php
// persistent legacy worker — still the CLI SAPI, so still none of the vulnerable path.
$sock = stream_socket_server("unix:///run/legacy/worker.sock", $errno, $errstr);
while ($conn = stream_socket_accept($sock, -1)) {
    $job = json_decode(fgets($conn), true);

    reset_request_state();          // $_GET/$_POST/$_FILES/$_SERVER, session, output buffers
    $resp = handle($job);           // run the booted app against this job
    fwrite($conn, json_encode($resp));
    fclose($conn);
}

The nice property: a persistent worker is still the CLI SAPI. It still never runs rfc1867.c, still never runs the web request-init the second CVE reads stale state during. You get FrankenPHP-style throughput without putting the vulnerable parse back on the hot path. The thing you now own is reset_request_state() — because a long-lived process does carry state between jobs, so you reset it yourself, deliberately, between every one.

The residual risk I’m not going to paper over

Containment narrows the surface dramatically. It does not make the interpreter safe, and two honest caveats keep it that way.

First, the OOM-corruption class behind CVE-2026-109618 is broader than multipart. The attacker-reachable trigger we know about is the multipart parse, and that’s gone here — but a legacy CLI process you can still drive to exhaust memory on large inputs isn’t provably zero-risk. So the part-one posture still applies inside the box: run the legacy CLI with memory_limit=-1 and a hard cgroup cap, so exhaustion is reaped by the kernel above the process rather than hitting PHP’s longjmp-on-OOM bailout over half-built state. (Part one, §3 has the container math.)

Second, the moment you move from fork-per-request to persistent workers for the latency win, you reintroduce the cross-job state that the second CVE’s whole family cares about. The specific web-SAPI vectors — INI diagnostics, upload-progress — still don’t run under CLI, so you’re not re-exposing that bug. But “a long-lived interpreter that reuses memory across untrusted requests” is a posture that wants disciplined reset regardless of which CVE is in the news this week. Reset defensively.

So is it worth it?

For most shops reading part one: no, and that’s fine. Blocking multipart/form-data at the edge and turning off session.upload_progress is roughly ninety percent of this protection for ten percent of the effort, and it ships this afternoon. Start there.

Containment earns its keep in three situations:

  • You can’t reliably block multipart at the edge — a CDN or appliance you don’t control, routes that legitimately take uploads everywhere, a WAF you can’t tune. If you can’t put a clean barrier in front of the parse, moving the parse to a patched interpreter is the barrier.
  • You need hard defense-in-depth, not a stopgap — regulated, high-value, or internet-facing with a target on it, and “reduces exposure to specific paths” isn’t a sentence you can take to your risk owner.
  • You were going to build a persistent-worker runtime anyway. If your port lands on Octane, RoadRunner, or FrankenPHP — and a lot of them now do — then the worker-pool front is work you’d do regardless. Build it against a patched 8.3 runtime now, let the legacy app ride inside it as a stepping stone, and the containment is a free side effect of the architecture you were already moving toward.

That last one is the real point. The “insane” box isn’t a dead end; it’s the same shape as the destination. A patched front terminating HTTP, a pool of long-lived workers running your application code, a clean serialized boundary between them — that’s not a hack, that’s where a modern PHP deployment already lives. The only weird part of the stopgap is that the worker is running an interpreter you’re racing to retire.

See it run

The php-cve-lab repo now has a fourth demo that stands the pattern up: a patched PHP 8.3 fpm front, a legacy PHP 7.4 CLI worker in both fork-per-request and persistent modes, and a check that the legacy side reports php_sapi_name() === "cli" and never parses a multipart body — with the latency delta between the two modes printed so you can see what the persistent pool buys you.

Same charter as the rest of that repo: it demonstrates the isolation boundary, not the vulnerability. There’s no exploit code in it, and there never will be.


If a persistent-worker runtime is where your PHP is headed anyway, the box and the port are the same project. That’s the Modernization Roadmap: a port strategy and a target architecture you can hand to your team, scoped so you ship it without a weekend of outages.

I help teams modernize legacy systems with AI-assisted development guardrails that keep the agents productive and the production safe. fitzpatricksoftware.com