200%
default hard limit as a share of memory_limit, up to 1 GiB
137
exit status when a request crosses the hard limit
70
exit code of a PHP-FPM child whose POST body exhausted memory_limit, before and after the fix, in our tests
4
release branches that ship the change: 8.2, 8.3, 8.4 and 8.5
Your out of memory alerts were written for the old PHP
The PHP security releases of 21 Oct 2026 fixed CVE-2026-109618 and CVE-2026-109619 by changing how PHP behaves when a request runs out of memory, and that change will outlast the CVEs. Most requests that cross memory_limit look exactly as they did last week. A new class of request now ends the whole PHP process on the spot, with no cleanup and no line in the PHP error log.
The fix is right. Before it, an allocation that crossed memory_limit jumped straight out of whatever PHP was doing, and if that happened while request memory was half built, shutdown later read the wreckage. That was the root of the remote code execution we covered in our analysis of the two CVEs. The patch stops the jump. It also adds a second ceiling, and that ceiling is where the new failure lives.
For years, out of memory in PHP has meant one log line, "Allowed memory size of N bytes exhausted", followed by a normal shutdown. The new failure writes a different line, sends it somewhere PHP-FPM discards by default, and exits with status 137, the number a shell reports when the kernel kills a process with signal 9. A team that only watches the PHP error log will see a worker vanish and blame the kernel.
The blast radius now depends on the process model. On PHP-FPM the hard exit costs one child. On FrankenPHP, which serves every request from threads of one process, it costs the server. On Apache with the event MPM, it costs every request on that child. The advisory itself says bounding input sizes reduces "hard OOM and denial of service risk even after applying the patch". A request that drives one allocation past the hard limit is a lever on availability, and on threaded servers the patch made that outcome harsher and quieter.
So the question this week is not only "are we on 8.5.12". It is whether the people watching the fleet would notice the new failure and tell it apart from a kernel kill.
What changed in PHP 8.5.12 when memory_limit is exceeded
The change is one commit, 31c0ea7d7f on the PHP-8.5 branch, titled "Avoid state corruption via bailout", carried on every supported branch: 01a40e89fb in 8.2.35, 3c832b523e in 8.3.36 and 506e87fa7d in 8.4.27. Its message is two lines: "Replace bailout on OOM with UnwindExit" and "Properly reset various globals that may be accessed during startup". The PHP security advisory lists the four releases and every SAPI that serves web requests as at risk.
The old behaviour is in the lines the commit deletes from Zend/zend_alloc.c. When an allocation would cross the limit, the memory manager raised the fatal error and called zend_bailout(), a longjmp back to the last recovery point. The advisory puts it plainly: "PHP uses longjmp() for out-of-memory (OOM) errors to exit the current context and abort handling of the request."
The new behaviour lives in zend_mm_check_limit. When an allocation would cross memory_limit, which the code now calls the soft limit, the allocation goes ahead. PHP records the message, sets the engine's interrupt flag and returns to the caller; code that opted in to fallible allocation gets a null pointer instead and stops on its own. The queued error is raised by zend_handle_delayed_oom at what the advisory calls "the next VM safe point", using the same internal unwind signal as exit(), so the script stops and normal shutdown follows.
The second ceiling is new. If an ordinary allocation would cross the hard limit, PHP queues nothing. It writes "Hard memory limit of N bytes exhausted" straight to standard error and calls _exit(137). The advisory calls it "an immediate hard OOM using _exit() if memory consumption reaches a second, higher threshold".
-
Fixed in //
8.2.35, 8.3.36, 8.4.27, 8.5.12 -
Soft limit //
memory_limit; the fatal waits for the next safe point -
Hard limit //
200% of memory_limit up to 1 GiB, memory_limit plus 1 GiB above -
New directive //
memory_hard_limit_percent: 100 or more, or -1 for the default; changeable at runtime -
memory_limit=-1 //
no soft limit and no hard limit -
Past the hard limit //
message on stderr, _exit(137), no shutdown
The quiet part is the documentation. The ChangeLog entry for 8.5.12 reads "Avoid state corruption via bailout" and nothing else about memory. Neither advisory names memory_hard_limit_percent, and neither do php.ini-production or php.ini-development in the tagged releases. The clearest operator note we found outside the source is FrankenPHP's known issues page, written because the change broke its own tests.
Allowed memory size exhausted in PHP 8.5: where the fatal now fires
The message did not change. It is still "Allowed memory size of 8388608 bytes exhausted (tried to allocate N bytes)", and alert rules keyed on "Allowed memory size of" still match. The figure at the end can move, because the new build reports the allocation that first crossed the line. In our tests the same oversized POST body was reported as an attempt to allocate 8192 bytes on 8.2.35 and 4198400 bytes on 8.3.14.
Inside a script, the fatal fires at the next function call or loop iteration, as FrankenPHP's documentation puts it, so a few more operations can run past the line. Shutdown functions still run and error_get_last() still returns the message; php-src added tests for both. No catch block sees it, before or after the change, and finally blocks are skipped, because the engine treats it like exit().
During request startup, before any of your code runs, there are two cases.
The first is input PHP parses without a body handler: the query string and cookies. The parser sees the pending error and stops, and startup carries on. Extension startup runs, the script begins, the fatal fires at the first safe point and shutdown is ordinary. In our tests on a FrankenPHP build of 8.5.12 with memory_limit=8M, a first request carrying a 5 MiB query string returned the ordinary "Allowed memory size of 8388608 bytes exhausted" fatal after startup had completed. This path needs a small memory_limit or a front end that accepts huge request lines; Apache's LimitRequestLine defaults to 8190 bytes.
The second is the POST body, URL encoded or multipart. The parsers now check for a pending error after each step and stop cleanly. When the body handler returns, PHP raises the fatal, and because no PHP code is running yet, it will "abort the request, the way an immediate OOM does", in the words of the source comment. In our tests, PHP-FPM 8.2.35 and 8.3.14 both logged the fatal against "Unknown on line 0", both children exited with code 70, and both masters started a replacement. The difference is internal: the parser unwound before the abort, so shutdown no longer reads half-built state. A php-src test, "Request parsing doesn't reach hard limit", holds the parsers to that.
Figure 01
One request on 8.5.12, from the first byte to the hard limit
-
T0 //
PARSE
PHP reads the query string, cookies and body before your code runs.
Watch
request sizes at the edge
-
T1 //
SOFT LIMIT
An allocation crosses memory_limit. It succeeds, the fatal is queued and the parsers stop.
Watch
nothing yet: the message waits
-
T2 //
SAFE POINT
The next function call, loop iteration or the end of body parsing raises the usual fatal.
Watch
"Allowed memory size of", against Unknown on line 0 if no script had started
-
T3 //
SHUTDOWN
Shutdown functions, extension cleanup, the session write and upload cleanup run.
Watch
the worker stays up, or an FPM child exits with code 70
-
T4 //
HARD LIMIT
If allocation keeps climbing before T2 and reaches twice memory_limit, the process exits.
Watch
"Hard memory limit of" on stderr, exit status 137
What it means for FrankenPHP, mod_php, PHP-FPM and worker loops
PHP-FPM. One child serves one request at a time. The advisory notes that "FPM exits the child when request startup fails"; that is the exit code 70 above. A hard exit kills the child with status 137, the master logs "[pool www] child N exited with code 137" as a warning and starts another. The request never reaches the FPM access log, because the child writes that line after the request finishes.
Apache with mod_php. Under prefork, each child handles one connection and the picture matches FPM. Under the event MPM, Apache's documentation describes "a hybrid multi-process multi-threaded server" in which each child runs ThreadsPerChild threads. A hard exit in one request ends the child and every request its other threads were serving. Before this release, the same oversized request produced a fatal on one thread and the child carried on.
FrankenPHP. Its known issues page is direct: it "serves all requests from threads of a single process, so every in-flight request is lost and the server stops until it is restarted." It advises a supervisor, such as a container restart policy or systemd's Restart=. On the soft path FrankenPHP gained the most: a query string that exhausts memory at startup used to fail startup, and now ends in the ordinary fatal while the thread keeps serving.
Worker loops. Octane style servers and FrankenPHP's worker mode boot the application once and feed it requests in a loop. A soft fatal unwinds the whole worker script, not just the request; in our tests the worker exited with status 255 and was restarted. That is the safe outcome, because the state the worker held is thrown away rather than carried into the next request. A hard exit ends whichever process hosts the loop: the server on FrankenPHP, one worker where each worker is its own process.
On a threaded server, memory_limit and the hard limit have become availability settings, and the thread count multiplies whatever one bad request costs.
Hard memory limit exhausted: what still exits hard and what it leaves behind
Three paths in the new memory manager end the process without PHP's shutdown.
The hard limit prints "Hard memory limit of N bytes exhausted (tried to allocate M bytes) in FILE on line L" with fprintf to standard error and calls _exit(137). It never passes through PHP's error handler, so error_log and log_errors play no part.
If the operating system refuses memory, PHP now prints "Out of memory" to standard error and calls exit(1), where the old code raised a fatal and bailed out. And an ordinary allocation whose size calculation overflows now reports "Possible integer overflow in memory allocation" and calls abort(); on FPM that is a child that exited on signal 6.
The body parsers are built to stop at the soft limit. The query string and cookie header are copied in one ordinary allocation, so one larger than the hard limit would end the process, which front end request line limits normally rule out. The realistic route is application code that builds one large value from request input before reaching a safe point. FrankenPHP's page names "a single allocation larger than memory_limit (such as array_merge() on large arrays) or consecutive string concatenations"; in our tests a chain of concatenations in one expression crossed it at memory_limit=8M.
Exit status 137 is 128 plus 9, what a shell and most container runtimes report for a process killed by signal 9, which is how the kernel's out of memory killer ends a process. FPM's master helps here: it logs a kernel kill as "exited on signal 9 (SIGKILL)" and the PHP hard limit as "exited with code 137". A container exiting with 137 could be either, so check the runtime's out of memory flag and the kernel log.
What the hard exit leaves behind is everything shutdown normally does:
- Shutdown functions, extension shutdown and destructors do not run, and the request's session data is not written.
- Files uploaded with the request stay in upload_tmp_dir, because PHP deletes them in the teardown that never runs.
- Output buffers are not flushed, so the client or proxy sees a truncated response or a reset.
- On FPM, the access log line is never written, and the stderr message goes to /dev/null unless the pool sets catch_workers_output.
The upload files are the item that accumulates. Repeated hard exits on upload routes slowly fill the temporary directory with files no process owns.
Figure 02
PHP hard memory limit exit, or memory exhaustion before any script
Sigma
title: PHP hard memory limit exit or startup memory exhaustion
status: experimental
description: Hard limit exits on PHP 8.2.35 and later, which bypass the PHP error log, and memory exhaustion before any script ran.
logsource:
product: php
service: fpm
detection:
hard_limit:
message|contains: 'Hard memory limit of'
fpm_child_137:
message|contains|all:
- '] child '
- 'exited with code 137'
startup_soft:
message|contains|all:
- 'Allowed memory size of'
- ' in Unknown on line 0'
condition: hard_limit or fpm_child_137 or startup_soft
falsepositives:
- Legitimate large requests against a low memory_limit
level: medium What to check in your own deployment
The effective memory_limit, per pool and per site. Read it from the SAPI that serves traffic, not the command line binary, which usually loads a different php.ini. If you set memory_limit=-1 as the advisory's interim mitigation, that also removed the hard limit. After upgrading, put a finite value back, or the cgroup and the kernel become your only ceiling.
The hard limit, and what it does to sizing. With the default percentage, a request under memory_limit=128M can briefly hold up to 256M, because the allocation that crosses the soft limit now succeeds and the script stops only at the next safe point. Container memory sized as workers times memory_limit needs another look. Do not set the percentage to 100: the hard check runs first, so most crossings of memory_limit would become a process exit with no fatal. Both directives can be changed at runtime, so pin them in pool or server configuration if that matters.
How your SAPI reports the fatal. The soft fatal goes wherever PHP errors already go. The hard line goes to standard error. php-src's default www.conf says that without catch_workers_output, worker "stdout and stderr will be redirected to /dev/null"; in our tests the official Docker php-fpm image turns it on in its own docker.conf. Under FrankenPHP and Apache, the line lands wherever the server's standard error goes: a container log, the journal or the main error log.
What your alerting keys on. Keep "Allowed memory size of". Add "Hard memory limit of" from standard error, "exited with code 137" from the FPM master, and exits with status 137 from whatever supervises FrankenPHP or Apache, and route them to the same place so the person on call sees them together.
Inventory before you trust the version string. A version string says what a build claims to be; the presence of memory_hard_limit_percent says whether it carries this change, as the next section shows. Our free atarus-phpcheck tool finds PHP installs, container images and fleet hosts, reads their versions and mitigating settings, and reports fixed, vulnerable or end of life without triggering either bug.
How to verify the change with a two-line test
Run the two lines below on a workstation or a throwaway container built from your production image. The first allocates 12 MiB against an 8 MiB memory_limit, past the soft limit and under the 16 MiB hard limit. The second asks for one allocation of about 60 MiB, past both.
The two-line test and what each build prints
-
Line 1, soft //
php -d memory_limit=8M -r 'register_shutdown_function(function () { echo "shutdown ran\n"; }); $a = str_repeat("a", 12 * 1024 * 1024);'; echo "exit $?" -
Line 2, hard //
php -d memory_limit=8M -r 'register_shutdown_function(function () { echo "shutdown ran\n"; }); $a = sprintf("%032000000d", 1);'; echo "exit $?" -
Fixed build //
line 1: the "Allowed memory size of 8388608 bytes exhausted" fatal, "shutdown ran", exit 255; line 2: "Hard memory limit of 16777216 bytes exhausted", exit 137, no shutdown line -
Unfixed build //
both lines: the "Allowed memory size" fatal, "shutdown ran", exit 255 -
Quick check //
php -r 'var_dump(ini_get("memory_hard_limit_percent"));'prints an empty string on a fixed build and bool(false) before the change
We ran both lines against the official Docker images for PHP 8.2.35, 8.3.14 and 8.1.34: 8.2.35 matched the fixed row, and the other two matched the unfixed row.
Line 1 is the control: it behaves the same everywhere, because the soft path did not change in a way users see. Line 2 is the whole change in one command. If it prints a fatal and "shutdown ran" on a build you believe is patched, that build does not carry the fix, whatever its version string says.
After the test, confirm that a hard limit line from a real worker reaches somewhere a person will read it.
What to do today
-
Upgrade every PHP runtime to 8.2.35, 8.3.36, 8.4.27 or 8.5.12, or a vendor package with the fix, and restart every worker. Confirm with the directive check, not the version string.
-
Put a finite memory_limit back wherever you set -1 as an interim mitigation. Then size container memory against the hard limit times the number of workers or threads.
-
Send PHP's standard error somewhere you read. Set catch_workers_output = yes on FPM pools and capture the server's stderr for FrankenPHP and Apache.
-
Alert on the new signals next to the old one. "Hard memory limit of", FPM children "exited with code 137" and supervisor exits with status 137, kept distinct from kernel kills. The Sigma rule above is a starting point.
-
Supervise single-process servers and bound input at the edge. Run FrankenPHP under a restart policy, and keep request size limits low enough that no request routinely reaches the hard limit.
SOURCES
- php-src commit 31c0ea7d7f // replaces the out of memory bailout with a delayed fatal and adds the hard limit, _exit(137) and memory_hard_limit_percent
- php-src GHSA-w8hw-w9fx-j79w // the delayed soft OOM at the next VM safe point, the immediate hard OOM via _exit(), and FPM exiting the child when startup fails
- PHP security advisory // the 21 Oct releases for all four supported branches and the SAPIs at risk
- PHP ChangeLog // the only release note for the change: Avoid state corruption via bailout
- FrankenPHP known issues // crossing the hard limit stops the whole FrankenPHP server, while PHP-FPM loses one child