PHP2Shell: Critical PHP Flaws Explained and What Organizations Must Do to Mitigate Risk - Safe Security

PHP2Shell: Critical PHP Flaws Explained and What Organizations Must Do to Mitigate Risk

Oct 22, 2026 16 minute read

1. Introduction

This article examines two related memory-safety vulnerabilities in the PHP engine, CVE-2026-109618 and CVE-2026-109619. In our reproduced mod_php chain, multipart HTTP requests combine an out-of-memory trigger with later stale-state consumption to achieve unauthenticated remote code execution (RCE) before the target application code runs, under the conditions detailed in Section 8. The chain succeeded against a digest-pinned official PHP 8.4.26 Apache image and stopped at the heap-leak stage against our PHP 8.4.27 control; this differential is specific to those builds and lab settings. The PHP project rates both CVEs CVSS v4.0 9.2 Critical and identifies process-memory disclosure as a confirmed impact; the reports credit Wiz Research, OpenAI Codex Security, and Calif.

2. PHP Description

PHP is a widely used server-side scripting language for building websites and web applications. WordPress and Drupal are examples. The interpreter normally executes inside the web server process itself (mod_php) or in worker processes behind FastCGI (PHP-FPM).

With mod_php and a prefork MPM, each Apache worker is a long-lived OS process that serves one request at a time and stays alive for the next. Anything the interpreter leaves dangling in process memory survives into subsequent requests, including requests handled by completely different PHP applications on the same server. Separately, PHP enforces memory_limit per request, so an attacker who can drive a request over that limit forces the engine into its out-of-memory failure path without any application involvement. PHP’s defaults are memory_limit=128M and post_max_size=8M.

Our lab used memory_limit=8M with post_max_size=64M. Two routes make OOM reachable over HTTP: the lab configuration sets post_max_size above memory_limit, and the PHP advisory documents multipart paths that can exhaust memory_limit even within post_max_size. These include requests without a usable declared length and multipart parts with many headers. For the confirmed mod_php startup path, application code is not required.

3. Vulnerability Severity

Field Value
CVE IDs CVE-2026-109618, CVE-2026-109619
Severity CRITICAL: CVSS v4.0 9.2 (both, per the PHP project)
CVSS Vector CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N; network-exploitable, no privileges or interaction, but high attack complexity and present attack conditions (the advisory provides the vector, not a metric-by-metric rationale; our lab required build-specific heap calibration and the tested mod_php configuration).
Advisories GHSA-w8hw-w9fx-j79w (109618: RCE via OOM corruption), GHSA-6wmf-4rv5-p3rp (109619: RCE via stale global state)

4. Scope of Impact

Software Affected Range Fixed Version
PHP 8.5.x < 8.5.12 8.5.12
PHP 8.4.x < 8.4.27 8.4.27
PHP 8.3.x < 8.3.36 8.3.36
PHP 8.2.x < 8.2.35 8.2.35
PHP 7.4 / 8.0 / 8.1 (EOL) Pre-backport releases; verify vendor package Official source backport branches published; vendor release varies

Wiz’s code-history analysis traces the relevant code to PHP 5.4; this is a code-ancestry finding, not a release-by-release test of every version. The PHP project published minimal source backports for PHP 7.4, 8.0, and 8.1. Distributors may also ship fixes for other branches. Verify the installed package with its vendor, and migrate or apply a supported fix where an end-of-life deployment remains unpatched.

5. Where is the vulnerability present?

The PHP advisories describe two related but distinct failure patterns: CVE-2026-109618 covers invalid request state exposed by out-of-memory handling; CVE-2026-109619 covers stale worker-global state that refers to freed request memory. Some exploit chains cross these patterns, so individual primitives should not be treated as exclusive to one identifier. The affected code spans the engine request lifecycle, including Zend/zend_alloc.c, Zend/zend_hash.c, main/SAPI.c, main/php_variables.c, and ext/session/session.c. The PHP patches address the failure paths described in the advisories; this article does not assess every related candidate or adjacent memory-safety issue.

5.1 The OOM trigger (CVE-2026-109618)

When an allocation exceeds memory_limit, PHP raises a fatal error and unwinds the request with longjmp(). If this happens during request startup, while request data structures are only partially built, the partially-initialized state is left behind, and the subsequent cleanup treats it as valid. The attacker controls the failure point with ordinary HTTP:

Figure 1. A crafted POST triggers an out-of-memory fatal error on the PHP 8.4.26 target.

The reachable failure sites include the multipart parser’s variable and file-registration paths (main/rfc1867.c → main/php_variables.c → Zend/zend_hash.c). One canonical example: _zend_hash_str_add_or_update_i() in Zend/zend_hash.c: increments the table’s element count before allocating the bucket’s key:

ZEND_HASH_IF_FULL_DO_RESIZE(ht);    /* may also fail (OOM); harmless */
add_to_hash:
    idx = ht->nNumUsed++;           /* bucket counted as used ... */
    ht->nNumOfElements++;
    p = ht->arData + idx;           /* ... but its contents are ... */
    p->key = key = zend_string_init(str, len, ...);   /* ← OOM here aborts */
    p->h = ZSTR_H(key) = h;
    ZVAL_COPY_VALUE(&p->val, pData);/* ... leaving key/val uninitialized */

An OOM at the marked line aborts the request with the bucket visible to zend_array_destroy() during request teardown: teardown then reads uninitialized heap memory as a zend_string* and a zval. The advisory identifies the specific routes an attacker has to reach the limit despite max_input_vars=1000 and max_input_nesting_level=64 defaults: multipart requests without a usable declared length, unrestricted headers within a single part, and empty file controls (filename=””), which bypass max_file_uploads while still creating registration metadata; deeply nested field names multiply the array work each name performs.

5.2 The stale-state consumer (CVE-2026-109619)

The session.upload_progress feature initializes PHP session state during multipart parsing, before PG(modules_activated) is set. If the request then OOM-aborts, the multipart END callback is skipped and the session module’s request-shutdown is gated off, but the request heap is freed while process-global session pointers remain:

Figure 2. A two-request session-upload-progress probe against the PHP 8.4.26 target.

Observed in this run, Request A returned HTTP 200 after the OOM fatal; Request B returned no response. Apache logged zend_mm_heap corrupted and a child exiting with SIGABRT; a follow-up request returned HTTP 200. This confirms a worker abort after the consumer request, not the specific invalid-memory access. The advisory and source analysis support the stale-pointer interpretation; this probe did not instrument the dereference or demonstrate code execution. The required condition is that the same mod_php worker handles both requests; our Request B used the upload-progress form that exercises the cleanup path. The relevant consumer flow is shown below:

/* multipart END callback (runs during POST parsing, before modules_activated=1) */
case MULTIPART_EVENT_END:
    if (Z_TYPE(progress->sid) && progress->key.s) {
        if (PS(rfc1867_cleanup)) {
            php_session_rfc1867_cleanup(progress);   /* → php_session_initialize()
                                                        reads dangling PS(id),
                                                        destroys PS(http_session_vars) */
        }
        php_rshutdown_session_globals();             /* gated by activation state */
    }

Source analysis indicates that php_session_track_init() calls zval_ptr_dtor(PS(http_session_vars)) on the stale pointer. The probe records the worker crash but does not instrument this dereference. The published exploit’s GDB analysis describes how attacker-controlled reclaimed memory can be interpreted as a forged HashTable whose pDestructor pointer reaches zif_system. That exploitation detail is source and exploit analysis, not a direct observation from the two-request probe.

5.3 How the patch addresses both failure patterns

The 8.4.26-to-8.4.27 source diff spans 252 files with 12,830 added and 9,151 removed lines; security-relevant changes concentrate in Zend/zend_alloc.c/h, main/, ext/session/, and ext/standard/ (sibling branches carry the same fix):

  • Replace the longjmp() OOM exit with a delayed soft-OOM state machine (Zend/zend_alloc.c/h): allocation failure during early request state is recorded and handled at a consistent point (zend_handle_delayed_oom()), avoiding the vulnerable mid-operation unwind that exposed partially initialized state.
  • Add a hard memory limit (new INI memory_hard_limit_percent: the hard threshold is 200% of memory_limit below 1 GiB, and memory_limit plus a 1 GiB buffer at or above that value). When exceeded, _exit() terminates the worker, preventing that process from handling another request with the affected state.
  • Reset all early-request state unconditionally (php_basic_early_rinit() in ext/standard/basic_functions.c, called after zend_activate() before sapi_activate(); php_url_scanner_early_rinit(); PG(http_globals) set to ZVAL_UNDEF; stale EG fields cleared), removing the known failure path for cross-request state leakage.

6. Risk

  • Unauthenticated, pre-application RCE on mod_php. The attacker needs network reachability but no credentials or victim interaction. The reproduced chain requires a finite PHP memory_limit, session.upload_progress.enabled=On, display_errors=On, and an Apache mod_php prefork worker that handles both the producer and consumer requests. A finite memory limit is needed for this path; memory_limit=-1 removes the primary per-request trigger but does not remove all allocation-failure paths. session.upload_progress.enabled is enabled by default. PHP’s built-in display_errors default is On, though production templates set it Off. The specific full-RCE chain tested here required it On; a documented Off variant reaches the heap-leak stage, and PHP says disabling display_errors or log_errors does not protect against CVE-2026-109619. The official PHP Apache image uses mod_php with prefork, a layout also common in classic LAMP deployments. The victim application need not itself use sessions, uploads, or other PHP features; preconditions differ for other paths.
  • Memory disclosure without code execution. The PHP advisories classify process-memory disclosure as a confirmed impact. The PS(id) path described in the exploit analysis is one disclosure path. In our reproduction, the leak stages returned chunk and libphp base addresses in PHPSESSID response cookies; we did not observe credentials or other application secrets. The responses remained valid, but an anomalous cookie alone is a lead for investigation, not proof of exploitation. The demonstrated command ran as the web-server user (www-data), so it had the permissions granted to the PHP worker. In our tested official PHP Apache image, request handling ran as www-data. Other deployments may use a different account. The command executed inside the container.
  • Shared-hosting and multi-tenant impact. Because the state is process-global, the later request need not target the application that handled the first request; it must reach the same worker and invoke an affected consumer path. Other applications served by that worker may therefore share the exposure. Attack traffic was reported pre-patch. Cloudflare reported observing attack traffic targeting internet-facing hosts on the morning of release day; Unit 42 reported proof-of-concept activity. The first establishes attempted exploitation; the second establishes exploit-code availability. Neither establishes confirmed successful compromise. Given the reported attack traffic and publicly available exploit code, internet-exposed mod_php deployments should be prioritized for patching.
  • PHP-FPM has path-dependent exposure. The PHP project has not demonstrated a complete exploit for the OOM-at-startup path: FPM exits the child when request startup fails, preventing the cross-request consumer.

Separately, the CVE-2026-109619 advisory validates a session-lifecycle RCE against FPM when an application performs suitable session operations. Treat unpatched FPM as vulnerable to that conditional path.

7. Mitigation

Apply in order:

  1. Patch or upgrade immediately: PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12. This is the only complete fix. PHP upstream no longer issues regular releases for EOL branches (7.4/8.0/8.1); use a vendor-supported backport or the official minimal source backport, or migrate to a supported branch.
  2. Restart every PHP worker process after patching (Apache full restart for mod_php; FPM pool restart). The stale state lives in running workers; patching binaries without restarting leaves exploited workers running.
  3. Stopgap configuration (until patching is possible; verify with php -i as the web-server SAPI, not CLI):
  • display_errors=Off does not protect against CVE-2026-109619, according to the PHP project. The reproduced chain used display_errors=On; disabling it was not tested as a way to stop that chain.
  • session.upload_progress.enabled=Off blocks the confirmed early upload-progress path; it does not block every stale-state path and is not a complete fix.
  • memory_limit=-1 via php_admin_value removes the predictable PHP per-request memory-limit trigger, but an underlying allocation failure, such as an mmap() failure, can still reach the vulnerable path. Do not use RLIMIT_AS/RLIMIT_DATA to cap PHP because these limits can cause the vulnerable allocation failure. If using a system memory boundary, a cgroup v2 memory.max can help contain availability impact, but the PHP advisory cautions that cgroups are not a complete mitigation and a cgroup kill can terminate the master and its workers.
  • fatal_error_backtraces=0 (where supported; absent from the 8.4.x sources checked and listed for 8.5.x; restart workers): blocks the confirmed fatal-backtrace path only.
  • On PHP-FPM, lower pm.max_requests to recycle workers; the PHP project suggests pm.max_requests=1 if performance permits. This reduces worker reuse but is not a complete fix.

      4. Edge controls: enforce decoded request-size limits at the frontend and bound multipart part count, headers per part, empty file controls, input variable count, and nesting depth. Ensure limits also cover requests without a usable Content-Length. These controls reduce exposure but do not replace patching.

      5. Hunt for exploitation:

  • PHP fatal errors Allowed memory size of … exhausted with in Unknown on line 0, especially pre-application (no application log line for the same request).
  • Multipart POSTs with thousands of parts, empty filename=”” controls with deeply nested name= values, or bodies sent without a usable Content-Length.
  • mod_php worker crashes (exit signal Abort/Segmentation fault, zend_mm_heap corrupted) followed by requests from the same client; child SIGABRT/SIGSEGV in Apache logs.
  • Requests with PHP_SESSION_UPLOAD_PROGRESS fields paired with PHPSESSID values of unexpected length or content, across an A/B request pattern from the same source.

IOC summary:

Indicator Notes
Multipart POST with ~1,000+ form fields or empty file controls, deep name nesting OOM priming for 109618; part counts far above application norms
Multipart POST with absent or unusable Content-Length, or chunked framing May bypass the initial post_max_size check in the affected parsing path
OOM fatal with in Unknown on line 0, pre-application The trigger’s signature in PHP error logs
zend_mm_heap corrupted / worker SIGABRT/SIGSEGV in server logs, same client IP recurring Cross-request UAF consumption
A/B request pairs from one source: A = file part + PHP_SESSION_UPLOAD_PROGRESS, B = same session id, no file part 109619 producer/consumer shape
Unexpectedly long or malformed PHPSESSID response cookies after multipart POSTs Possible memory-disclosure output; correlate with other indicators

8. Exploit Implementation

Attack Scenario

The vulnerable target was the official php:8.4.26-apache image on linux/amd64, pinned by digest sha256:60111400ff77bdc44fa79b4f072afe49a42abba71e0846ad496be4761dc9c11a (Apache 2.4.69, libphp.so sha256 51663cf883b1223436d4c99f31dd0225735ea5573665e4242621eee7f4ee679c). The recorded runs used the sibling official tag php:8.4.26-apache-bookworm, digest sha256:52f04cd1e398edd0f80afa8330ce73bce61931ddbd0d5bdd919c6f914d02bcf8 (Apache 2.4.68, libphp.so sha256 ff3ea77fb119541f6d10ddf0fa7e9e3fd6a3043e049a536b530dbbec35db68d6). Both tags are published and pair the same PHP release with different Apache builds; we ran the complete chain on both, and the pinned image succeeded with the exploit’s automatic offset derivation. The libphp symbol offsets differ between the two builds, so do not carry offsets from one build to the other or from older notes; the exploit reads them from the running container.

The lab used Apache mod_php with a single prefork worker and host-default ASLR.

The lab set memory_limit=8M, post_max_size=64M, max_input_vars=1000, session.upload_progress.enabled=On, and display_errors=On. PHP defaults are memory_limit=128M and post_max_size=8M. The image ships no php.ini, so it uses PHP’s built-in display_errors=On default. The webroot is the minimal response shown below.

<?php echo “OK\n”; ?>

The PHP 8.4.27 control used the same lab settings on a digest-pinned Debian 12 base image. It was built from the official PHP source tarball with its checksum verified; Debian package versions were resolved at build time and were not pinned.

Prerequisites:

  • Docker (vulnerable php:8.4.26-apache and patched php:8.4.27-apache containers)
  • Python 3.9+
  • Git, to retrieve the pinned exploit source used in the reproduction below.
  • No credentials or pre-positioned target files were needed. The reproduction wrote proof output to the mounted out directory and did not call session_start().

Exploitation

The following steps reproduce the vulnerable-side chain and compare it with the recorded PHP 8.4.27 control.

Payload anatomy: every request is an ordinary multipart/form-data POST:

Stage Request contents Purpose
0: worker rotation ~1,001 small fields + 12 MB field (OOM) Rotates prefork workers to establish the heap layout expected by this build-specific chain
1: chunk-base leak ~1,000 small fields + OOM + a forged zend_string Reclaims freed session state so the next request’s php_url_encode() reads out of bounds into Set-Cookie (leaks a chunk pointer)
2: libphp-base leak Similar, with a 56-byte reclaim block Reads ZVAL_PTR_DTOR’s address through the same out-of-bounds channel: yields libphp.so base
3: RCE Upload-progress + PS(id) + forged reference/HashTable fields Reclaims the dangling PS(http_session_vars) reference; forged pDestructor = zif_system; a session-initializing request executes it
  1. Clone the pinned exploit source and pull the exact vulnerable image.

git clone https://github.com/9bie/php2shell-bypass-aslr-exp
cd php2shell-bypass-aslr-exp
git checkout 4586f5cc120c82fd468b17469202bac629810ad9
IMAGE='php@sha256:60111400ff77bdc44fa79b4f072afe49a42abba71e0846ad496be4761dc9c11a'
docker pull --platform linux/amd64 "$IMAGE"
docker tag "$IMAGE" php:8.4.26-apache

The digest pin matters: the mutable php:8.4.26-apache tag has been rebuilt since our runs (its Apache base moved from 2.4.68 to 2.4.69), and per-build heap calibration is exactly what the chain depends on.

  1. Start the target and confirm the baseline response.

docker compose -f docker/docker-compose.yml up -d
curl http://127.0.0.1:8088/index.php

  1. Run the exploit and verify the command output written by the worker.

python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/out/p1'
cat out/p1

Figure 3. The pinned exploit executes a command as www-data on PHP 8.4.26.

[+] chunk base = 0x7ffffc200000
[+] libphp base = 0x7ffffc517000
[+] W3 attempt 1
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)

4. Compare the same exploit against the PHP 8.4.27 control. The control used the same PHP configuration and pinned base image; Debian package versions were resolved at build time, so this is a recorded differential rather than a byte-for-byte reproducible build.

rm -f out/p1
python3 exploit.py -u http://127.0.0.1:8089/index.php -c 'id>/out/p1'
cat out/p1
curl http://127.0.0.1:8089/index.php

Figure 4. On PHP 8.4.27, the exploit stops at HEAP_LEAK_FAILED, creates no proof file, and the target returns OK.

The forged-structure carrier limits its command string to 14 bytes. For longer commands, the reproduction stages a shell script at /out/c and invokes it with sh</out/c. The retained runs recorded are succeeding in three of three attempts, plus an arbitrary multi-command script.

Stages 1 and 2 use the PS(id) path to leak chunk and libphp.so base addresses, consistent with the PHP advisory’s process-memory disclosure impact. In this Docker Desktop VM, the leaked addresses were identical across restarts, and prefork workers inherit the parent mapping by fork. These runs therefore do not demonstrate an ASLR bypass on a host with randomized mappings. They show address disclosure and command execution, not disclosure of credentials or all intermediate memory operations. The published exploit’s GDB analysis describes the forged-structure chain.

See how SAFE transforms your CTEM Unified exposure visibility, AI-driven prioritization, and quantified risk in business terms. Built for enterprise scale.