Post

PHP2Shell: The Critical PHP Engine Vulnerability That Broke the Internet

PHP2Shell: The Critical PHP Engine Vulnerability That Broke the Internet

In October 2026, two devastating CVEs rocked the PHP ecosystem. Dubbed PHP2Shell, these weren’t your typical WordPress plugin bugs or sloppy eval() calls, they lived deep inside PHP’s core engine, turning every PHP-powered server on the planet into a potential attack surface. No authentication required. No application code involved. Just a crafted HTTP request and you’re in.

What Is PHP2Shell?

PHP2Shell is the informal name given to a pair of critical Remote Code Execution (RCE) vulnerabilities tracked as:

CVE IDNameCVSS v4.0Core Issue
CVE-2026-109618OOM Corruption9.2 (Critical)Memory corruption via longjmp() during Out-of-Memory handling
CVE-2026-109619Stale Global State9.2 (Critical)Use-After-Free via stale pointers persisting across PHP-FPM workers

These vulnerabilities affect PHP versions 5.4 through 8.5 (prior to the emergency patches), covering over a decade of PHP releases. The attack is unauthenticated, meaning no login, no session, no valid endpoint is needed, just a reachable PHP server.

Why “PHP2Shell”?

The name captures exactly what the exploit chain achieves: from PHP straight to shell. A single crafted HTTP POST request can trigger memory corruption deep in the interpreter, hijack execution flow, and drop the attacker into a reverse shell running as www-data. No web shell upload. No file write. Just memory manipulation at the engine level.


The Affected Attack Surface

Because the bugs live in PHP’s C-level request lifecycle, not in any PHP userland code, the impact is universal:

  • Every SAPI: Apache mod_php, PHP-FPM, php-cgi, LiteSpeed, FrankenPHP
  • Every framework: Laravel, Symfony, Django-adjacent PHP tools, WordPress, Drupal, Magento, Nextcloud
  • Every hosting model: Shared hosting, VPS, Kubernetes pods, Docker containers, serverless PHP runtimes

If it runs PHP and accepts HTTP requests, it was vulnerable.


Deep Dive: How the Exploits Work

CVE-2026-109618, The OOM Abort Trap

This vulnerability exploits how PHP handles Out-of-Memory (OOM) conditions using C’s setjmp/longjmp mechanism.

The Normal Flow

Under normal operation, when PHP hits memory_limit, it needs to abort the current request immediately. PHP does this using longjmp(), a low-level C function that performs a non-local jump, instantly teleporting execution to a previously saved checkpoint (setjmp). Think of it as an emergency eject button.

1
2
3
4
5
6
7
8
9
Normal request lifecycle:
 
 [Request Start] → [Parse Headers] → [Parse Body] → [Execute Script] → [Cleanup] → [Done]
                     ↓
                  memory_limit hit!
                     ↓
                  longjmp() fires
                     ↓
                 [Emergency Cleanup] → [Done]

The Problem

The critical flaw is: longjmp() doesn’t unwind the stack. Unlike C++ exceptions or Go’s defer/panic, it doesn’t call destructors, free temporary allocations, or finalize partially-initialized structures. It just jumps.

When PHP is mid-way through parsing a multipart form/file upload and an OOM occurs:

  1. Internal data structures (file metadata, boundary markers, upload tracking) are half-initialized
  2. longjmp() fires, bypassing all cleanup for those partial structures
  3. The shutdown handler runs and tries to free everything, including the garbage pointers left behind
  4. This results in heap corruption: freeing uninitialized memory, double-frees, or writing to already-reclaimed addresses
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Simplified pseudo-code of what goes wrong

void parse_multipart_body(request *req) {
  upload_entry *entry = zmalloc(sizeof(upload_entry)); // Step 1: Allocate
  entry->filename = NULL;                // Step 2: Partial init
  // ... more field initialization happening here ...
  
  entry->metadata = zmalloc(large_computed_size);    // Step 3: OOM HERE!
  // longjmp() fires, we never reach the rest of init
  // But entry is already registered in the request's cleanup list
  // with entry->metadata pointing to garbage
  
  entry->filename = zstrdup(parsed_name);        // Never reached
  entry->is_valid = true;                // Never reached
}

void request_shutdown(request *req) {
  for (entry in req->uploads) {
    if (entry->filename) zfree(entry->filename);    // UAF or wild pointer!
    if (entry->metadata) zfree(entry->metadata);    // Freeing garbage!
  }
}

The Exploitation Primitive

An attacker sends a carefully sized multipart POST request designed to:

  1. Trigger OOM at a precise moment, during upload entry construction
  2. Groom the heap beforehand, fill the allocator with controlled data blocks so that when the corrupted free occurs, it hits attacker-influenced memory
  3. Hijack a function pointer or vtable, redirect execution to a ROP chain or shellcode

CVE-2026-109619, The Stale Worker Ghost

This vulnerability targets persistent PHP worker processes (PHP-FPM, mod_php) that handle multiple sequential requests.

The Setup

In production PHP deployments, worker processes don’t die after each request. A PHP-FPM pool might have 50 workers, each handling thousands of requests over their lifetime. This is efficient, but it means global state must be perfectly cleaned between requests.

1
2
3
4
5
6
7
PHP-FPM Worker Lifecycle:

 Worker Process (PID 12345)
  ├── Request #1: GET /index.php  → Process → Cleanup → ✓
  ├── Request #2: POST /upload.php → Process → CRASH → Incomplete Cleanup → ⚠️
  ├── Request #3: GET /about.php  → Process → reads STALE POINTERS → 💀
  └── ...

The Problem

When Request #2 crashes or hits a fatal error condition (via the OOM vector from CVE-2026-109618, or other error paths), certain global variables are not reset:

  • Error backtrace caches retain pointers to freed request memory
  • Session upload progress (session.upload_progress) callbacks hold references to deallocated structures
  • Internal module state hooks point to reclaimed per-request arenas

When Request #3 arrives on the same worker, the engine reads these stale pointers before they’re overwritten. The attacker controls what now occupies that memory (via heap spraying in Request #3’s body), turning a dangling pointer into arbitrary code execution.

The session.upload_progress Amplifier

The session.upload_progress feature, enabled by default in most PHP installations, is the primary amplifier for this attack chain:

1
2
3
4
; php.ini, these defaults make exploitation trivial
session.upload_progress.enabled = 1  ; ON by default!
session.upload_progress.cleanup = 1
session.upload_progress.prefix = "upload_progress_"

This feature parses multipart POST data before any PHP script executes, meaning:

  • No authentication checks have run yet
  • No framework middleware has loaded
  • No WAF rules at the application layer apply
  • The attack surface is the raw PHP engine itself

The Full Attack Chain

Here’s how a real-world PHP2Shell exploit flows, step by step:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Step 1: Reconnaissance
 └── Attacker identifies target running PHP (trivially via headers, error pages, etc.)

Step 2: Heap Grooming (Request A)
 └── Send carefully sized POST requests to shape the worker's heap layout
 └── Fill allocator bins with controlled data patterns

Step 3: Trigger OOM Corruption (Request B)
 └── Send malicious multipart POST with:
   ├── session.upload_progress tracking name
   ├── Deeply nested/oversized boundary structures 
   └── Payload sized to trigger OOM during parse_multipart_body()
 └── longjmp() fires → half-initialized structures left in cleanup queue
 └── Worker survives but global state is now poisoned (CVE-2026-109619)

Step 4: Exploit Stale Pointers (Request C)
 └── New request hits the SAME worker process
 └── Heap spray in POST body occupies freed memory regions
 └── Engine dereferences stale global pointers → reads attacker data
 └── Function pointer hijack → RCE as www-data

Step 5: Post-Exploitation
 └── Reverse shell, lateral movement, persistence

Who Was Affected?

Everyone running PHP. But some configurations were at higher risk:

ConfigurationRisk LevelWhy
Apache + mod_php🔴 CriticalSingle-process model; stale state reuse is trivial
PHP-FPM (default pools)🔴 CriticalPersistent workers; default session.upload_progress enabled
PHP-FPM (strict recycling)🟡 HighWorkers recycled frequently reduce stale state window
php-cgi (fresh process/request)🟡 HighCVE-109618 still applies; CVE-109619 mitigated by process isolation
Containerized + read-only FS🟡 HighRCE still possible; post-exploitation limited

Real-World Impact

  • WordPress (43%+ of the web), universally affected at the PHP layer
  • Magento / Adobe Commerce, e-commerce platforms handling payment data
  • Nextcloud, self-hosted file storage with sensitive documents
  • Laravel Forge / Vapor, managed PHP hosting platforms scrambled for patches
  • cPanel / Plesk, shared hosting panels with hundreds of tenants per server

Emergency Remediation

Patch Immediately

The PHP core team released emergency patches. Upgrade now:

PHP BranchPatched Version
PHP 8.58.5.12
PHP 8.48.4.27
PHP 8.38.3.36
PHP 8.28.2.35
PHP 8.1 and older⚠️ End-of-Life, no official patch
1
2
3
4
5
6
7
8
9
10
11
12
13
# Check your current PHP version
php -v

# Ubuntu/Debian, update via package manager
sudo apt update && sudo apt upgrade php

# Or use Ondřej Surý's PPA for latest versions
sudo add-apt-repository ppa:ondrej/php
sudo apt update && sudo apt install php8.4

# Docker, update your base image
# FROM php:8.4.26-fpm ← VULNERABLE
# FROM php:8.4.27-fpm ← PATCHED ✓

Temporary Mitigations (If Patching Is Delayed)

If you absolutely cannot patch immediately, apply these defense-in-depth measures:

1
2
3
4
5
6
7
8
; php.ini, disable the primary attack amplifier
session.upload_progress.enabled = 0

; Reduce memory limit to shrink OOM trigger window (tradeoff: may break large uploads)
memory_limit = 64M

; Aggressive worker recycling (php-fpm.conf)
pm.max_requests = 100
1
2
3
4
5
6
7
8
9
# Nginx, block oversized multipart bodies at the reverse proxy
client_max_body_size 2M;

# Rate-limit POST requests to slow down heap grooming
limit_req_zone $binary_remote_addr zone=post_limit:10m rate=10r/s;
location ~ \.php$ {
  limit_req zone=post_limit burst=5 nodelay;
  # ... existing fastcgi config
}

Detection: Are You Being Targeted?

Look for these indicators in your access and error logs:

1
2
3
4
5
6
7
8
9
10
11
# Unusual multipart POST floods to random endpoints
grep -c "POST.*multipart" /var/log/nginx/access.log

# PHP-FPM child processes dying unexpectedly
grep -i "child.*exited\|segfault\|signal 11" /var/log/php-fpm.log

# OOM kills in system logs
dmesg | grep -i "oom.*php"

# Abnormal session file creation patterns
ls -la /tmp/sess_* | wc -l

Lessons for the PHP Ecosystem

1. Memory-Unsafe Languages in Critical Infrastructure

PHP is written in C. The longjmp() / setjmp() pattern is a known footgun in C programming, it bypasses RAII, doesn’t respect stack unwinding, and leaves resource management entirely to the programmer’s discipline. This class of bug is structurally impossible in memory-safe languages like Rust or Go.

2. Default-On Features Are Attack Surface

session.upload_progress being enabled by default, with pre-authentication parsing of untrusted input, is a textbook example of unnecessary default attack surface. The principle of least privilege should extend to default configurations.

3. Persistent Workers Need Paranoid State Isolation

Any server architecture that reuses processes across trust boundaries (different HTTP requests from different users) must treat state isolation as a security-critical invariant, not a performance optimization detail.

4. Defense in Depth Matters

Organizations that had:

  • Reverse proxies with request size limits
  • WAFs blocking anomalous multipart patterns
  • Aggressive PHP-FPM worker recycling
  • Network segmentation limiting www-data blast radius

…bought themselves critical time between disclosure and patching.


Timeline

DateEvent
2026-09-14Vulnerability discovered by Wiz research team
2026-09-15Responsible disclosure to PHP security team
2026-10-01PHP core team confirms both CVEs
2026-10-07Emergency patches released for PHP 8.2–8.5
2026-10-08CERT-EU and NCSC-NL publish advisories
2026-10-10First in-the-wild exploitation attempts detected
2026-10-14Major hosting providers (cPanel, Plesk, Cloudways) push forced updates
2026-10-21You’re reading this. Have you patched yet?

Quick Reference Checklist

  • Check your PHP version: php -v, are you on a patched release?
  • Disable session.upload_progress if not needed: session.upload_progress.enabled = 0
  • Update Docker base images to patched PHP versions
  • Scan your infrastructure for forgotten PHP installations (monitoring panels, legacy apps, phpMyAdmin)
  • Review PHP-FPM pool configs: set pm.max_requests to force periodic worker recycling
  • Add reverse proxy limits: cap client_max_body_size and rate-limit POST requests
  • Check vendor appliances: firewalls, NAS devices, and routers often bundle PHP internally
  • Monitor logs for segfaults, OOM kills, and unusual POST traffic patterns

Final Thoughts

PHP2Shell is a sobering reminder that the most dangerous vulnerabilities aren’t in your code, they’re in the runtime beneath it. You can write the most secure Laravel application in the world, follow every OWASP guideline, have 100% test coverage, and still be popped by a crafted POST request that never even reaches your router.

The PHP core team responded quickly with patches. The community rallied with detection tools and mitigations. But the window between disclosure and universal patching is where real damage happens.

Patch your servers. Today. Not tomorrow. Not after the sprint review. Now.

Disclaimer: This article is written for educational and defensive security purposes. Understanding how vulnerabilities work is essential for building secure systems. Always practice responsible disclosure and authorized testing.

This post is licensed under CC BY 4.0 by the author.