Still on PHP 7.4 and Can't Upgrade? Mitigating the October 2026 RCEs Today

SERIES · NO PATCH COMING
1 / 2 View series

PHP shipped fixes for two critical remote code execution bugs in October 2026: CVE-2026-109618 and CVE-2026-109619, both CVSS 9.2, in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12. If you’re on PHP 7.4, 8.0, 8.1, or anything older, you get no official fix, and having read the patch, I’m confident the bug is present in your version too. In the next hour: reject multipart/form-data at your web server on every route that doesn’t accept file uploads, and turn off session.upload_progress. Then start planning the port, because nothing below makes you safe.

This is the companion to the release write-up. That post is “what shipped and who’s affected.” This one is the uncomfortable one: you’re on an end-of-life PHP, you can’t move to 8.3 this week, and you need to not get owned in the meantime.

First, the bad news: yes, you’re affected

I wanted to be able to tell legacy shops “the advisory only lists 8.2 and up, maybe you dodged it.” You didn’t.

The GitHub advisories scope the affected range to < 8.2.35, < 8.3.36, and so on. That looks like older versions are off the hook. They aren’t. That range only covers the branches PHP still supports. It is not a statement that 7.4 is safe, and CERT-EU’s advisory 2026-018 says the opposite in plain language: every version from PHP 5 onward is affected.

I checked the claim against the source rather than take it on faith. The single upstream fix commit is titled “Avoid state corruption via bailout,” and it does two things:

  • Replaces the out-of-memory bailout with a controlled unwind. When PHP runs out of memory mid-request, it historically aborts by jumping straight out with longjmp. If that jump fires while a request’s internal state is only half-built, request shutdown later trusts the half-built state. That longjmp-on-OOM path is not an 8.x invention. It sits in Zend/zend_alloc.c and Zend/zend.c, unchanged in spirit, all the way back. It is literally LONGJMP(*EG(bailout), FAILURE) in the PHP 5.4 tree.
  • Resets request-owned globals before the next request can read them. Error state, session state, and serializer state left over from a request that died early get read by the next request’s startup work. Same lifecycle in 7.4 as in 8.1.

The attacker’s entry point, the multipart form parser in main/rfc1867.c, is present and structurally identical in 7.4. So the preconditions are all there. The honest summary: the bug is longstanding engine behavior that was only now recognized and hardened, which is exactly why treating 7.4 and 5.4 as vulnerable is the safe call and why the “maybe we’re fine” hope doesn’t survive contact with the diff.

I did not build a working exploit to prove reachability on 7.4, and I’m not going to publish one. For a critical RCE with no patch for the most widely deployed unpatched versions, a public proof of concept is a mass-targeting tool, not a blog flex.

Why no one is going to backport this for you

The reflex is “someone will cut a patch for 7.4.” Look at the shape of the fix before you count on it. It replaces the out-of-memory bailout mechanism itself and, as a result, had to touch the allocator, the hash table, the virtual machine, and allocation-failure handling in roughly forty extensions. It also relies on the request-unwinding machinery that PHP 8 introduced and 7.x does not have.

That is not a patch you cherry-pick onto 7.4 over a weekend. Distro backports for end-of-life branches are unlikely for the same reason. If your plan is “wait for a backport,” you don’t have a plan. The realistic options are: isolate the app, apply the mitigations below as a stopgap, and schedule the move to a supported branch.

What actually reduces your exposure today

Everything here comes straight from PHP’s own “if you cannot update immediately” guidance. Read their framing first, because it sets the ceiling on your expectations:

While only an update is a complete solution, the following measures reduce exposure to specific attack paths but do not make an installation safe.

That is the vendor saying these are stopgaps. Treat them that way. In rough order of how much risk they remove:

1. Block multipart at the edge, before PHP parses it

Both bugs are reached through multipart/form-data request bodies, so the instinct is to add a check in your app. Here is the thing that bit me when I thought it through: by the time your framework runs, it is already too late. PHP parses the multipart body in main/rfc1867.c during request startup, before a single line of Laravel, CodeIgniter, or Fat-Free executes. The vulnerable parse and the session.upload_progress write both happen in that startup window. A middleware, a hook, a beforeroute handler, even an auto_prepend_file, all run after the dangerous work is done.

So the mitigation that actually works lives at or before the web server. Everything in the app layer is hygiene.

nginx + php-fpm. Reject multipart globally, allow it only on the routes that upload. Map the content type once, then gate each location:

# http {} block
map $content_type $is_multipart {
    default                 0;
    "~*multipart/form-data" 1;
}
limit_conn_zone $binary_remote_addr zone=uploads:10m;

# server {} block
location / {
    if ($is_multipart) { return 415; }
    try_files $uri /index.php$is_args$args;
}

# the ONE place uploads are allowed (tune the path per framework, below)
location = /profile/avatar {
    client_max_body_size 32m;      # hard cap at the edge
    limit_conn uploads 4;          # cap concurrent uploads (budget math in §4)
    try_files $uri /index.php$is_args$args;
}

The upload location is the only framework-specific part, because it depends on how each one routes:

  • Laravel 6: uploads hit the route you defined, e.g. POST /profile/avatar. Everything funnels through public/index.php, so the allow-list is just your real upload routes, matched exactly (location = /profile/avatar).
  • CodeIgniter 3: URLs map to controller/method, e.g. POST /uploads/save. Match location ~ ^/uploads/(save|batch)$. If you route through index.php without URL rewriting, gate on the query-string convention your app uses instead.
  • Fat-Free (F3): uploads hit the pattern you registered with $f3->route('POST /upload', ...). Front-controlled through index.php like Laravel, so location = /upload is the carve-out.

Apache + mod_php. No map, so use mod_setenvif plus an access rule, and carve out the upload path:

SetEnvIfNoCase Content-Type "multipart/form-data" IS_MULTIPART
<Location "/">
    <RequireAll>
        Require all granted
        Require not env IS_MULTIPART
    </RequireAll>
</Location>
<Location "/profile/avatar">
    Require all granted
    LimitRequestBody 33554432
</Location>

The framework layer, honestly. You can still return a clean 415 from inside the app, and it is worth doing for logging and the API contract, but say out loud what it is: it runs after the vulnerable parse, so it does not mitigate these CVEs. It is a tripwire and a nicer error, not protection. For Laravel 6 that is a global middleware registered in App\Http\Kernel::$middleware; for CodeIgniter 3 a pre_controller hook; for Fat-Free a beforeroute handler. Each checks $_SERVER['CONTENT_TYPE'] and bails with 415. The one PHP-level knob that is early enough is enable_post_data_reading = Off, set per path in your pool or server config, but it disables all POST parsing for that path, so it only fits routes that legitimately take no body. The official guidance also asks you to cap decoded request size, field count, multipart part count, header count, and nesting depth at the proxy, and to make those caps apply even to requests that don’t send a usable Content-Length.

2. Disable session upload progress

This closes the confirmed path for CVE-2026-109619. If you don’t use the feature, and almost nobody does, this is free.

; php.ini or an FPM pool — NOT .user.ini, and ini_set() won't work either
session.upload_progress.enabled = 0

The gotcha that will waste your afternoon: this setting is read before your application runs, so setting it in .user.ini or with ini_set() does nothing. It has to be in php.ini, an FPM pool, or server config, and you have to restart every worker afterward. PHP’s advisory calls this out explicitly, which tells you how many people are about to get it wrong.

3. Take away the memory limit PHP can trip over

This one feels insane the first time you read it, so here it is in PHP’s words: set memory_limit=-1 and enforce memory from outside instead. The bug is in how PHP handles hitting its own ceiling, so you remove the ceiling it can trip over and let the operating system be the backstop with a blunt, uncorruptable kill. Do not reach for RLIMIT_AS or RLIMIT_DATA as the outside limit; the advisory warns those cause the very allocation failure you are trying to avoid.

Here is the whole thing wired together for a PHP 7.4 / php-fpm / nginx / Laravel 6 stack in Docker.

docker-compose.yml — the cgroup memory cap is the backstop:

services:
  app:
    build: .                    # php:7.4-fpm base + the Laravel 6 app
    mem_limit: 1g               # cgroup hard cap; kernel OOM-kills on breach
    restart: unless-stopped
  web:
    image: nginx:1.27
    depends_on: [app]
    ports: ["8080:80"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

FPM pool (/usr/local/etc/php-fpm.d/www.conf) — lock the limit off so the app can’t put it back, and bound concurrency so the math in §4 holds:

[www]
php_admin_value[memory_limit] = -1      ; the app cannot override an admin value
pm = static
pm.max_children = 12                     ; peak workers = the multiplier in your budget
pm.max_requests = 200                    ; recycle workers (also helps CVE-2026-109619)
request_terminate_timeout = 30s

nginx (nginx.conf):

server {
    listen 80;
    root /var/www/html/public;
    client_max_body_size 32m;
    location / { try_files $uri /index.php$is_args$args; }
    location ~ \.php$ {
        fastcgi_pass app:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

What you have now: when something exhausts memory, the cgroup trips first and the kernel sends SIGKILL to the worker. The FPM master notices the dead child and forks a fresh one. PHP never reaches the longjmp-on-OOM path that corrupts request state, because the process is gone before it can run shutdown over half-built data. You trade a corrupted-state RCE primitive for a clean worker restart and one failed request.

Verify it behaves that way instead of trusting the config:

# cgroup v2: this counter climbs each time the kernel reaps a worker
docker compose exec app cat /sys/fs/cgroup/memory.events | grep oom_kill
# FPM should log the child dying on a signal and a respawn
docker compose logs app | grep -E "exited on signal|started"

Two honest caveats. The container cap kills at container granularity, so “kill only the affected worker” is aspirational unless each pool gets its own cgroup; in a single-container setup a bad enough spike takes the container down and restart: unless-stopped brings it back. And mem_limit is an availability control, not a safety one. It stops the corruption path. It does not make you patched.

4. Size the request body against your memory, and cap uploads in flight

post_max_size is where most people stop, with a number they picked by vibes. Two things deserve actual thought: what the number should be, and what happens when many uploads arrive at once.

The math. A single request is bounded by post_max_size, but multipart parsing allocates well beyond the raw bytes on the wire: every field becomes a PHP string plus a zval, arrays nest, temp buffers get copied. Budget for a 2–4x amplification over the body size. The real ceiling is aggregate:

peak upload memory  ≈  concurrent upload workers  ×  post_max_size  ×  amplification

That has to stay under what the box can spare: total RAM, minus the OS and other services, minus FPM’s own per-worker baseline. Worked example on a 4 GB box:

budget for uploads     = 4096 MB − 1024 (OS+services) − (12 workers × 60 MB baseline)
                       ≈ 2350 MB
max concurrent uploads = 8          (nginx limit_conn on the upload location)
amplification          = 3x
=> post_max_size  ≲  2350 / (8 × 3)  ≈  98 MB   → round down to 64M

Flip it around and the trap is obvious: bump post_max_size to 256M on that same box and 8 concurrent uploads alone want ~6 GB. You have hand-built the OOM flood the first CVE feeds on. Pick post_max_size from the budget and the concurrency you allow, not from the largest file anyone might someday send. Keep upload_max_filesize at or below it.

post_max_size = 64M         ; derived above; 8M if this app takes no uploads at all
upload_max_filesize = 64M   ; at or below post_max_size

Capping uploads in flight. limit_conn in nginx is the cheap first line, and you saw it in §1. When you need a global budget across many workers or several nodes, track in-flight uploads in Redis as a sorted set, scored by declared size, and refuse admission before the aggregate would blow the budget:

-- OpenResty access_by_lua, runs BEFORE the body is read — placement is everything
local r = connect_redis()                         -- env-driven TLS connect, defined below
if not r then return ngx.exit(503) end            -- budget store down: fail closed (see note)

local budget = 2350 * 1024 * 1024                 -- bytes, from the math above
local size   = tonumber(ngx.var.http_content_length) or 0
local id     = ngx.var.request_id
local now    = ngx.now()

r:zremrangebyscore("uploads_inflight", 0, now - 60)   -- evict stale/abandoned entries
local inflight = r:eval(
  "local t=0 for _,m in ipairs(redis.call('ZRANGE',KEYS[1],0,-1)) do " ..
  "t=t+tonumber(string.match(m,':(%d+)$')) end return t", 1, "uploads_inflight")

if tonumber(inflight) + size > budget then
    return ngx.exit(429)                          -- shed load; body is never parsed
end
-- score = timestamp (for eviction); member carries the byte size we sum above
r:zadd("uploads_inflight", now, id .. ":" .. size)
r:set_keepalive(30000, 100)                       -- hand the TLS conn back; never close on success
-- a log_by_lua phase ZREMs this id when the request finishes

The detail that makes or breaks it: this has to run at the edge, in nginx/OpenResty, before the request body is read. The same accounting written as Laravel middleware runs after rfc1867.c has already parsed the body, so it protects the next request but not the one that just triggered the parse. In PHP it is load-shedding; at the edge it is an actual barrier in front of the OOM vector.

One deployment footgun before you ship this: connect("redis", 6379) opens a plaintext socket, which is fine for a Redis on the same box or a locked-down private subnet. Point it at a managed instance with encryption in transit, AWS ElastiCache with the TLS option or most hosted Redis, and the plain connect simply fails, because the server wants a TLS handshake first. lua-resty-redis can do it, but you have to opt in: r:connect(host, port, { ssl = true, ssl_verify = true, server_name = host }), followed by r:auth(token) when an AUTH token is set, which in-transit encryption usually implies. Verification needs a CA bundle wired once at the http level (lua_ssl_trusted_certificate, lua_ssl_verify_depth), and server_name has to match the endpoint’s certificate for SNI. The handshake also adds latency in the access phase, which runs on every upload, so the set_keepalive pool stops being optional: skip it and you pay a full TLS handshake per request, in the exact hot path you were trying to protect.

Here is that wired up for ElastiCache, with the endpoint pulled from the environment instead of baked into the Lua. One nginx-specific trap first: nginx scrubs its own environment, so any variable you don’t explicitly declare with env in the main context comes back nil from os.getenv in the workers.

# main context (top of nginx.conf), outside and above the http {} block
env REDIS_HOST;          # ElastiCache PRIMARY endpoint (cluster mode disabled)
env REDIS_PORT;
env REDIS_AUTH_TOKEN;    # present when encryption-in-transit / AUTH is enabled

http {
    # let ssl_verify validate the ElastiCache certificate chain against the system CAs
    lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
    lua_ssl_verify_depth 3;
    # ... server {} blocks, the access_by_lua above, etc.
}
-- init_by_lua (runs once per worker); the access_by_lua block calls connect_redis()
local redis = require "resty.redis"

local REDIS_HOST  = os.getenv("REDIS_HOST")           -- e.g. my-cache.abc123.ng.0001.use1.cache.amazonaws.com
local REDIS_PORT  = tonumber(os.getenv("REDIS_PORT")) or 6379
local REDIS_TOKEN = os.getenv("REDIS_AUTH_TOKEN")     -- nil when AUTH is off

function connect_redis()
    local r = redis:new()
    r:set_timeouts(1000, 1000, 1000)                  -- connect/send/read ms: never block the edge
    -- Encryption in transit means the server speaks TLS first. server_name must match
    -- the endpoint cert for SNI + verification; the pool is keyed so TLS sessions reuse.
    local ok, err = r:connect(REDIS_HOST, REDIS_PORT, {
        ssl = true, ssl_verify = true,
        server_name = REDIS_HOST,
        pool = "ec_" .. REDIS_HOST,
    })
    if not ok then
        ngx.log(ngx.ERR, "elasticache connect failed: ", err)
        return nil
    end
    -- AUTH only on a brand-new socket; a pooled one is already authenticated.
    if REDIS_TOKEN and r:get_reused_times() == 0 then
        local ok2, err2 = r:auth(REDIS_TOKEN)         -- r:auth(user, token) for an RBAC user
        if not ok2 then
            ngx.log(ngx.ERR, "elasticache auth failed: ", err2)
            return nil
        end
    end
    return r
end

Two ElastiCache specifics that will bite if you skip them. First, lua-resty-redis speaks the single-node protocol, not the cluster protocol, so point REDIS_HOST at a cluster-mode-disabled primary endpoint and every operation on the one uploads_inflight key lands on the right shard. Aim it at a cluster-mode-enabled configuration endpoint and it hits an arbitrary shard and gets MOVED redirects it won’t follow; if you’re committed to cluster mode, swap in lua-resty-redis-cluster, which hashes the key to a slot and chases the redirect. Second, keep this budget’s reads and writes on the primary, not a reader endpoint: the ZADD has to be visible to the very next request’s ZRANGE, and a replica even a few milliseconds behind hands you an in-flight total that reads low at exactly the moment you’re under the flood you’re trying to shed.

The fail-closed choice above — ngx.exit(503) when Redis is unreachable — is deliberate. With an unpatched RCE sitting behind this budget, dropping uploads during a cache blip beats admitting an unbounded flood. Invert it to fail open only if an upload outage is a bigger business risk to you than the OOM vector is; just make it a decision you made on purpose, not a default you inherited.

5. Recycle workers aggressively

The stale-state bug needs leftover state to survive into the next request on the same worker. Lowering pm.max_requests so workers restart often shrinks that window. It costs you some performance and it is not a fix, but it narrows the exposure.

6. Stop emitting diagnostics before the app starts

The advisory asks you to remove invalid or deprecated settings that emit warnings per request before initialization, and to fix attacker-reachable fatal errors and timeouts in callbacks, upload handling, and cleanup. One trap worth repeating: hiding the diagnostics with error_reporting, display_errors, or the @ operator does not help. The error state still gets populated. You have to remove the cause.

None of this makes you safe

I’ll say it as plainly as the advisory does: these measures reduce exposure to specific paths and do not make the installation safe. If the app is internet-facing and you can take it behind authentication at the edge or off the public internet while you work, do that. The only complete fix is running a supported, patched PHP.

For a 7.4 Laravel or CodeIgniter monolith, that means the port you’ve been deferring. The good news, and the thing I spend my days on, is that the mechanical parts of a 7.x-to-8.3 migration are exactly what coding agents are now good at, under a human who writes the failing tests first and reviews every diff. That’s a different post. This week, block multipart and disable upload progress.

Find out where you’re actually exposed

You can’t mitigate PHP you forgot you were running. The php-cve-lab repo stands up CodeIgniter 3, Laravel 6, and Fat-Free on PHP 7.4 across both Apache mod_php and nginx + php-fpm, with an inventory script that reports the PHP version, the serving SAPI, and the loaded extensions for a target, plus a decision doc for picking a mitigation that won’t break your app. It is detection and mitigation-testing only. It carries no exploit code, and it never will, for the reason above.


If you’re running a PHP 7 app and need a concrete plan to get off it without a weekend of outages, that’s the Modernization Roadmap: fixed scope, a port strategy you can hand to your team.

I help teams modernize legacy systems with AI-assisted development guardrails that keep the agents productive and the production safe. fitzpatricksoftware.com