The PHP2Shell fix gives PHP a second memory limit, and a worker that crosses it is killed outright. With the default memory_limit = 128M, the new ceiling sits at 256M. Past it, PHP prints “Hard memory limit … exhausted” to stderr and calls _exit(137), so no shutdown function, destructor or error handler runs. On PHP-FPM, the client gets a 502, and the master log gets a single line about a child that exited with code 137.
Behind it sits memory_hard_limit_percent, a setting added in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 as part of the fix for CVE-2026-109618 and CVE-2026-109619, and in the Ubuntu backports. This post measures what triggers it, what it logs, and what tuning it actually changes. A 10,000-class include under a 10M memory_limit hit the hard limit at exactly 20,971,520 bytes on every fully patched build we tested. The runs took place on 21 and 22 October 2026, with PHP 8.3.36 from Remi on Rocky Linux 10, the backported 8.3.6 on Ubuntu 24.04 and 8.4.27 from the ondrej PPA.
Step 1: Results Across Nine Runs
The runs below cover the cases that matter. They are split between the Rocky Linux 10 VM and the Ubuntu 24.04 VM, and the include runs load the same 586,676-byte PHP file holding 10,000 small class declarations, which peaks at 29,884,416 bytes when nothing stops it. The include and allocation runs executed a script file rather than php -r, because the two paths handle a pending memory error differently:
| Run | memory_limit | memory_hard_limit_percent | Hard limit | Result | Exit code |
|---|---|---|---|---|---|
| Include, default percent | 10M | unset (no value, treated as -1) | 20,971,520 | killed, shutdown function skipped | 137 |
| Include, raised percent | 10M | 300 | 31,457,280 | “Allowed memory size” fatal, shutdown ran | 255 |
| Include, normal limit | 64M | unset | 134,217,728 | loaded, peak 29,884,416 | 0 |
| Concatenate two 100M strings | 128M | unset | 268,435,456 | killed asking for 209,719,296 | 137 |
| str_repeat() of 300M | 128M | 100 | 134,217,728 | “Allowed memory size” fatal, shutdown ran | 255 |
| array_fill() of 20 million | 128M | unset | 268,435,456 | “Allowed memory size” fatal asking for 536,875,008 | 255 |
| PHP-FPM include | 10M (ini_set) | unset | 20,971,520 | HTTP 502, child exit 137 | 137 |
| PHP-FPM include, raised percent | 10M (ini_set) | 300 (ini_set) | 31,457,280 | HTTP 500, fatal logged, shutdown ran | none |
| Invalid value | default | 50 | unchanged | warning, previous value kept | 0 |
Three results stand out. The default is exactly twice memory_limit for limits up to 1G, and above 1G PHP adds a flat 1G instead, as Zend/zend_alloc.c computes it. Raising the percent to 300 did not let the 10M include succeed: it swapped the kill for the ordinary “Allowed memory size” fatal, with shutdown functions intact, and the request still failed. And the two 137 kills came from very different code, a long compile and a single string concatenation, while two other large allocations failed softly at the same limits.
Step 2: Learn When the Hard Limit Fires
Ordinary memory_limit behaviour changed with the fix as well, and the two limits only make sense together. Before PHP2Shell, hitting memory_limit jumped straight out of the current operation, which is the state corruption the CVE exploits. Now an allocation that exceeds the soft limit takes one of three paths:
- Delayed soft error: the allocation goes ahead, PHP records a pending “Allowed memory size” error and raises it at the next safe point in the virtual machine, such as a function call or a loop iteration. Shutdown functions still run.
- Refused allocation: functions such as
str_repeat()ask for “fallible” memory. PHP refuses the request and raises the soft error, and the hard limit never applies to them. The 300Mstr_repeat()at a percent of 100 shows it: the hard limit equalled the soft limit, and the run still ended softly. - Hard kill: if the allocation is not fallible and would take the process past the hard limit, PHP prints the message and calls
_exit(137)on the spot. The check runs when PHP asks the system for a new 2M chunk or a huge block.
The concatenation run shows the third path with ordinary code. At 100M of usage, $a . $a asked for 209,719,296 bytes in one allocation. That would have reached about 300M against a 268,435,456-byte hard limit, so the process died without reaching a safe point. A compile of a large file reaches the same end by a different route: it keeps allocating chunks without passing a safe point, until the next 2M chunk crosses the line. The 20-million-element array_fill() asked for 536,875,008 bytes, more than twice the hard limit, and still ended with the soft fatal. That is the fallible path again: array_fill() sizes its packed array through a fallible allocation, while the concatenation does not, so the size of a request alone does not predict which path it takes. In practice, a script that grows memory in small steps across many statements dies with the familiar soft error, and a 137 points at one large non-fallible allocation or a long compile.
Step 3: Spot a Hard-Limit Kill in Logs
A hard-limit kill leaves different traces from a normal out-of-memory error, and the message itself is missing from where you would look first. We sent one request through nginx to a PHP-FPM page that set memory_limit to 10M and included the 10,000-class file. The client saw a 502:
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1/hard3.php
A 502 is the only thing the client learns:
HTTP 502
The PHP-FPM master log recorded the worker exit and the replacement it started in the same second:
[21-Oct-2026 23:26:19] WARNING: [pool www] child 52319 exited with code 137 after 4818.629964 seconds from start
[21-Oct-2026 23:26:19] NOTICE: [pool www] child 52791 started
nginx logged the connection being reset mid-request:
recv() failed (104: Connection reset by peer) while reading response header from upstream, client: 127.0.0.1, server: _, request: "GET /hard3.php HTTP/1.1", upstream: "fastcgi://unix:/run/php-fpm/www.sock:"
Nothing reached the pool error log, /var/log/php-fpm/www-error.log, for that request, and the shutdown function on the page never wrote its line. The “Hard memory limit” message goes to the stderr of the worker, which PHP-FPM discards by default. The wording of the master log line is the useful part: FPM writes “exited with code” for a process that called exit, and “exited on signal” for one that was killed. A worker we killed with kill -9, the same SIGKILL the kernel OOM killer sends, produced the other form:
[22-Oct-2026 09:12:24] WARNING: [pool www] child 55562 exited on signal 9 (SIGKILL) after 2.122586 seconds from start
So “exited with code 137” points at the PHP hard limit, unless your own code calls exit(137), and “exited on signal 9” points at the OOM killer or an operator. One grep separates both causes:
sudo grep -E "exited with code 137|exited on signal 9" /var/log/php-fpm/error.log
On Ubuntu and Debian, the master log is /var/log/php<version>-fpm.log; our Ubuntu 24.04 VM had /var/log/php8.3-fpm.log and /var/log/php8.4-fpm.log, one per installed branch. To see the message itself, enable catch_workers_output = yes in the pool and restart PHP-FPM. With it on, the same request left the full message in the master log, one line before the exit:
[22-Oct-2026 09:12:39] WARNING: [pool www] child 55681 said into stderr: "Hard memory limit of 20971520 bytes exhausted (tried to allocate 2097152 bytes) in /usr/share/nginx/html/hard3.php on line 1"
That option also forwards everything else workers print to stderr, so on a busy pool we would enable it for the first week after the upgrade and then decide whether to keep it.
Step 4: Tune memory_hard_limit_percent
The setting is PHP_INI_ALL, so it works in php.ini, a pool php_value, or ini_set(), and it only accepts 100 or more, or -1 for the default. A lower value is rejected with a warning, and the previous value stays in force:
php -d memory_hard_limit_percent=50 -r 'echo 1;'
PHP warns and carries on with the default:
PHP Warning: memory_hard_limit_percent must be >= 100, or -1 in Unknown on line 0
1
For most sites the default is the right answer, and we left it alone on our own servers. The measurements narrow down where a change helps:
- Large compiles: an installer or admin task that includes huge generated files needs a higher
memory_limit, which moves the hard limit with it. The 10,000-class include loaded at 64M and peaked at 29,884,416 bytes. Raising only the percent turned the 502 into a 500 with a logged fatal, which is easier to debug but still a failed request. - memory_limit = -1: with no soft limit there is effectively no hard limit either. If you set
-1as a PHP2Shell mitigation, put a real value back after patching. - Shared hosting: keep the default, because the hard limit is what stops one runaway request from taking memory the other tenants need.
Raising the percent trades safety for log quality in a specific way. A higher value lets a single request take more memory before anything stops it, and in exchange a failure below the new ceiling arrives as a normal error with shutdown functions intact. A request that crosses the raised ceiling is still killed with 137. We would only raise it on a dedicated worker pool where those shutdown hooks matter, and never as a fix for code that needs the memory.
Step 5: Reproduce It on Your Own Build
To confirm your PHP has the setting and see the kill for yourself, use the CLI on a test machine. Generate a large include file first:
php -r '$s="<?php\n"; for($i=0;$i<10000;$i++){$s.="class C$i { public \$a=$i; function f(){return $i;} }\n";} file_put_contents("/tmp/many.php",$s);'
Then load it under a 10M limit with a shutdown function registered, and print the exit code:
php -d memory_limit=10M -r 'register_shutdown_function(function(){echo "shutdown ran\n";}); require "/tmp/many.php"; echo "loaded\n";'; echo "exit=$?"
Keep the default percent for this test. Under php -r, a raised percent lets the include finish and print “loaded”, because the pending soft error is dropped at the end of the eval. A patched PHP prints the hard-limit message and exits 137, with no “shutdown ran” line:
Hard memory limit of 20971520 bytes exhausted (tried to allocate 2097152 bytes) in Command line code on line 1
exit=137
The other two kinds of build fail the same test differently, so it also tells them apart. A vulnerable PHP has no memory_hard_limit_percent at all (ini_get returns false), and it ends with the ordinary soft error, exit code 255 and “shutdown ran”, as the stock Debian 13 build 8.4.24 did in a container. The minimal backports for end-of-life PHP 8.1, 8.0 and 7.4 also lack the setting, but they turn every memory_limit overrun into an immediate exit. On the Rocky 9 VM, the Remi php81 8.1.34-6 build printed this and skipped the shutdown function:
Fatal error: Allowed memory size of 10485760 bytes exhausted (tried to allocate 65536 bytes) in Command line code on line 1
exit=1
The RHEL 9 AppStream 8.0.30-8.el9_8.1 build (Rocky rebuild) behaved the same: ini_get returned false for the setting, and it exited with code 1 for both the include and a plain loop that filled memory. The message goes to stderr after a blank line, so it can look detached from the command. Through PHP-FPM, the php81 pool answered a 502 and its master log recorded child 20235 exited with code 1 after 1.068191 seconds from start. So the three results map to three builds: 137 means the full fix, 1 with no shutdown output means an end-of-life backport, and 255 with “shutdown ran” means an unpatched PHP. The PHP2Shell check and patch guide confirms the same answer from the package changelog, and the end-of-life PHP guide explains what those backports change.
What to Watch After the Upgrade
A hard-limit kill is most likely to surprise you in the first week after a PHP2Shell upgrade, because workloads that used to fail softly can now fail with a 502. Three signals cover it:
- FPM master log: count “exited with code 137” lines per day. A jump after the upgrade points at the hard limit, while “exited on signal 9” lines point at the OOM killer. On an end-of-life backport, every
memory_limitoverrun shows up as “exited with code 1” plus a 502, so count those lines too. - Web server errors: nginx logged “Connection reset by peer” from the PHP upstream for each kill. We only tested nginx; Apache with
mod_proxy_fcgishould log a similar upstream error, and under mod_php there is no upstream at all, because the kill ends the httpd child itself. - Kernel log: an OOM-killer entry in
journalctl -kat the same timestamp confirms a signal 9 line.
If the count stays at zero, the new limit is doing its job silently. If it does not, find the request from the access log timestamp and decide between raising memory_limit for that pool and fixing the code that allocates that much. The RHEL-family patch guide covers installing the builds these measurements came from.