PHP2Shell Checker: Technical Reference
Two pre-authentication critical RCE vulnerabilities in PHP were published on 21 October 2026. Here is a full account of what our checker looks for, why each check matters, and what we can and cannot tell you from the results.
PHP allocates memory through its own heap manager, Zend MM, which wraps system calls and tracks allocations per-request. When an allocation fails and PHP runs out of memory, it raises a fatal error and calls longjmp() to unwind the call stack. This is faster than a proper exception, but it skips destructor calls for stack-allocated objects, leaving parts of the Zend engine in an inconsistent state.
The specific danger is in how PHP's request-shutdown routine handles that aftermath. After longjmp() fires, the request cleanup phase runs and traverses internal HashTable structures to free them. When a HashTable bucket still holds a live pDestructor function pointer, the cleanup routine calls it. An attacker who can control the memory layout (by triggering the OOM at the right moment with the right data already placed) can make that pointer point to zif_system, PHP's internal wrapper around system(), with their command as the argument.
The published PoC breaks this into four stages:
The ASLR bypass in Stage 2 does not require display_errors=On, which earlier analysis assumed. During the OOM abort, PHP's error handler builds HTTP response headers. If upload progress data placed a large enough value in a session-related buffer, that buffer (which contains live heap metadata) is written verbatim into a Set-Cookie response header. The attacker reads the header from the HTTP response and extracts two values: the base address of the request heap, and the load address of the PHP shared library. With both, ASLR is defeated for that worker process.
Stage 3 uses the leaked PHP library base address to compute the address of zif_system. A 167-byte fake HashTable structure is constructed with pDestructor pointing to that address and the command string as payload. This structure is placed into the session file via the upload progress write mechanism and referenced by a pointer left dangling from Stage 1.
The HTTP response to the final request returns 200. The targeted worker does not crash. No error appears in the PHP or Apache logs. The attack is silent.
<?php echo "OK"; ?>, with display_errors=Off and memory_limit=128M (the stock php.ini-production defaults). Your application code does not run before the exploit completes.
PHP-FPM and Apache mod_php both reuse worker processes across requests for performance. A worker serves request A, PHP cleans up, then the same process serves request B. The cleanup between requests is supposed to reset all per-request globals. CVE-2026-109619 is a case where it does not.
Specifically: the PS(http_session_vars) global in the session module is not fully zeroed between requests. After request A ends, this pointer still references the previous request's session variable storage. When request B activates the session upload progress handler at RINIT (request initialization, before any PHP code), the handler walks a data structure that still contains a live pointer from request A's heap. That memory has been freed and may have been reallocated to something else. Accessing it is a use-after-free.
The exploit path: the attacker sends a crafted multipart POST that includes PHP_SESSION_UPLOAD_PROGRESS as a field name. This activates the upload progress tracker. The session.upload_progress.name field in that POST body specifies the session ID, regardless of session.use_only_cookies, and the tracker writes attacker-controlled data into the session file before the application sees the request. The stale pointer from the previous request means that write operation, combined with the right heap state from a preceding request, can be turned into a controlled write primitive.
Critically: this fires at RINIT. There is no point in your application's startup sequence (not a framework bootstrap, not auto_prepend_file, not a WAF module, not ini_set()) that executes before it.
Researcher analysis identified four distinct chains that reach RCE using these CVEs. They differ in prerequisites and stability. Understanding them is why we report configuration values separately from the version number.
Chain A exploits the upload progress write mechanism to inject PHP code into a session file, then uses a local file inclusion (LFI) vulnerability in the application to execute it.
The write step: the attacker submits a multipart POST where PHP_SESSION_UPLOAD_PROGRESS is set to <?php system($_GET['cmd']); ?> and session.upload_progress.name is set to an attacker-chosen PHPSESSID. PHP writes a session file at /tmp/sess_<PHPSESSID> containing that string before any application code runs. The execute step: any LFI vulnerability in your application (a require $_GET['page'], a path traversal in a file reader, or an improperly validated include) can include that session file and trigger the payload.
Chain A does not require memory corruption. It works on patched PHP if session.upload_progress.enabled remains on.
PHP offers three session serialization formats: php (the default), php_serialize, and php_binary. The php format stores data as key|serialize(value): a pipe character separates the key name from the serialized value. The php_serialize format stores the entire $_SESSION array as a standard PHP serialization string.
If one part of your application writes a session using php_serialize and another reads it using php, the reader applies the wrong grammar to the data. A session value written by the php_serialize handler might look like a:1:{s:3:"foo";O:7:"Exploit":0:{}}. When the php handler reads this, it treats the pipe character inside the serialized string as a key separator, and the portion after it (O:7:"Exploit":0:{}) is passed directly to unserialize(). If your application has a gadget class available (a class whose __destruct(), __wakeup(), or __toString() method does something dangerous when called with attacker-controlled properties), that gadget fires.
Wiz Research demonstrated this chain producing pre-auth RCE on default installations of WordPress (via WooCommerce), Joomla, Drupal, Magento (including maintenance mode), and Laravel (via PendingBroadcast's destructor). Every one of those applications ships gadget classes. Chain C does not require memory corruption and works on patched PHP if the serializer mismatch exists.
This is the full memory corruption chain described in the CVE-2026-109618 section above. It targets Apache prefork with mod_php specifically because that architecture provides the worker persistence that CVE-2026-109619 requires (stale state from a previous request) combined with the shared process heap that ASLR defeat targets. The published PoC does not currently target PHP-FPM pools, though the stale-state flaw is present there too.
Chain D works on any PHP page regardless of what your application code does. No LFI, no gadget class, and no unusual configuration are required. If your server runs Apache prefork with mod_php and your PHP version is unpatched, you are exposed to Chain D.
When PHP calls session_start() and the session file exists but fails to deserialize cleanly (for instance because it was written in a different format or is partially corrupt), the session_decode() call fails. PHP does not abort the decode silently: any objects that were partially reconstructed before the failure still have their destructors called during cleanup. An attacker who can write a serialized object into your session file (via the upload progress write mechanism in Chain A, or by other means) and then trigger a decode failure can cause that object's __destruct() to fire before the application does anything.
session.upload_progress.enabled remains on. The PHP patch addresses the memory safety bugs but does not change your default configuration. Setting session.upload_progress.enabled = Off in php.ini (not via ini_set) is required to close these paths even after patching.
We run these four checks on every scan. We send only requests that a normal browser visit would also generate. We make no writes to your server and leave no trace beyond standard access log entries.
We probe four independent sources and cross-check them:
Response headers. We read X-Powered-By: PHP/8.x.y and version strings embedded in the Server header from your initial response and any redirect targets. Some stacks suppress these headers in production; if yours does, we fall through to the next sources.
Error pages. We request a path that does not exist on your site to trigger a 404, and a deliberately malformed request to probe for a 500. Default PHP error templates include the PHP version in the rendered HTML. This source works even when response headers are suppressed, because the version appears in the page body.
phpinfo() pages. If we find a publicly accessible phpinfo page on your server, we read the exact PHP version, build date, Zend engine version, and SAPI directly from it. This is our most reliable source.
phpMyAdmin. phpMyAdmin's login page and footer embed the PHP version in their HTML. If you have a phpMyAdmin installation accessible without authentication, we read the version from it.
Once we have a version string, we assess it against the known patched releases: 8.5.12, 8.4.27, 8.3.36, 8.2.35. For branches with no current PHP.net release (8.1, 8.0, 7.4, and older), we flag the version as end-of-life and note that distribution backports may exist. When none of our sources discloses a version, we report "unknown" rather than silently returning a clean result, because unknown is not the same as safe.
The session upload progress handler activates only when PHP receives a multipart/form-data POST body containing the field PHP_SESSION_UPLOAD_PROGRESS. If your server rejects multipart POST requests before they reach PHP (via an Apache LimitRequestBody, a Nginx content-type block, or a WAF rule) that specific trigger cannot fire. We test this by sending a minimal multipart POST to your site root and checking the response.
Blocking multipart POST at the web server layer is one of the recommended interim mitigations. It closes the upload-progress trigger for Chains A, C, and E. It does not address Chain D (OOM corruption), which can be triggered by any request type. A server that blocks multipart POST is scored as Reduced risk, not safe.
Note: the multipart POST we send contains the PHP_SESSION_UPLOAD_PROGRESS field name — the same field that activates the upload progress handler. This request shape matches published IDS signatures: Suricata SID 30260002 and YARA rule PHP_UploadProgress_Session_Poison will fire on it. If your server has an IDS in front, this check will generate an alert. We do not include a PHP open tag in our test request, so the probe itself does not write executable content.
We check a list of paths where developers commonly leave phpinfo pages publicly accessible: /info.php, /phpinfo.php, /test.php, /php_info.php, /server-info.php, /i.php, and a dozen others. We confirm a genuine phpinfo page by checking that the response contains the phpinfo output signature, not just a 200 status.
A public phpinfo page is an independent vulnerability regardless of your PHP version or patch status. It hands an attacker your PHP version, every configuration value, every loaded extension, environment variables, and server paths. For an attacker preparing a targeted exploit, this eliminates all guesswork. We flag its presence as a standalone issue and, when we find one, use it as the source for the configuration checks in Section 4.
We check your site at phpMyAdmin's common install paths. An accessible phpMyAdmin instance exposes database credentials via brute force or default credentials, provides a LOAD DATA INFILE path to file system access, and (separately from these CVEs) has its own published vulnerability history. We flag its presence as a separate risk alongside the PHP version it discloses.
When we find a phpinfo page on your server, we extract these settings. Each one changes your risk picture in a way the version number alone cannot capture. This is also why we report a clean version result separately from a clean configuration result.
| session.upload_progress.enabled | The primary gate for Chains A, C, and E on your server. When this is on (the default since PHP 5.4), PHP activates the upload progress tracker on any multipart POST that includes PHP_SESSION_UPLOAD_PROGRESS as a field name. The tracker writes attacker-controlled data to a session file before any of your application code runs. Turning this off in php.ini closes those three chains even on a patched PHP version. ini_set() cannot disable it: the upload progress check happens at RINIT, before PHP evaluates any script. Important: setting this in a .user.ini file does not work: per-directory ini files are processed after RINIT, so the value is applied too late to prevent the write. |
| session.upload_progress.cleanup | Controls whether PHP removes the upload progress entry from the session file when the request ends. On by default. When this is off, the injected progress data persists in the session file after the request completes, extending the window during which Chains A and C can be triggered by a follow-up request. An attacker who cannot time a subsequent request precisely benefits significantly from cleanup being disabled, since the poisoned session file remains available indefinitely. |
| session.serialize_handler | Determines the format PHP uses to read and write your $_SESSION data. The default (php) stores data as key|serialize(value); php_serialize stores the whole session array as a standard PHP serialization. If different parts of your application use different handlers on the same session save path, data written by one is parsed incorrectly by the other. That mismatch is the Chain C attack path. We report which handler your phpinfo page shows and flag if it differs from the modern recommended value. |
| session.use_strict_mode | When this is off (the default), PHP accepts any session ID your visitors supply, including one an attacker has pre-created and seeded with malicious data. Strict mode rejects session IDs that do not correspond to existing server-side sessions, which breaks the session fixation step in Chain A. It does not prevent Chains C, D, or E. |
| session.use_only_cookies | On by default since PHP 4.3. This setting prevents session ID injection via URL parameters. It does not protect against the upload progress attack: the upload progress handler reads the session ID from session.upload_progress.name in the POST body, which is a separate mechanism from URL-based session ID injection. PHP processes that POST field before enforcing cookie-only mode, so PHPSESSID supplied via POST reaches the upload progress handler regardless. We report it because it is frequently cited as a protection here. It is not. |
| memory_limit | Triggering the CVE-2026-109618 OOM chain requires exhausting your server's per-request memory allocation. The published PoC was tested and confirmed at the stock production default of 128M. Values at or below 16M on your server make the OOM trigger earlier and more reliably; values above 128M may still be exploitable, though the PoC's author notes the upper threshold has not been exhaustively tested. We report the raw value and assess it against the confirmed range. |
| max_input_vars | The published exploit submits 1000 form fields in the multipart body to generate the memory pressure for the OOM trigger. The default is 1000. If your server has this set lower, PHP truncates the input array earlier, potentially preventing the OOM from occurring at the expected point. If it is higher, the exploit adjusts but remains functional. |
| post_max_size | Sets the maximum size of the entire POST body. Combined with memory_limit, this caps how large a multipart body an attacker can force PHP to parse and allocate memory for. The default is 8M. At lower values, the attacker must generate the required OOM pressure with a smaller payload; at higher values, the exploit has more room. We report it alongside memory_limit so you can see the total allocation budget available to an attacker in one place. |
| fatal_error_backtraces | PHP 8.5 introduced this ini directive. When enabled (the default in PHP 8.5), PHP includes a stack trace in the response body on fatal errors. Researcher analysis shows that these stack frames can expose internal heap addresses alongside the existing Set-Cookie leak path used by the published ASLR bypass. On PHP 8.5 servers, this is an additional CVE-2026-109619 information-disclosure vector. Setting fatal_error_backtraces=0 in php.ini removes this additional leak path without affecting error logging. |
| display_errors | The original version of the exploit required display_errors=On to leak PHP internals through rendered error output. That version is archived. The current published PoC leaks heap addresses through oversized Set-Cookie headers instead, so display_errors is irrelevant to the memory corruption path. We report it to correct the widespread misconception that setting this to Off provides meaningful protection against CVE-2026-109618. |
| allow_url_include | Not required by any of the four published chains. We report it because it expands the post-exploitation surface on your server: with code execution achieved, an attacker can load additional payloads from remote URLs without writing to disk. Off by default; its presence extends the impact of a successful compromise. |
| file_uploads | Controls whether PHP accepts actual file uploads in multipart POST bodies. The session upload progress tracker activates independently of whether actual file content is present. However, this setting affects whether the exploit trigger requires a multipart body with file content attached or can operate on a field-only POST against your server. |
| open_basedir | Restricts which filesystem paths PHP can access. If your server has this set, code execution achieved via Chains A through E would be constrained to the configured directory tree, limiting what an attacker can read, write, or include. It does not prevent code execution. We report it so you can assess the blast radius of a successful compromise on your specific setup. |
| disable_functions | Lists PHP functions disabled at the engine level on your server. An attacker who reaches code execution via system() invocation through Chain D already has shell access, making this setting largely irrelevant for that chain. For Chains A, C, and E, where arbitrary PHP code runs, a well-populated disable_functions list covering system, exec, passthru, popen, proc_open, and shell_exec raises the bar for what an attacker can do. We report it as a post-exploitation constraint. Important exception: Chain B (var_destroy use-after-free) reaches code execution by overwriting a function handler pointer and dispatching through the binary directly, bypassing PHP's function-level permission checks entirely. disable_functions does not constrain Chain B. |
| Server API (SAPI) | The published Chain D PoC targets Apache prefork with mod_php (Apache 2.0 Handler SAPI) specifically. Under that architecture, Apache worker processes embed PHP and persist across requests, providing the stale global state that CVE-2026-109619 depends on. PHP-FPM, FrankenPHP, and LiteSpeed worker pools also persist across requests and are theoretically susceptible to Chain D, but the published PoC does not target them directly. PHP-CLI spawns a new process per invocation and is not affected. The SAPI value is the single most important factor we look at when assessing Chain D applicability for your server. |
| SERVER_SOFTWARE | The full server software string from your environment (e.g. Apache/2.4.58 (Debian)). We use this alongside the SAPI value to confirm the Apache version and verify that your server is running the exact stack the published PoC targets. |
We combine the multipart acceptance result with the session.upload_progress.enabled value (when we found a phpinfo page to read it from) into a single score for the upload-progress attack paths. Chain D is assessed separately via the version check and SAPI value.
session.upload_progress.enabled is on. Both conditions the attack requires are in place. Chains A, C, and E are all reachable if your PHP version is unpatched.session.upload_progress.enabled is off on your server, or your server blocks multipart POST at the perimeter. Chains A, C, and E are blocked by this. Chain D (OOM corruption) is not affected by either of these mitigations.The crash probe constructs a multipart POST body approximately 90 KB in size. We shape it to mimic the OOM trigger from the published Chain D PoC:
max_input_vars default and the number the published exploit uses to stress Zend MM's per-request allocator.We send this body as a single POST to your site root (/index.php or /) and inspect the response.
We call the probe positive if we see any of:
Allowed memory sizeA positive result confirms that the OOM condition can be triggered on your server with this request shape. It is strong evidence that Chain D is viable. It does not confirm that the full four-stage exploit (ASLR defeat, structure forge, code execution) would succeed: the ASLR bypass and structure placement steps require precise memory layout that this probe does not attempt.
A clean response (a normal 200 or 404) means our probe did not trigger OOM on your server as sent. This can happen because your PHP version is patched; your memory_limit is high enough that 1000 fields and 50 levels of nesting do not exhaust it; your server filtered the request before it reached PHP; or the OOM condition requires a different request shape for your specific setup. A negative probe result does not confirm your server is safe.
This probe can disrupt your PHP worker processes, cause brief service interruption, and trigger security monitoring on the target. We require verifying ownership of the domain before it runs. We send a verification email to an address at your domain. Only after clicking the confirmation link (which requires reading email at that domain) does the probe become available. This gate is enforced server-side and cannot be bypassed via URL parameters.
A version-only check answers one question: has your PHP installation received the security patch? These four scenarios show why that question is insufficient on its own.
Your version is not disclosed. If your server suppresses X-Powered-By, returns generic error pages, has no public phpinfo, and runs no phpMyAdmin instance, we have nothing to assess the version from. We report this explicitly as "version unknown" rather than silently returning a clean result, because unknown is not the same as safe.
EOL branches with distribution backports. PHP 8.1, 8.0, 7.4, and older have no official patch from PHP.net for these CVEs. A version-only check against PHP.net releases would flag all of them as vulnerable. In practice, Debian, Ubuntu, Red Hat, cPanel, and Plesk all ship their own security backports for supported PHP versions in their package repositories. Your server running Debian's php8.1 package may have received the patch via apt upgrade even though PHP.net shows 8.1 as end-of-life. We flag these as EOL with a clear note that distribution backports may apply, rather than calling them definitively vulnerable.
Patched PHP, unchanged configuration. Chains A, C, and E survive a PHP upgrade if your configuration is not also changed. The security patch fixes the memory safety bugs. It does not set session.upload_progress.enabled = Off, it does not unify session.serialize_handler across your application, and it does not add session ID validation. If you upgrade PHP and consider the work done, you remain exposed to three of the four attack chains. We report the phpinfo configuration values separately from the version assessment so this gap is visible in your results.
Public phpinfo and phpMyAdmin are risks regardless of patching. A phpinfo page accessible without authentication hands an attacker your complete PHP configuration, extension set, environment variables, and file paths, before they attempt anything. This directly accelerates a targeted attack against your server. We flag these exposures independently of your version status, because they represent real risk even on a fully patched, correctly configured server.
Patched versions carry two additional unresolved issues. CVE-2026-115560, acknowledged by PHP on 23 October 2026 and not yet patched, covers a session serialize_handler mismatch path: it requires session.serialize_handler set to php_binary or php_serialize in php.ini combined with a runtime ini_set() switch to php before session_start(). Applications that never switch the handler at runtime are not affected. PHP plans to ship the fix in its next regular bugfix release; the mitigation is disabling session.upload_progress.enabled. Separately, GH-24488 is a GC double-free regression introduced by the CVE-2026-109618 fix itself: it affects exactly PHP 8.4.27 and 8.5.12 (not 8.2.35 or 8.3.36), causes a crash via SimpleXML reference cycles (DoS; RCE escalation is blocked by ZendMM on stock builds), and is fixed in the pending 8.4.28 and 8.5.13 releases. The interim mitigation is zend.enable_gc = Off. The configuration mitigations (disabling upload_progress, locking serialize_handler) remain recommended even after upgrading.
These are observable signals that exploitation of these CVEs is being attempted or has occurred on your server. None are detectable by this tool remotely: they require access to server logs, session storage, or process state.
<?php or |O: (a serialized object prefix)Allowed memory size of ... bytes exhausted followed by a 500 or 502 from the same worker within the same secondSet-Cookie: PHPSESSID= response headers with anomalously long values (the ASLR leak exfiltrates heap addresses through this header)sess_*) containing an upload_progress_ key alongside O: markers (serialized object injection)free(): double free detected in tcache 2 from glibc, or zend_mm_heap corrupted from PHPphp://inputsh, bash, dash) as a child processIDS note: the multipart POST our scanner sends contains the PHP_SESSION_UPLOAD_PROGRESS field. This matches Suricata SID 30260002 and YARA rule PHP_UploadProgress_Session_Poison. If you have network monitoring in place, our passive check will generate an alert.
Ready to check your server?
Run the PHP2Shell Checker →