PHP's new memory_hard_limit_percent setting

The PHP security releases of 21 October add a new setting: memory_hard_limit_percent. It changes what memory_limit means. memory_limit is now a soft limit. A second, higher limit sits above it. A process that reaches that one is ended on the spot, with exit code 137. No shutdown functions, no error page, no line in your PHP error log.

By default the hard limit is twice your memory_limit, up to 1G. Above 1G, it’s memory_limit plus 1G.

This post covers what the setting does, why it exists, and what it means if you run PHP in production. If you still have to patch, start with how to apply the patches . If you set memory_limit = -1 from the early mitigation post , read the part on -1 below.

Every command and every output below comes from a real run in a container.

The short version#

  • Crossing memory_limit gives the same Allowed memory size ... exhausted fatal error as before. Shutdown functions still run. The client gets a 500.
  • But PHP now lets C code finish what it was doing first. So one request can use more than memory_limit, up to the hard limit.
  • Reaching the hard limit ends the process with _exit(137). Shutdown functions and destructors don’t run. PHP-FPM logs exited with code 137, nginx returns a 502.
  • The hard limit’s error message only reaches a log if the FPM pool has catch_workers_output = yes. Debian’s default pool has it off.
  • memory_limit = -1 turns off both limits. Put a real limit back after you patch.
  • Don’t set memory_hard_limit_percent = 100. It turns every normal out-of-memory error into a hard kill.

What I tested#

PHPWhere it came from
8.5.12, 8.4.27, 8.3.36, 8.2.35packages.sury.org , Debian 13 container
8.4.27 (8.4.27-1~deb13u1)Debian 13 trixie-security
8.3.6 (8.3.6-0ubuntu0.24.04.13)Ubuntu 24.04 noble-security
8.4.26, not patched, to comparephp:8.4-fpm-alpine Docker image
PHP-FPM 8.5.12 behind nginx 1.26.3packages.sury.org, Debian 13 container

Every patched build behaved the same, Ubuntu’s backport into 8.3.6 included. The output below is from 8.5.12 unless I say otherwise.

The official php:8.x Docker images and the FrankenPHP images were not rebuilt yet at 15:05 UTC, so I could not test those.

Why PHP needed a second limit#

Before the patch, PHP handled “out of memory” with longjmp(). That’s a C jump straight out of whatever code was running, back to the top of the request. The code that was running never got to finish. If it was halfway through building something in memory, that thing stayed half built. Request shutdown then used it. That’s the bug behind CVE-2026-109618 (GHSA-w8hw-w9fx-j79w ).

The fix, commit 31c0ea7d7f , removes that jump. It splits the limit in two.

The soft limit is memory_limit. When an allocation crosses it, PHP still hands out the memory. It notes “out of memory” as a pending error and lets the C code finish. At the next safe point in the engine, it raises the usual fatal error. It unwinds the same way exit() does, so shutdown functions run as normal.

The hard limit is for C code that keeps allocating and does not reach a safe point in time. When an allocation would cross it, PHP prints one line to stderr and calls _exit(137). That ends the process at once. Nothing else runs, so nothing can touch memory that was left half built.

You can read both in Zend/zend_alloc.c: zend_mm_check_limit() queues the soft error, and zend_mm_hard_limit_error() does the _exit(137).

Code that reads untrusted input now also checks for the pending error itself. The multipart parser checks it between parts and fields, according to PHP core developer Alexandre Daubois . In the 8.5.12 source, main/rfc1867.c has 20 of those checks, and 41 C files under ext/ and main/ use them.

Many functions also switched to allocations that are allowed to fail. They get nothing back, stop, and you get the normal fatal error. str_repeat() is one: asking it for 300 MB with a 128M limit gave me a normal fatal error, not a hard kill. So not every big allocation reaches the hard limit.

Crossing memory_limit: the same fatal error as before#

A script that leaks memory:

<?php
register_shutdown_function(function () {
    echo "shutdown function ran\n";
});

$rows = [];
for ($i = 0; ; $i++) {
    $rows[] = ['id' => $i, 'name' => "customer-$i"];
}
$ php8.5 -d memory_limit=128M leak.php; echo "exit: $?"
PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /scripts/leak.php on line 8
Stack trace:
#0 {main}
shutdown function ran
exit: 255

Same message and same exit code 255 as before the patch. The stack trace is a PHP 8.5 feature (fatal_error_backtraces), not part of this fix. error_get_last() in the shutdown function still returns the error, so error trackers that look there still see it.

Destructors don’t run. They didn’t before the patch either: PHP doesn’t call destructors after a fatal error.

One request can now use more than memory_limit#

The soft limit lets the C code finish, so the process does get the memory. hash_pbkdf2() with a 100 MB output asks for more than 128M in one go. I printed the peak memory of the process from a shutdown function:

<?php
register_shutdown_function(function () {
    echo "shutdown function ran, max RSS: ", getrusage()['ru_maxrss'], " KB\n";
});

$key = hash_pbkdf2('sha256', 'password', 'salt', 1, 100_000_000);

On PHP 8.4.26, not patched:

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 100000032 bytes) in /scripts/rss.php on line 6
shutdown function ran, max RSS: 66128 KB

On PHP 8.4.27, patched:

PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 100003840 bytes) in /scripts/rss.php on line 6
shutdown function ran, max RSS: 163668 KB

Same error. But the old PHP refused the 100 MB allocation, and the patched one handed it out first: a 66 MB peak against 164 MB, with a 128M memory_limit. Debian’s 8.4.27 peaked at 161 MB, Ubuntu’s 8.3.6 at 165 MB.

So if you size servers as pm.max_children × memory_limit, that’s no longer the worst case. The worst case per worker is now the hard limit, twice as much with the defaults.

Crossing the hard limit: exit 137, and nothing else runs#

The same function with a 400 MB output crosses the 256M hard limit:

<?php
class Report {
    public function __destruct() {
        echo "destructor ran\n";
    }
}

register_shutdown_function(function () {
    echo "shutdown function ran\n";
});

$report = new Report();

$key = hash_pbkdf2('sha256', 'password', 'salt', 1, (int) $argv[1]);
echo "hash_pbkdf2() returned ", strlen($key), " bytes\n";
echo "next statement\n";
$ php8.5 -d memory_limit=128M hard.php 400000000; echo "exit: $?"
Hard memory limit of 268435456 bytes exhausted (tried to allocate 400003072 bytes) in /scripts/hard.php on line 14
exit: 137

No shutdown function, no destructor. And no PHP Fatal error: in front of the message. It doesn’t go through PHP’s error handling at all: PHP writes it straight to stderr. So error_log, log_errors and your error tracker never see it.

The unpatched 8.4.26 gives a normal fatal error for the same script, and runs the shutdown function:

$ php -d memory_limit=128M hard.php 400000000; echo "exit: $?"

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 200000000 bytes) in /scripts/hard.php on line 14
shutdown function ran
exit: 255

php2shell.com , the researchers’ site, says the same: reaching the hard limit “terminates the process and prevents shutdown handlers from running”.

The 137 is hardcoded in PHP. A shell also shows 137 for a process killed with SIGKILL (128 + 9), but PHP isn’t killed by a signal here. It exits by itself. PHP-FPM’s log tells the two apart, see below .

The defaults#

memory_hard_limit_percent sets the hard limit as a percentage of memory_limit. The default is -1. In zend_mm_update_hard_limit() that means 200% of memory_limit up to 1G, and memory_limit plus 1G above that.

ini_get() returns an empty string for the default:

$ php8.5 -r 'var_dump(ini_get("memory_hard_limit_percent"));'
string(0) ""

There is no function that returns the hard limit itself. The error message prints it, so I asked for an impossible amount (hash_pbkdf2() with a length of PHP_INT_MAX) under different settings. PHP refuses before it allocates anything.

<?php
hash_pbkdf2('sha256', 'password', 'salt', 1, PHP_INT_MAX);

Saved as limit.php, and run as php8.5 -d memory_limit=128M limit.php:

Hard memory limit of 268435456 bytes exhausted (tried to allocate 4611686018427387904 bytes) in /scripts/limit.php on line 2
memory_limitmemory_hard_limit_percentHard limit in the error message
64Mdefault134217728 (128M)
128Mdefault268435456 (256M)
256Mdefault536870912 (512M)
512Mdefault1073741824 (1G)
1Gdefault2147483648 (2G)
2Gdefault3221225472 (3G)
4Gdefault5368709120 (5G)
128M100134217728 (128M)
128M150201326592 (192M)
128M300402653184 (384M)

Values below 100 get a warning, and PHP keeps the default:

$ php8.5 -d memory_hard_limit_percent=50 -r 'var_dump(ini_get("memory_hard_limit_percent"));'
PHP Warning:  memory_hard_limit_percent must be >= 100, or -1 in Unknown on line 0
string(0) ""

You can change it at runtime, it’s a PHP_INI_ALL setting. An invalid value makes ini_set() return false:

$ php8.5 -r 'var_dump(ini_set("memory_hard_limit_percent", "50"), ini_get("memory_hard_limit_percent"));'
PHP Warning:  memory_hard_limit_percent must be >= 100, or -1 in Command line code on line 1
bool(false)
string(0) ""

In an FPM pool, php_admin_value[memory_hard_limit_percent] = 50 passes php-fpm8.5 -t. Each worker logs the warning when it starts, and runs with the default. So read the FPM log after you change it.

The setting isn’t in php.ini-production, php.ini-development or UPGRADING in the 8.5.12 source.

Don’t set it to 100#

100 makes the hard limit equal to memory_limit. Then there’s no room left for the soft limit. Here is the leaking script from above again, which gave a normal fatal error with the default:

$ php8.5 -d memory_limit=128M -d memory_hard_limit_percent=100 leak.php; echo "exit: $?"
Hard memory limit of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /scripts/leak.php on line 8
exit: 137

Every normal out-of-memory error is now a hard kill. No error page, no shutdown function, no report. With 101 the same script gave the normal fatal error again, but that’s a margin of 1.3 MB. Any bigger allocation still goes straight to the hard limit. I’d leave it at the default. If you change it, go up, not down.

memory_limit = -1 means no limit at all#

-1 sets the soft limit to the maximum, and the hard limit follows it. So there’s no soft limit and no hard limit. Ask for an impossible amount and it’s the operating system that says no:

$ php8.5 -d memory_limit=-1 limit.php; echo "exit: $?"

mmap() failed: [12] Cannot allocate memory

mmap() failed: [12] Cannot allocate memory
Out of memory
exit: 1

Lots of people set -1 this week as a mitigation . Before the patch, that was the advice from PHP itself. After the patch, put a real limit back. Without one, a single request grows until the kernel or the container stops it.

Also check your CLI. Debian’s php.ini for the CLI ships with memory_limit = -1 (I checked 8.4.27 from trixie-security and the sury packages). So queue workers, cron jobs and artisan commands run with no soft or hard limit, unless you set one.

What PHP-FPM logs#

My test pool: pm = static, two children, memory_limit = 128M, PHP-FPM 8.5.12 behind nginx 1.26.3. I sent one request that leaks, one that crosses the hard limit, and then a normal one.

The soft limit looks the way it always did. nginx returns a 500, the PHP error log has the fatal error, the shutdown function ran. The worker stays up and takes the next request.

The hard limit, with the default catch_workers_output = no:

[21-Oct-2026 15:00:43] WARNING: [pool www] child 15646 exited with code 137 after 2.123035 seconds from start
[21-Oct-2026 15:00:43] NOTICE: [pool www] child 15655 started

That’s it. The “Hard memory limit” line went to the worker’s stderr, and FPM throws that away by default. The PHP error log has nothing. nginx logs this:

2026/10/21 15:01:23 [error] 16052#16052: *3 recv() failed (104: Connection reset by peer) while reading response header from upstream, client: 127.0.0.1, server: , request: "GET /hard.php HTTP/1.1", upstream: "fastcgi://127.0.0.1:9000", host: "127.0.0.1:8080"

And in the access log, the leaking request got a 500 and the hard limit a 502:

127.0.0.1 - - [21/Oct/2026:15:01:22 +0000] "GET /leak.php HTTP/1.1" 500 5 "-" "curl/8.14.1"
127.0.0.1 - - [21/Oct/2026:15:01:23 +0000] "GET /hard.php HTTP/1.1" 502 150 "-" "curl/8.14.1"

With catch_workers_output = yes in the pool, the message shows up in the FPM log, with the file and line:

[21-Oct-2026 15:00:52] WARNING: [pool www] child 15677 said into stderr: "Hard memory limit of 268435456 bytes exhausted (tried to allocate 400003072 bytes) in /scripts/www/hard.php on line 6"
[21-Oct-2026 15:00:52] WARNING: [pool www] child 15677 exited with code 137 after 2.137838 seconds from start

Debian’s www.conf ships with ;catch_workers_output = yes commented out. I’d turn it on. It’s the only place where the file and line of a hard-limit kill end up.

Exit code 137 or signal 9#

Next I ran the same FPM setup in a container with a 150 MB memory limit, below the 256M hard limit. Then I sent it the 164 MB request from earlier. The kernel got there first:

[21-Oct-2026 15:01:53] WARNING: [pool www] child 14 exited on signal 9 (SIGKILL) after 1.349045 seconds from start

nginx gave a 502 again, and the container’s memory.events showed oom_kill 1. So in the FPM log:

  • exited with code 137 is PHP’s hard limit.
  • exited on signal 9 (SIGKILL) is something else killing the worker. Usually the kernel’s OOM killer, because of a cgroup or container limit.

A shell shows $? as 137 for both, so look at the FPM log, not just the exit code.

What this means in production#

  • Patch first. The hard limit comes with the fix. The steps are in how to apply the patches .
  • Put memory_limit back if you set it to -1. Check the CLI too: on Debian it’s -1 by default.
  • Set catch_workers_output = yes in your FPM pools. Without it, a hard-limit kill only leaves exited with code 137.
  • Alert on exited with code 137 in the FPM log. Each one is a request that got a 502, and a worker FPM had to start again. One now and then is fine. A steady stream means some code path regularly needs more than twice your memory_limit.
  • Don’t rely on shutdown functions for out-of-memory reports. Laravel catches fatal errors in a shutdown function (HandleExceptions::handleShutdown()), and so do most error trackers. They still see the soft limit. They never see a hard-limit kill.
  • Size memory for the hard limit. A worker can now grow to the hard limit before PHP stops it. If your container or cgroup limit is below workers × hard limit, the kernel can step in first, and it picks which process to kill by itself.
  • Use a cgroup, not RLIMIT_AS or RLIMIT_DATA, to cap PHP’s memory. That’s MemoryMax= in systemd or a container memory limit, not ulimit -v, LimitAS= or LimitDATA=. The advisory says the rlimits trigger the vulnerable longjmp() path on unpatched PHP. I didn’t test rlimits on the patched builds.
  • Threaded servers lose more. _exit() ends the whole process. In PHP-FPM that’s one worker and one request. In FrankenPHP, many requests share one process, so I expect a hard-limit kill to take all of them along. I couldn’t test this, as the patched FrankenPHP images weren’t out yet.

One more trick. Ubuntu’s fixed package still calls itself 8.3.6, so php -v doesn’t tell you if you’re patched. The new setting does. The unpatched 8.4.26 returns false, every patched build I tested returns an empty string:

$ php -r 'var_dump(ini_get("memory_hard_limit_percent"));'
string(0) ""

If you start seeing exited with code 137 after you patch, I’d love to hear which code path it was. Let me know!