跪拜 Guibai
← Back to the summary

PHP's php2shell RCEs Break the Runtime Before Your Code Runs: Patch, Restart, and Don't Trust the Copy-Paste PoCs

Don't Blindly Copy Online php2shell Reproduction Scripts: Where the PHP Kernel Actually Broke This Time — 4 Truths (2026)

📖 Summary: On October 21, 2026, PHP officially issued urgent releases 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 to fix the CVSS 9.2 php2shell vulnerabilities (CVE-2026-109618 / 109619). This article clarifies the memory corruption root cause from longjmp, teaches you how to determine real reachability in 30 minutes, how to upgrade and detect, and corrects the false reproduction conclusions circulating online.

🏷️ Keywords: php2shell, PHP security, remote code execution, memory corruption, incident response

Table of Contents

48 Hours: A Fire Lit Early

On October 20, 2026, the php.net homepage displayed four lines: Upcoming Critical PHP Security Release, saying that fixes for two critical vulnerabilities would be released the next day at 14:00 UTC. A four-line announcement could not sustain a weekend of hype; what really made it explode was: details leaked before the announcement.

The vulnerability is codenamed php2shell, with two CVEs: CVE-2026-109618 (OOM memory corruption) and CVE-2026-109619 (cross-request stale global state), both rated CVSS 4.0 9.2. They require no account, no session, and no vulnerable application code anywhere on the target site.

From leak to million-level attacks: the timeline

Putting the public disclosure timeline together shows a very tight curve:

Time (UTC) Event
2026-08-11 Calif (Ryan) and OpenAI independently report the OOM → RCE chain; Wiz and OpenAI later add new attack vectors
2026-10-17 Amazon MadPot honeypot records the first exploitation attempt, sourced from commercial anonymized exit IPs (4 days before disclosure)
2026-10-20 PHP publishes the advance notice; the same day CVE numbers, session.upload_progress, and fragments spread on social media and HN, forcing the official release to accelerate
around 2026-10-21 02:00 The earliest public PoC appears, about 12 hours before the embargo lifted
2026-10-21 13:30–14:00 Official releases 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35, plus minimal source backports for 7.4 / 8.0 / 8.1
2026-10-21 daytime UK NCSC issues an alert; Dutch NCSC and the Port of Rotterdam, Overheid.nl, KNVB, multiple hospitals, and several political party websites temporarily go offline; Finland shuts down some government public services; the UK Office for Budget Responsibility website goes down
2026-10-21 evening Cloudflare CEO publishes attack volume: tens of thousands within 45 minutes after the announcement, hundreds of thousands within 2 hours, then millions
2026-10-22 Singapore CSA announces "threat is imminent"; South Korea's Ministry of Science and ICT raises the national cyber alert to "caution"; China's CNVD, MIIT NVDB, Tencent Cloud, Alibaba Cloud, and Qi'anxin issue advisories one after another

A few orders of magnitude for context, to explain why governments chose "take it offline first":

⚠️ Key point: this is not "a plugin has a hole"; the host language itself has a hole. Upgrading all your WordPress plugins, auditing all themes, and using strong admin passwords adds zero score against these two CVEs.

Why this is not the same magnitude as "some CMS has a vulnerability"

The difference is in three "unrelated to business" properties:

  1. Unrelated to code quality. Request parsing happens before extension initialization and before script compilation. That is, the vulnerability is triggered before the first line of your index.php executes. No matter how clean your code is, its exposure is exactly the same as a messy legacy site.
  2. Unrelated to framework. Laravel middleware, Symfony firewalls, and WordPress's wp_die() all take effect only in the script phase, while this bug is in the SAPI layer.
  3. Unrelated to authentication. A single anonymous POST can hit it; there is no "requires login" downgrade condition.

CrowdStrike's public test conclusion: one anonymous POST request can make an Apache mod_php worker spawn a shell process and execute arbitrary commands, reliably consistent across x86-64 / ARM, still reliable after worker restart, and on ARM hosts it can bypass ASLR using only HTTP requests.

The worst part is how "quiet" it is: the worker does not crash, HTTP returns 200, and the application logs contain nothing, because the application code never ran.

Root cause: longjmp skips C stack unwinding

To understand these two bugs, you first have to accept a counterintuitive fact: PHP's way of handling "out of memory" is not returning an error; it is jumping away directly.

What PHP actually does when it hits a fatal error

The terminal path for PHP fatal errors is zend_bailout():

// Zend/zend.c (official public source, excerpt)
ZEND_API ZEND_COLD ZEND_NORETURN void _zend_bailout(const char *filename, uint32_t lineno)
{
   ...
   CG(unclean_shutdown) = 1;   // only leaves a "this shutdown was not clean" marker
   ...
   LONGJMP(*EG(bailout), FAILURE);   // jump directly back to the outer setjmp point
}

LONGJMP is C's "non-local jump". It does not unwind the stack: all C function frames between the error point and zend_catch are discarded directly; destructors do not run, refcounts are not rolled back, cleanup functions are never called, and nothing records "this data structure was only half-built".

After jumping back, PHP continues its normal request cleanup flow (php_request_shutdown()), which assumes all structures are in a consistent state. The two CVEs are about the two places where that assumption breaks.

Also remember two components:

CVE-2026-109618: accounting first, allocation second

The problem is in _zend_hash_str_add_or_update_i(), the low-level implementation of all string-key insertions:

// Zend/zend_hash.c (official public source, excerpt)
add_to_hash:
   idx = ht->nNumUsed++;          // ① accounting first: the table "has one more element"
   ht->nNumOfElements++;
   p = ht->arData + idx;
   p->key = key = zend_string_init(str, len, ...);   // ② allocation second: this step can OOM

The order is key. ① has already incremented nNumUsed, and only then ② allocates a copy of the key. If ② happens to hit memory_limit, zend_bailout() capsizes the request, and this bucket (ht->arData[idx]) is never filled in: its value, hash, and key pointer are all stale heap data left by the allocator.

Then the cleanup flow steps in: zend_array_destroy() faithfully iterates over nNumUsed buckets and treats this "ghost entry" as a normal zval; i_zval_ptr_dtor() dereferences the pointers inside it, decrements reference counts at the pointed-to addresses, and even when the count reaches zero, indexes into a function pointer table using the type tag in the stale bytes and dispatches a call.

The attacker needs to control two things:

  1. Control "where OOM happens". Send a URL-encoded POST with field names in the form ZZZZ...ZZZZ[x]=1 as an extra-long nested structure; php_register_variable_ex() inserts the outer name as a new array key; the key length is directly controlled by the attacker, tuned to just cross memory_limit, so the bailout lands exactly on the key-allocation line.
  2. Control "what the stale bytes are". Zend MM's size-class + LIFO reuse behavior is deterministic enough. An earlier field in the same request has its value copied, decoded, and immediately freed by PHP, so the freed content happens to occupy the block that is later "claimed". The ghost entry then becomes a forged structure completely defined by the attacker.

💡 One-sentence summary of 109618: the table says "I have one more element", and the bytes of that element are garbage left over from a previous allocation, and the cleanup flow believes it.

CVE-2026-109619: cross-request zombie globals

109619 is the same mechanism, but the damage lands on process-level global variables, pushing the exploitation window to the next request.

The interrupted operation caches state in module-level globals (error state, session identity and handler state, serialization context, upload progress state, etc.), and relies on reaching its own cleanup to clear them. When a fatal error jumps away, teardown is skipped, the globals still point at per-request heap, and nested counters still claim "an operation is in progress".

When the request closes, the entire per-request heap is freed, but the globals belong to the process, not the request, so they survive. PHP is now holding module-level pointers to freed memory.

Two factors make this zombie state both reachable and controllable:

So 109619 is two-phase: request one acts as "producer" and poisons the worker; request two acts as "consumer" and triggers reads/writes of freed memory. The trigger entry point is not limited to memory exhaustion; execution timeout and client disconnect also go through the same zend_bailout() path.

Who is most at risk: SAPI and configuration reachability

Worker reuse is the amplifier

The official statement that mod_php is the highest risk is not casual:

Dimension Apache mod_php PHP-FPM
Worker reuse The same process handles requests consecutively; cross-request exploitation is naturally feasible (the core exposure surface of 109619) Workers that fail during startup are killed; but workers interrupted after entering the script phase survive and are equally exposed
Memory layout stability In prefork, all workers fork from the same parent process, so the layout remains stable after restart and payloads can be refined repeatedly at zero cost Relatively unstable
Official judgment Highest risk; a single anonymous request can bypass all application code Partially mitigated, by no means immune

Full list of affected SAPIs: PHP-FPM, Apache mod_php, php-cgi, FrankenPHP, LiteSpeed. Any PHP installation that "provides external web request handling" is in scope, including the image you pulled with docker pull php:8.1-apache.

The memory_limit / post_max_size ratio

This is the easiest and most overlooked check. For the attacker to cause "exactly exhaust memory while parsing the request", the request body must be able to chew through memory_limit without exceeding post_max_size. The empirical criterion is:

Reachable condition ≈ memory_limit < 4 × post_max_size
Configuration combination Reachable? Explanation
post_max_size=8M / memory_limit=128M Default combination, URL-encoded path not reachable But the multipart path still depends on field count and nesting depth
post_max_size=64M / memory_limit=128M Reachable Common in businesses that "accept large attachments"
post_max_size=100M / memory_limit=256M Reachable Common in backend imports and media sites
post_max_size=2M / memory_limit=32M Not reachable Common on small sites, risk significantly reduced

⚠️ Note: post_max_size can be set per directory in .htaccess or in virtual hosts. On shared hosting, a single site can turn itself into a vulnerable configuration, so "our default image is safe" is not true.

Hands-on: self-check and remediation in 30 minutes

Inventory: the "invisible PHP" instances

Do not rush into apt upgrade first. The real pitfalls are not the PHP you installed yourself, but the PHP someone else installed for you.

# 1) Find all PHP executables (including ones that bypassed the package manager)
for p in /usr/bin/php* /usr/local/bin/php* /opt/*/bin/php*; do
  [ -x "$p" ] && printf "%-40s %s\n" "$p" "$("$p" -r 'echo PHP_VERSION;' 2>/dev/null)"
done

# 2) Among processes listening / serving externally, which are PHP-family
sudo lsof -nP -iTCP -sTCP:LISTEN | grep -Ei 'php|httpd|apache|nginx|litespeed|frankenphp'

# 3) Container images (for Podman, replace docker with podman)
docker ps --format '{{.Names}}\t{{.Image}}' | grep -Ei 'php|wordpress|nextcloud|matomo|whmcs'

# 4) Products with panels / bundled runtimes, list separately for manual review
#    Common forms: control panels, ticket systems, e-commerce SaaS, monitoring probes, router/NAS web management pages

Write the inventory result directly into a table, to avoid "I thought there were only two machines":

Asset type Examples Common misses
Self-hosted sites Business backends, official sites post_max_size changed per directory in .htaccess
CMS WordPress / Drupal / Joomla Plugin-bundled composer dependencies pin PHP versions and cannot be upgraded
Panels / tools Various ops panels, ticketing, monitoring Bundled PHP runtime that does not go through the system package manager
Containers php:8.x-fpm, php:8.x-apache Image built long ago, base layer not rebuilt
Devices / appliances Firewalls, NAS, print servers Vendor firmware embeds PHP and never tells you

Determine whether your instance is actually reachable

A one-liner on a single machine gets the key four items:

php -v | head -1
php -i | grep -E '^(memory_limit|post_max_size|upload_max_filesize|session\.upload_progress\.enabled|Loaded Configuration File)'

Check against a one-line table:

PHP branch Highest affected version Fixed version
8.5.x ≤ 8.5.11 8.5.12
8.4.x ≤ 8.4.26 8.4.27
8.3.x ≤ 8.3.35 8.3.36
8.2.x ≤ 8.2.34 8.2.35
8.1 and earlier All No new official releases; only source-level backports for 7.4 / 8.0 / 8.1

💡 Side note: the 8.2 branch reaches EOL on 2026-12-31. Projects still on 8.2 should put "upgrade to 8.3/8.4" into their December plan after this fix, rather than being hit again next year.

Upgrade: why workers must be restarted

The official announcement includes a sentence many people skim over: after updating, restart services so running processes load the fixed code.

The reason is straightforward: PHP-FPM master processes, Apache prefork workers, and OPcache shared memory all "keep running old code while the process is alive". After you apt install, the new version is on disk, but the old Zend is still what is running in memory.

# Debian / Ubuntu family
sudo apt update && sudo apt install --only-upgrade php8.4-fpm php8.4-cli php8.4-common

# RHEL / Rocky / Alma family (the third-party remi repo had no update on release day; official advice is to switch back to distro packages or compile yourself)
sudo dnf upgrade --refresh 'php*'

# Key: restart, not just reload (reload does not rebuild worker memory layout)
sudo systemctl restart php8.4-fpm
sudo systemctl restart apache2        # machines using mod_php must restart httpd/apache2

# Containers: recreate, not restart
docker compose up -d --force-recreate php

# Verify: confirm the running version, not the on-disk version
php -v | head -1
sudo lsof -p "$(pgrep -o php-fpm)" | grep -E 'libphp|php$' | head

⚠️ Machines using mod_php have the highest priority. If one machine runs both php-fpm and mod_php (common in legacy leftovers), restart both; do not restart only the one you remember.

Mitigations: temporary measures when you cannot patch

The official wording is restrained and honest: these measures "only reduce the exposure of specific attack paths; they cannot make an installation safe". In other words, they buy time; they do not fix the problem.

For CVE-2026-109618 (OOM corruption):

; php.ini or FPM pool configuration
; 1) Turn off the controllable memory-limit failure (must prevent the app from overriding it)
;    FPM: php_admin_value[memory_limit] = -1
;    Apache: php_admin_value memory_limit -1
;    And check every pool / <Directory> / .htaccess to see whether a finite value has been set back
memory_limit = -1

; 2) Lower the request body limit (8M if no uploads needed, 32M if uploads are needed)
post_max_size = 8M
upload_max_filesize = 6M   ; must be less than post_max_size

At the LiteSpeed / Nginx / reverse proxy layer, add limits on "decoded request size, field count, multipart segment count, header count, nesting depth", and cover requests without an available Content-Length (chunked transfer gets missed otherwise):

# nginx-side example: limit request body and buffers, reducing per-request disposable memory
client_max_body_size 8m;
client_body_buffer_size 128k;
large_client_header_buffers 4 8k;

For CVE-2026-109619 (stale globals):

; Must be configured in a place that takes effect "before request parsing":
;   php.ini / FPM pool / server configuration
; Do not rely on ini_set() or FPM's .user.ini; their effective timing is too late
session.upload_progress.enabled = 0

; PHP 8.5 only: turn off fatal error backtraces to block the confirmed backtrace path
fatal_error_backtraces = 0

After changing these, every worker must be restarted, otherwise you only changed a config file. Two more things that "configuration cannot solve":

⚠️ One more counterintuitive trap: do not use RLIMIT_AS / RLIMIT_DATA as memory guards. They make allocations fail directly, which is exactly what pushes execution back onto the vulnerable allocation path. Use container / cgroup memory limits instead, configured as "only kill the affected worker".

Detection: how to tell if you have been hit

The only high-fidelity signal

A successful exploit leaves almost no trace: the worker does not crash, HTTP returns 200, and application logs are quiet. The only stable product is a generic fatal error in the web server error log:

PHP Fatal error:  Allowed memory size of 16777216 bytes exhausted
(tried to allocate 3670048 bytes) in Unknown on line 0

The distinguishing point is all in the last half of the line: in Unknown on line 0.

It means no PHP file was executing at the time: the error occurred before script startup. That separates it cleanly from the large amount of noise from "in-script arrays too large causing OOM". Grep for this:

# Search back from 10-17 (the earliest exploitation attempt recorded by the honeypot)
sudo zgrep -hE 'Allowed memory size of .* exhausted' /var/log/apache2/*error*.gz \
  /var/log/nginx/*error*.gz 2>/dev/null \
  | grep 'in Unknown on line 0' | sort | uniq -c | sort -rn | head -50

# FPM side
sudo journalctl -u php8.4-fpm --since '2026-10-17' \
  | grep -E 'memory size of .* exhausted' | grep 'Unknown on line 0' | wc -l

If the hit count for this rule jumps from 0 to dozens or hundreds after October 17, treat it as "being scanned / exploit attempts".

Post-exploitation traces at process and file level

Logs can only prove "someone is attacking"; proving "they got through" depends on results:

Layer What to look for Command / location
Process tree sh, bash, curl, wget, python child processes under httpd / php-fpm / lshttpd ps -eo pid,ppid,user,lstart,cmd --forest
Files New .php files under the web root, especially random filenames with content starting with eval/assert/base64_decode find /var/www -name '*.php' -newermt '2026-10-17'
Scheduled tasks cron / systemd timers written as www-data crontab -u www-data -l, /etc/cron.d/
Credentials atime changes from .env, wp-config.php, config/database.php being read find /var/www -name '.env' -newerat '2026-10-17'
Outbound network Abnormal external connections from web processes Egress firewall / NetFlow; watch www-data established connections

💡 Note: criteria like find -newer are noisy on machines with frequent releases. Use them only on static sites and WordPress sites; for business repositories, a file integrity baseline comparison is more reliable.

Response order after a confirmed compromise

Getting the order wrong loses evidence and misses backdoors:

  1. Isolate first, then forensics: take down the public entry or put up a maintenance page; do not shut the machine down directly (memory evidence will be lost);
  2. Keep copies: disk snapshot + the whole /var/log + the process list at that time;
  3. Patch and restart workers; close the entry first, otherwise you will be re-compromised after cleanup;
  4. Rotate all credentials: database passwords, object storage AK/SK, third-party API keys, everything in .env, and the salts in wp-config.php. This class of vulnerability can read process memory, and those plaintext values are in memory;
  5. Clean backdoors before returning to service: webshells, cron, SSH authorized_keys, modified .htaccess, injected mu-plugins;
  6. Do the retrospective last: why was this machine's PHP version not on the list.

Debunking: where the online reproduction conclusions are wrong

This section may be the most valuable part of this article. The vulnerability is real, but among the secondhand information around it, the wrong parts spread faster than the right ones.

Claim 1: the root cause is a session.upload_progress zval UAF

This is the most widely circulated claim. Several public PoC repositories locate the root cause as: the rfc1867 upload progress handler caches a zval* into the session HashTable; after an upload stalls longer than session.upload_progress.min_freq, the next FILE_DATA event reinitializes and frees the table, the cached pointer becomes dangling, and the next write to bytes_processed breaks through.

It sounds self-consistent, and it does match the two line numbers in ext/session/session.c for CVE-2026-109618 / 109619. But someone verified the public PoCs one by one on ASAN builds (PHP 8.4.4, 8.5.4, 5.6.40; tried disabling ASLR, poisoning call addresses), and the conclusion was: the UAF/RCE for these two CVEs did not reproduce. Comparing with the official patch diff, the patch actually does three things:

  1. Rework the OOM bailout path: remove longjmp() in OOM scenarios and instead trigger a delayed "soft OOM" at the next VM safe point; at a higher threshold, use _exit() for a "hard OOM". This way the shutdown flow no longer sees half-finished state, while preserving memory_limit's normal capability;
  2. Initialize session state before RINIT;
  3. Reset state left over from the previous request.

In other words: the root-cause claim is wrong; the attack-surface claim is half right. session.upload_progress is indeed a confirmed consumer path for 109619 (the official announcement itself says so), but it is an amplifier, not the root cause. Treating the amplifier as the root cause has a real consequence: you will think "turning off session.upload_progress.enabled makes me safe", while the official statement says this only blocks the confirmed session paths and does not block other variants.

💡 The simplest way to judge whether a vulnerability analysis is credible: check whether it includes a diff of the official patch. Analyses that only reverse-engineer from a PoC are probably guesses.

Claim 2: CVSS 10.0, only PHP 8 is affected

Both numbers were distorted:

Rumor Actual situation
CVSS 10.0 Official CVSS 4.0 is 9.2; some organizations rate it 9.8 under CVSS 3.1; neither is 10.0
Only PHP 8.x is affected The defective code can be traced back to the PHP 5.4 era; 7.x / 8.x are all in scope; official releases were only made for 8.2–8.5
8.1 and earlier are also fixed by someone The official project no longer releases new versions for 8.1 and earlier; only minimal source backports for 7.4 / 8.0 / 8.1; distros and LTS vendors may provide their own backports

The "only 8.x affected" rumor is the most damaging: it makes a bunch of legacy 7.4 sites give up on checking, and those are precisely the least likely to have a WAF, the least maintained, and the most likely to be used as a springboard.

Claim 3: adding a WAF or changing config equals being fixed

The PHP Foundation's original words deserve to be copied and pasted into your group chat:

Mitigations "reduce the exposure of specific attack paths, but cannot make an installation safe".

Breaking it down:

Claim 4: upgrading the kernel is all you need

This is the most insidious one. The kernel patch fixes PHP's own memory corruption, but application-level attack chains do not disappear because the kernel is upgraded.

A verified chain that still works: session.upload_progress writes the uploaded filename verbatim into the session file (no sanitization), and the multipart handler accepts PHPSESSID from the POST body, bypassing session.use_only_cookies. If the target application itself has an LFI, the session file can be included to execute injected code. This combination succeeds with cleanup=0; with the default cleanup=1 race it succeeded 3/3 times; with enabled=0 it still succeeds by switching to "include the uploaded temporary file".

The conclusion needs to be split into two layers:

So the correct action is two tracks in parallel: upgrade the kernel today, and this week, audit sinks like include($_GET[...]).

Three long-term lessons from this incident

A shadow PHP asset inventory

What made governments scramble this time was not "our main site uses PHP", but no one could answer within an hour "how many PHP instances do we actually have?". Panels, ticket systems, monitoring probes, NAS management pages, printer web consoles, vendor appliances; they all run PHP and are not in the CMDB.

One workable practice: treat the "runtime inventory" as an independent asset, not as an attachment under an application. The minimum fields are four: runtime + exact version + external reachability + owner. Teams that could filter out all php < 8.2.35 within an hour this time will get to lose one less all-nighter next time.

In the age of AI vulnerability discovery, the patch window is closing

This timeline is worth recording separately: this OOM flaw was found by someone in 2020, rediscovered in a 2024 PHP audit, and was not fixed because exploitability had not been demonstrated.

For more than ten years it just lay there, because "from OOM to RCE" requires a chain that human researchers found very hard to walk by hand. Frontier models walked that chain. Wiz's statement is that AI "significantly lowers the bar for turning a long-standing flaw into a working exploit"; an American cloud security company CTO's comment is more direct: this will not be the last time; AI will keep digging out similar problems in widely used software.

For those of us writing business code, the practical meaning is one thing: "this bug is too complex, nobody should be able to exploit it" is no longer an acceptable scheduling reason. The moat of complexity is being filled in by compute.

Put "restart required" into the change process

This time a number of teams will go through a very unpleasant scenario: the patch is installed, the version number is correct, and they are still compromised, because the old Zend was still running in memory.

This is not a new problem, but it has the most severe consequences in "language runtime-level" security updates. It is recommended to fix one line in the change checklist as:

[ ] Security update applied
[ ] Running processes confirmed to be loading the new version (not just the version number on disk)
[ ] No leftover old workers / old containers / old master

Summary

Compress the whole thing into one table:

Dimension Conclusion
What it is Two unauthenticated RCEs in the PHP kernel, codenamed php2shell, CVSS 4.0 = 9.2
IDs CVE-2026-109618 (OOM memory corruption), CVE-2026-109619 (cross-request stale globals)
Root cause zend_bailout() jumps out with longjmp(), without C stack unwinding; cleanup trusts a structure that was "only half built"
Fixed versions 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35; 7.4 / 8.0 / 8.1 source backports only
Most dangerous config Apache mod_php resident workers + memory_limit < 4 × post_max_size
Only complete fix Upgrade + restart workers
Detection signal Allowed memory size ... exhausted with in Unknown on line 0
Biggest misconception Treating session.upload_progress as the root cause; thinking WAF / config change / kernel-only upgrade is enough

If you want to leave one sentence for colleagues still scheduling:

Running php -v is more important than reading any analysis. These two bugs do not care about frameworks, code quality, or whether you have a login state; they only care about "when you restart".


If this article helped you, feel free to like and bookmark. How did your team inventory this time (did you find hidden PHP in panels or devices)? Tell us in the comments, and leave a clue for people doing post-mortems later.