DEV Community

StarkMan
StarkMan

Posted on

Half-Initialized Workers: Verifying the PHP2Shell Fix When the Failure Path Is the Vulnerability

Half-Initialized Workers: Verifying the PHP2Shell Fix When the Failure Path Is the Vulnerability

Most patch cycles treat the upgrade as the finish line. For CVE-2026-109618 and CVE-2026-109619, the pair known as php2shell, the upgrade is the halfway point. The bug lives in a path PHP only takes when something goes wrong, and the state it leaves behind can outlive the version string that says you are patched.

What the flaw actually is

Both CVEs sit in the request shutdown machinery. When PHP hits an out-of-memory condition during request processing, the engine calls longjmp() to unwind instead of returning normally. The cleanup code that follows then walks structures that were never fully initialized, because the request died mid-construction. CVE-2026-109618 covers a request whose half-built memory is later consumed by shutdown cleanup. CVE-2026-109619 covers worker global state that still points at memory already freed.

The practical consequence is remote code execution before application code runs. No plugin, no theme, no framework handler is involved. Public analysis names two reachable surfaces: session.upload_progress and fatal-error backtraces. Both can be driven by a request that makes PHP allocate until it fails.

Affected deployment models are broad. mod_php in its default configuration is in scope, as are php-fpm, php-cgi, FrankenPHP and LiteSpeed. Fixed releases are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Every branch at or below 8.1 is end-of-life and will not receive a fix.

Why verification is its own step

A worker process that already carries corrupted global state does not become healthy because the package on disk changed.

If you upgrade in place and reload configuration without recycling the process pool, old workers keep serving requests until they exit on their own schedule. A version check passes. The exposure does not change.

This is not a hypothetical. Unit 42 and Cortex Xpanse counted more than 22.6 million exposed instances. Separate Shodan-based measurements found roughly 884,000 hosts on 8.1 or older, with about 1.5 million on any affected version. Exploit code has been observed, and at least one European government agency took a public site offline rather than carry the risk.

The verification question is therefore not what php -v reports. It is which processes are serving traffic, when they started, and whether that start time predates the upgrade.

A verification sequence that produces evidence

Work in this order and keep the output, so that you can later say which process ran which binary at which time.

  1. Record the inventory before touching anything: SAPI in use per host, PHP version, and the start time of every worker.
   php -v
   php -i | grep -i 'Server API'
   ps -eo pid,lstart,cmd | grep -E 'php-fpm|php-cgi|frankenphp'
Enter fullscreen mode Exit fullscreen mode
  1. Upgrade through your normal package channel. Then confirm the binary on disk and the binary the service manager will launch are the same file. Symlinked and side-by-side installs are where version checks mislead.

  2. Recycle the pool rather than reloading it. A graceful reload asks workers to finish their in-flight request; for a memory-corruption bug you want those processes gone. Confirm by start time, not by the absence of errors in the log.

  3. Re-run step 1 and compare. Every worker start time must be later than the upgrade timestamp.

  4. Only then review the compensating controls below, which change what an attacker can reach while a worker is mid-failure.

Compensating controls, ranked by what they block

  • session.upload_progress.enabled=0 removes one documented reachable surface outright, and costs nothing if you do not track upload progress.
  • fatal_error_backtraces=0 removes the other documented surface from error output.
  • memory_limit=-1 appears in vendor guidance as a way to avoid the OOM-triggered unwind. Treat it as a deliberate trade: it converts a memory-safety exposure into an unbounded-resource exposure, and is only defensible behind a hard container limit you control.
  • A small post_max_size reduces how much one request can allocate, which raises the cost of forcing the failure path.
  • session.use_strict_mode=1 is general hardening rather than a specific fix. It limits what an attacker gains from session-handling primitives during a corrupt request.

One thing to avoid: substituting RLIMIT_AS or RLIMIT_DATA for memory_limit. Vendor guidance is explicit that address-space limits do not reproduce the engine's own OOM handling, so you do not get the protection you think you are buying.

Where the existing coverage stopped

Much of what was published before the out-of-cycle release on 21 October 2026 was preparation for a disclosure that had not yet happened: which hosts to enumerate, which configurations to suspect. That material still helps with inventory. It does not answer the question that matters now, which is whether the workers running today started before or after the fix landed.

Patch compliance and exploit resistance separated for a few hours on a great many hosts. Process start times against upgrade timestamps are the only honest way to tell which side of that line you are on. Version strings will not do it.

References

Top comments (0)