Europe/Paris
Posts

PHP2Shell: what CVE-2026-109618 and 109619 actually do

October 22, 2026 · 4 min de lecture
Tuesday evening, the PHP security team announced a release for the next day at 14:00 UTC. On Wednesday 8.5.12, 8.4.27, 8.3.36 and 8.2.35 shipped, and a good part of the French administration switched to maintenance pages. Here is what is under the hood, and what to check on your own servers. Neither flaw lives in Symfony, Laravel or WordPress. Both trigger inside the engine, often before the first line of public/index.php runs. An up-to-date framework changes nothing. When an allocation failed (the classic Allowed memory size exhausted), PHP left the current context with a longjmp(). During request startup, especially while automatically parsing a multipart/form-data body, request memory already exists but is not fully initialized. The jump leaves half-filled structures behind, and the shutdown phase then treats them as valid. Use-after-free, uninitialized pointers: you know the rest. The annoying part is that an attacker can force the OOM without ever exceeding post_max_size:
  • a multipart body with no usable declared length, which passes the initial size check;
  • hundreds of headers per part, since their in-memory PHP representation weighs far more than the bytes on the wire;
  • empty file inputs, which are not counted in max_file_uploads but still allocate their metadata arrays;
  • deeply nested field names such as a[b][c][d][e].
The fix removes the longjmp() on OOM. A "soft" OOM is now deferred to the next safe point of the VM, and a second, higher threshold kills the process with _exit(). An FPM or mod_php worker serves request after request, and is supposed to start from a clean slate each time. The bug: a request interrupted by a fatal error (OOM, timeout, failed require...) can leave a global pointer to memory that has already been freed. The next request landing on the same worker reads or frees that pointer before the normal initialization resets it. The advisory confirms two "early" consumers:
  • INI diagnostics raised before request initialization: an invalid php_value in a .htaccess, or a PHP_VALUE sent by nginx over FastCGI. @ and error_reporting change nothing here;
  • session.upload_progress, enabled by default, which can consume session state left over by the previous request without any session_start() in your code.
On 8.5, fatal_error_backtraces adds another path, the one used for the confirmed exploit. Hence the two-step attack: one request that crashes at exactly the right place, then a second one that picks up the leftovers.
  • Apache mod_php: full exploit demonstrated on 109618, RCE confirmed on 109619.
  • PHP-FPM: vulnerable to 109618 per code analysis, upload_progress path validated on 109619.
  • FrankenPHP: likely vulnerable to 109618, no known early consumer for 109619 with the default config.
  • LSAPI: likely vulnerable to 109618, conditional candidate for 109619.
mod_php is the worst case: .htaccess directives are applied before request startup, and shutdown runs even when startup failed. For FrankenPHP, the advisory is careful to say that having no known path does not mean immunity. v1.13.2 ships the patched PHP versions.
Bash
# Debian / Ubuntu
sudo apt update && sudo apt install --only-upgrade 'php8.*'

# Without a restart, running workers keep the old code in memory
sudo systemctl restart php8.3-fpm
Two traps when verifying:
  • php -v asks the CLI, which is not necessarily the binary serving the site. Ask the FPM binary directly;
  • distributions backport fixes without bumping the version number. Ubuntu 24.04 still prints 8.3.6 after the fix. The package version is what matters.
Bash
php-fpm8.3 -v
dpkg -l | grep -E 'php8\.[0-9]-(fpm|cli|common)'
apt changelog php8.3-fpm | grep -m3 'CVE-2026-1096'
If your PHP comes from Remi's repository on RHEL/Alma/Rocky, there was no update on day one. The PHP advisory points to the distribution packages or a source build. If you can't patch tonight, the advisories list settings that shrink the attack surface. They must be set at admin level, in php.ini, the FPM pool or the web server config. A .user.ini or an ini_set() comes too late: the vulnerable code runs before.
Ini
; /etc/php/8.3/fpm/pool.d/www.conf
php_admin_value[memory_limit] = -1
php_admin_value[post_max_size] = 8M
php_admin_value[max_input_nesting_level] = 16
php_admin_flag[session.upload_progress.enabled] = off

; PHP 8.5 only
php_admin_flag[fatal_error_backtraces] = off
Then restart every worker. On the front side, refuse multipart where the app doesn't need uploads:
Nginx
client_max_body_size 8m;

location / {
    if ($content_type ~* "multipart/form-data") {
        return 415;
    }
    try_files $uri /index.php$is_args$args;
}

location /api/uploads {
    try_files $uri /index.php$is_args$args;
}
  • memory_limit=-1 removes the guard rail. A single request can eat all the container's RAM. Put a cgroup limit in front (mem_limit in Compose, resources.limits in Kubernetes) and remove the setting once patched. Do not use ulimit -v / RLIMIT_AS: the advisory explains that it makes mmap() fail and reopens the vulnerable path.
  • Application-level maintenance mode protects nothing. A maintenance bundle or WordPress's .maintenance file runs after PHP has already parsed the request. Real maintenance means a static page served by nginx without going through FPM.
  • Cloudflare's temporary WAF rules only help if your origin refuses direct traffic. Otherwise the attacker just hits the server IP.
The advisory recommends session.upload_progress.enabled=0 even after patching if you don't use the feature. Almost nobody does. Might as well turn it off in the base php.ini of every image: it costs nothing.
On this page
Book a call