[packages/php/PHP_7_4] rel 19: upload progress, delayed OOM and row-undef fixes backported from 8.2; C99 configure, 66049,
arekm
arekm at pld-linux.org
Fri Oct 23 16:28:20 CEST 2026
commit d2e02a402afd4d7d4112166f0b07619ba9c9bcd2
Author: Arkadiusz Miśkiewicz <arekm at maven.pl>
Date: Fri Oct 23 16:27:38 2026 +0200
rel 19: upload progress, delayed OOM and row-undef fixes backported from 8.2; C99 configure, 66049, gh18953
php-bug66049.patch | 145 +++++++
php-c99-configure.patch | 96 +++++
php-cve-2025-1218-row-undef.patch | 177 ++++++++
php-cve-2026-109618-delayed-oom.patch | 650 ++++++++++++++++++++++++++++
php-cve-2026-109618-session.patch | 787 ++++++++++++++++++++++++++++++++++
php-gh18953.patch | 117 +++++
php.spec | 18 +-
7 files changed, 1989 insertions(+), 1 deletion(-)
---
diff --git a/php.spec b/php.spec
index dcf2b9c..7706aa1 100644
--- a/php.spec
+++ b/php.spec
@@ -150,7 +150,7 @@ Summary(ru.UTF-8): PHP Версии 7 - язык препроцессирова
Summary(uk.UTF-8): PHP Версії 7 - мова препроцесування HTML-файлів, виконувана на сервері
Name: %{orgname}%{php_suffix}
Version: 7.4.33
-Release: 18
+Release: 19
Epoch: 4
# All files licensed under PHP version 3.01, except
# Zend is licensed under Zend
@@ -260,6 +260,16 @@ Patch240: php-cve-2026-7260.patch
Patch241: php-gh22643.patch
Patch242: php-from-8.2.34-to-7.4.patch
Patch243: php-7.4-cve-2026-109618.patch
+# session part of https://github.com/php/php-src/commit/3584a98bda0f instead of disabling upload progress
+Patch244: php-cve-2026-109618-session.patch
+# memory limit error raised at a safe point like in 8.2 (https://github.com/php/php-src/commit/3584a98bda0f), not _exit(1) from Patch243
+Patch245: php-cve-2026-109618-delayed-oom.patch
+# Patch219 frees row fields that the result set still owns when a row fails to decode
+Patch246: php-cve-2025-1218-row-undef.patch
+# Patch300+: upstream fixes that never reached 7.4, backported
+Patch300: php-c99-configure.patch
+Patch301: php-bug66049.patch
+Patch302: php-gh18953.patch
URL: https://www.php.net/
%{?with_pdo_firebird:%{!?with_interbase_inst:BuildRequires: Firebird-devel >= 1.0.2.908-2}}
%{?with_pspell:BuildRequires: aspell-devel >= 2:0.50.0}
@@ -2031,6 +2041,12 @@ cp -p php.ini-production php.ini
%patch -P242 -p1
%patch -P243 -p1
%patch -P191 -p1
+%patch -P244 -p1
+%patch -P245 -p1
+%patch -P246 -p1
+%patch -P300 -p1
+%patch -P301 -p1
+%patch -P302 -p1
sed -E -i -e '1s,#!\s*/usr/bin/env\s+(.*),#!%{__bindir}\1,' \
ext/ext_skel.php \
diff --git a/php-bug66049.patch b/php-bug66049.patch
new file mode 100644
index 0000000..675c760
--- /dev/null
+++ b/php-bug66049.patch
@@ -0,0 +1,145 @@
+From b09ab13fbf7458e9afde7dbfbc6f96a29691aa5b Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Arkadiusz=20Mi=C5=9Bkiewicz?= <arekm at maven.pl>
+Date: Thu, 22 Oct 2026 10:44:24 +0200
+Subject: [PATCH] Fix #66049: Typemap can break parsing in parse_packet_soap
+ leading to a segfault
+
+A SOAP fault whose text decodes to a non-string (typemap callback, or
+just xsi:type/xsi:nil from the server) is read via Z_STR(): segfault.
+
+Origin: backport, https://github.com/php/php-src/commit/209f4c296ec6a08c721afdf17d787db4b5fd37d0
+Bug: https://bugs.php.net/66049
+---
+diff --git a/ext/soap/php_packet_soap.c b/ext/soap/php_packet_soap.c
+index 685a474e..466b03c5 100644
+--- a/ext/soap/php_packet_soap.c
++++ b/ext/soap/php_packet_soap.c
+@@ -194,6 +194,7 @@ int parse_packet_soap(zval *this_ptr, char *buffer, int buffer_size, sdlFunction
+ if (tmp != NULL && tmp->children != NULL) {
+ zval zv;
+ master_to_zval(&zv, get_conversion(IS_STRING), tmp);
++ convert_to_string(&zv);
+ faultstring = Z_STR(zv);
+ }
+
+@@ -201,6 +202,7 @@ int parse_packet_soap(zval *this_ptr, char *buffer, int buffer_size, sdlFunction
+ if (tmp != NULL && tmp->children != NULL) {
+ zval zv;
+ master_to_zval(&zv, get_conversion(IS_STRING), tmp);
++ convert_to_string(&zv);
+ faultactor = Z_STR(zv);
+ }
+
+@@ -224,6 +226,7 @@ int parse_packet_soap(zval *this_ptr, char *buffer, int buffer_size, sdlFunction
+ if (tmp != NULL && tmp->children != NULL) {
+ zval zv;
+ master_to_zval(&zv, get_conversion(IS_STRING), tmp);
++ convert_to_string(&zv);
+ faultstring = Z_STR(zv);
+ }
+ }
+diff --git a/ext/soap/tests/bugs/bug66049.phpt b/ext/soap/tests/bugs/bug66049.phpt
+new file mode 100644
+index 00000000..0bbf449d
+--- /dev/null
++++ b/ext/soap/tests/bugs/bug66049.phpt
+@@ -0,0 +1,48 @@
++--TEST--
++Fix #66049 Typemap can break parsing in parse_packet_soap leading to a segfault
++--SKIPIF--
++<?php if (!extension_loaded('soap')) die('skip soap extension not available'); ?>
++--INI--
++soap.wsdl_cache_enabled=0
++--FILE--
++<?php
++function soap_string_from_xml($str)
++ {
++ echo "soap_string_from_xml\n";
++
++ // Should return an string
++ return 2.3;
++ }
++
++class TestSoapClient extends SoapClient {
++ function __doRequest($request, $location, $action, $version, $one_way = 0): ?string {
++ $res='<?xml version="1.0" encoding="UTF-8"?>
++ <SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
++ <SOAP-ENV:Body>
++ <SOAP-ENV:Fault><faultcode>SOAP-ENV:Server</faultcode><faultstring>not present</faultstring>
++ </SOAP-ENV:Fault></SOAP-ENV:Body></SOAP-ENV:Envelope>';
++ return $res;
++ }
++}
++
++try {
++ $client=new TestSoapClient(null, [
++ 'uri' => 'test://',
++ 'location' => 'test://',
++ 'typemap' => [[
++ "type_ns" => "http://www.w3.org/2001/XMLSchema",
++ "type_name" => "string",
++ "from_xml" => "soap_string_from_xml"
++ ]]]);
++ $client->Mist("");
++} catch (SoapFault $e) {
++ var_dump($e->faultstring);
++ var_dump($e->faultcode);
++}
++?>
++Done
++--EXPECT--
++soap_string_from_xml
++string(3) "2.3"
++string(15) "SOAP-ENV:Server"
++Done
+diff --git a/ext/soap/tests/bugs/bug66049_no_typemap.phpt b/ext/soap/tests/bugs/bug66049_no_typemap.phpt
+new file mode 100644
+index 00000000..1dfbfd2f
+--- /dev/null
++++ b/ext/soap/tests/bugs/bug66049_no_typemap.phpt
+@@ -0,0 +1,45 @@
++--TEST--
++Bug #66049 (non-string fault text from xsi:type or xsi:nil, no typemap needed)
++--SKIPIF--
++<?php if (!extension_loaded('soap')) die('skip soap extension not available'); ?>
++--INI--
++soap.wsdl_cache_enabled=0
++--FILE--
++<?php
++class TestSoapClient extends SoapClient {
++ public $res;
++ function __doRequest($request, $location, $action, $version, $one_way = 0) {
++ return $this->res;
++ }
++}
++
++$ns = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"';
++$soap11 = '<?xml version="1.0"?><env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/" ' . $ns . '><env:Body><env:Fault>%s</env:Fault></env:Body></env:Envelope>';
++$soap12 = '<?xml version="1.0"?><env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope" ' . $ns . '><env:Body><env:Fault>%s</env:Fault></env:Body></env:Envelope>';
++
++$faults = array(
++ sprintf($soap11, '<faultcode>x</faultcode><faultstring xsi:type="xsd:int">5</faultstring><faultactor xsi:type="xsd:boolean">true</faultactor>'),
++ sprintf($soap11, '<faultcode>x</faultcode><faultstring xsi:nil="true">x</faultstring>'),
++ sprintf($soap12, '<env:Code><env:Value>env:Sender</env:Value></env:Code><env:Reason><env:Text xsi:type="xsd:int">7</env:Text></env:Reason>'),
++);
++
++$client = new TestSoapClient(null, array('location' => 'test://', 'uri' => 'test://'));
++foreach ($faults as $fault) {
++ $client->res = $fault;
++ try {
++ $client->test();
++ } catch (SoapFault $e) {
++ var_dump($e->faultstring);
++ if (isset($e->faultactor)) {
++ var_dump($e->faultactor);
++ }
++ }
++}
++?>
++Done
++--EXPECT--
++string(1) "5"
++string(1) "1"
++string(0) ""
++string(1) "7"
++Done
diff --git a/php-c99-configure.patch b/php-c99-configure.patch
new file mode 100644
index 0000000..60efb23
--- /dev/null
+++ b/php-c99-configure.patch
@@ -0,0 +1,96 @@
+From: Florian Weimer <fweimer at redhat.com>
+Subject: Port various autoconf bits to C99 compilers
+
+gcc >= 14 rejects implicit int and implicit declarations, so run-time
+configure tests fail silently; PHP_TEST_WRITE_STDOUT fails and CLI/CGI write
+through stdio instead of write(2).
+
+Origin: backport, https://github.com/php/php-src/commit/aeaab8ee3e52f74c042a861e394437d6554b36be
+Backported to the 7.4 build/php.m4 layout, configure parts only; 7.4 already
+includes <stdlib.h> and <string.h> in the crypt checks
+---
+diff --git a/Zend/Zend.m4 b/Zend/Zend.m4
+index 6c33ca4e..bc513728 100644
+--- a/Zend/Zend.m4
++++ b/Zend/Zend.m4
+@@ -157,6 +157,7 @@ AC_MSG_CHECKING(whether double cast to long preserves least significant bits)
+
+ AC_RUN_IFELSE([AC_LANG_SOURCE([[
+ #include <limits.h>
++#include <stdlib.h>
+
+ int main()
+ {
+@@ -256,6 +257,7 @@ AC_MSG_CHECKING(for MM alignment and log values)
+
+ AC_RUN_IFELSE([AC_LANG_SOURCE([[
+ #include <stdio.h>
++#include <stdlib.h>
+
+ typedef union _mm_align_test {
+ void *ptr;
+diff --git a/build/libtool.m4 b/build/libtool.m4
+index 1faed63a..e6960367 100644
+--- a/build/libtool.m4
++++ b/build/libtool.m4
+@@ -945,6 +945,7 @@ else
+ #endif
+
+ #include <stdio.h>
++#include <stdlib.h>
+
+ #ifdef RTLD_GLOBAL
+ # define LT_DLGLOBAL RTLD_GLOBAL
+diff --git a/build/php.m4 b/build/php.m4
+index f69d4c53..3fe7d878 100644
+--- a/build/php.m4
++++ b/build/php.m4
+@@ -1119,7 +1119,7 @@ AC_CACHE_CHECK(for type of reentrant time-related functions, ac_cv_time_r_type,[
+ AC_RUN_IFELSE([AC_LANG_SOURCE([[
+ #include <time.h>
+
+-main() {
++int main() {
+ char buf[27];
+ struct tm t;
+ time_t old = 0;
+@@ -1135,7 +1135,7 @@ return (1);
+ ],[
+ AC_RUN_IFELSE([AC_LANG_SOURCE([[
+ #include <time.h>
+-main() {
++int main() {
+ struct tm t, *s;
+ time_t old = 0;
+ char buf[27], *p;
+@@ -1596,7 +1596,7 @@ AC_DEFUN([PHP_CHECK_FUNC_LIB],[
+ if test "$found" = "yes"; then
+ ac_libs=$LIBS
+ LIBS="$LIBS -l$2"
+- AC_RUN_IFELSE([AC_LANG_SOURCE([[main() { return (0); }]])],[found=yes],[found=no],[found=no])
++ AC_RUN_IFELSE([AC_LANG_SOURCE([[int main() { return (0); }]])],[found=yes],[found=no],[found=no])
+ LIBS=$ac_libs
+ fi
+
+@@ -2293,7 +2293,7 @@ AC_DEFUN([PHP_TEST_WRITE_STDOUT],[
+
+ #define TEXT "This is the test message -- "
+
+-main()
++int main()
+ {
+ int n;
+
+diff --git a/configure.ac b/configure.ac
+index 7e016652..311cd6a5 100644
+--- a/configure.ac
++++ b/configure.ac
+@@ -672,6 +672,8 @@ AC_CACHE_CHECK([for getaddrinfo], ac_cv_func_getaddrinfo,
+ #include <string.h>
+ #include <netdb.h>
+ #include <sys/types.h>
++#include <string.h>
++#include <stdlib.h>
+ #ifndef AF_INET
+ # include <sys/socket.h>
+ #endif
diff --git a/php-cve-2025-1218-row-undef.patch b/php-cve-2025-1218-row-undef.patch
new file mode 100644
index 0000000..718184e
--- /dev/null
+++ b/php-cve-2025-1218-row-undef.patch
@@ -0,0 +1,177 @@
+From e1a2296ff183bc4ddd3567680fca2d8cab11fc8d Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Arkadiusz=20Mi=C5=9Bkiewicz?= <arekm at maven.pl>
+Date: Thu, 22 Oct 2026 10:54:45 +0200
+Subject: [PATCH] mysqlnd: leave row zvals UNDEF when a row fails to decode
+
+The GHSA-h35g-vwh6-m678 backport frees the already decoded fields on a decode
+error, but 7.4 result sets still own those zvals: refetch returns and free re-frees them.
+
+Copy mode (MYSQLI_STORE_RESULT_COPY_DATA) also leaked its temporary row array
+when the decode failed; free it on that path.
+
+Origin: vendor, CloudLinux alt-php73 php-7.3-CVE-2025-1218.patch (ZVAL_UNDEF hunks); no upstream commit, 8.1+ releases row_data only after a successful decode
+Bug: https://github.com/php/php-src/security/advisories/GHSA-h35g-vwh6-m678
+---
+diff --git a/ext/mysqli/tests/ghsa-h35g-vwh6-m678-query-row-refetch.phpt b/ext/mysqli/tests/ghsa-h35g-vwh6-m678-query-row-refetch.phpt
+new file mode 100644
+index 00000000..ab01adf8
+--- /dev/null
++++ b/ext/mysqli/tests/ghsa-h35g-vwh6-m678-query-row-refetch.phpt
+@@ -0,0 +1,50 @@
++--TEST--
++GHSA-h35g-vwh6-m678 follow-up (mysqlnd text row decode error must not leave freed zvals in the result set)
++--SKIPIF--
++<?php
++if (!extension_loaded('mysqli')) die('skip mysqli extension not available');
++?>
++--FILE--
++<?php
++require_once 'fake_server.inc';
++
++$servername = "127.0.0.1";
++$username = "root";
++$password = "";
++
++$process = run_fake_server_in_background('query_response_row_field_len');
++$process->wait();
++
++$conn = new mysqli($servername, $username, $password, "", 33305);
++
++echo "[*] Query the fake server...\n";
++$result = $conn->query("SELECT strval, strval FROM data");
++
++// The second fetch must decode the row again, not hand out the already freed first field
++var_dump($result->fetch_row());
++var_dump($result->fetch_row());
++$result->free();
++$conn->close();
++
++$process->terminate(true);
++
++print "done!";
++?>
++--EXPECTF--
++[*] Server started
++[*] Connection established
++[*] Sending - Server Greeting: 580000000a352e352e352d31302e352e31382d4d6172696144420003000000473e3f6047257c6700fef7080200ff81150000000000000f0000006c6b55463f49335f686c6431006d7973716c5f6e61746976655f70617373776f7264
++[*] Received: 6900000185a21a00000000c0080000000000000000000000000000000000000000000000726f6f7400006d7973716c5f6e61746976655f70617373776f7264002c0c5f636c69656e745f6e616d65076d7973716c6e640c5f7365727665725f686f7374093132372e302e302e31
++[*] Sending - Server OK: 0700000200000002000000
++[*] Query the fake server...
++[*] Received: 200000000353454c4543542073747276616c2c2073747276616c2046524f4d2064617461
++[*] Sending - Malicious Query Response for data strval field [field length over-read]: 01000001023200000203646566087068705f74657374046461746104646174610673747276616c0673747276616c0ce000c8000000fd01100000003200000303646566087068705f74657374046461746104646174610673747276616c0673747276616c0ce000c8000000fd011000000005000004fe00002200060000050474657374fc05000006fe00002200
++
++Warning: mysqli_result::fetch_row(): Malformed server packet. Field length pointing after end of packet in %s on line %d
++bool(false)
++
++Warning: mysqli_result::fetch_row(): Malformed server packet. Field length pointing after end of packet in %s on line %d
++bool(false)
++[*] Received: 0100000001
++[*] Server finished
++done!
+diff --git a/ext/mysqli/tests/ghsa-h35g-vwh6-m678-stmt-row-refetch.phpt b/ext/mysqli/tests/ghsa-h35g-vwh6-m678-stmt-row-refetch.phpt
+new file mode 100644
+index 00000000..9de6d863
+--- /dev/null
++++ b/ext/mysqli/tests/ghsa-h35g-vwh6-m678-stmt-row-refetch.phpt
+@@ -0,0 +1,55 @@
++--TEST--
++GHSA-h35g-vwh6-m678 follow-up (mysqlnd binary row decode error must not leave freed zvals in the result set)
++--SKIPIF--
++<?php
++if (!extension_loaded('mysqli')) die('skip mysqli extension not available');
++?>
++--FILE--
++<?php
++require_once 'fake_server.inc';
++
++$servername = "127.0.0.1";
++$username = "root";
++$password = "";
++
++$process = run_fake_server_in_background('stmt_response_row_short_length_date');
++$process->wait();
++
++$conn = new mysqli($servername, $username, $password, "", 33305);
++
++echo "[*] Prepare and execute on the fake server...\n";
++$stmt = $conn->prepare("SELECT strval, datval FROM data");
++$stmt->execute();
++$result = $stmt->get_result();
++
++// The second fetch must decode the row again, not hand out the already freed first field
++var_dump($result->fetch_row());
++var_dump($result->fetch_row());
++$result->free();
++$stmt->close();
++$conn->close();
++
++$process->terminate(true);
++
++print "done!";
++?>
++--EXPECTF--
++[*] Server started
++[*] Connection established
++[*] Sending - Server Greeting: 580000000a352e352e352d31302e352e31382d4d6172696144420003000000473e3f6047257c6700fef7080200ff81150000000000000f0000006c6b55463f49335f686c6431006d7973716c5f6e61746976655f70617373776f7264
++[*] Received: 6900000185a21a00000000c0080000000000000000000000000000000000000000000000726f6f7400006d7973716c5f6e61746976655f70617373776f7264002c0c5f636c69656e745f6e616d65076d7973716c6e640c5f7365727665725f686f7374093132372e302e302e31
++[*] Sending - Server OK: 0700000200000002000000
++[*] Prepare and execute on the fake server...
++[*] Received: 200000001653454c4543542073747276616c2c2064617476616c2046524f4d2064617461
++[*] Sending - Stmt prepare data datval: 0c0000010001000000020000000000003200000203646566087068705f74657374046461746104646174610673747276616c0673747276616c0ce000c8000000fd01100000003200000303646566087068705f74657374046461746104646174610664617476616c0664617476616c0c3f000a0000000a811000000005000004fe00000200
++[*] Received: 0a00000017010000000001000000
++[*] Sending - Malicious Stmt Response for data datval [length too short]: 01000001023200000203646566087068705f74657374046461746104646174610673747276616c0673747276616c0ce000c8000000fd01100000003200000303646566087068705f74657374046461746104646174610664617476616c0664617476616c0c3f000a0000000a811000000005000004fe00002200090000050000047465737401de05000006fe00002200
++
++Warning: mysqli_result::fetch_row(): Malformed server packet. Field length is too short for the field type in %s on line %d
++bool(false)
++
++Warning: mysqli_result::fetch_row(): Malformed server packet. Field length is too short for the field type in %s on line %d
++bool(false)
++[*] Received: 0500000019010000000100000001
++[*] Server finished
++done!
+diff --git a/ext/mysqlnd/mysqlnd_result.c b/ext/mysqlnd/mysqlnd_result.c
+index 9f19adb0..c9bb8799 100644
+--- a/ext/mysqlnd/mysqlnd_result.c
++++ b/ext/mysqlnd/mysqlnd_result.c
+@@ -1175,6 +1175,7 @@ MYSQLND_METHOD(mysqlnd_result_buffered_c, fetch_row)(MYSQLND_RES * result, void
+ conn->options->int_and_float_native,
+ conn->stats);
+ if (rc != PASS) {
++ mnd_efree(current_row);
+ DBG_RETURN(FAIL);
+ }
+ if (!ZEND_BIT_TEST(set->initialized, set->current_row)) {
+diff --git a/ext/mysqlnd/mysqlnd_wireprotocol.c b/ext/mysqlnd/mysqlnd_wireprotocol.c
+index 75dbe5e4..e7025142 100644
+--- a/ext/mysqlnd/mysqlnd_wireprotocol.c
++++ b/ext/mysqlnd/mysqlnd_wireprotocol.c
+@@ -1603,8 +1603,10 @@ php_mysqlnd_rowp_read_binary_protocol(MYSQLND_ROW_BUFFER * row_buffer, zval * fi
+ enum_mysqlnd_field_types type = fields_metadata[i].type;
+ size_t row_position = p - rbp;
+ if (rbs <= row_position) {
++ /* The caller's row storage still owns these zvals and destroys them again: leave them UNDEF. */
+ for (j = 0, current_field = start_field; j < i; current_field++, j++) {
+ zval_ptr_dtor(current_field);
++ ZVAL_UNDEF(current_field);
+ }
+ php_error_docref(NULL, E_WARNING, "Malformed server packet. No packet space left for the field");
+ DBG_RETURN(FAIL);
+@@ -1613,6 +1615,7 @@ php_mysqlnd_rowp_read_binary_protocol(MYSQLND_ROW_BUFFER * row_buffer, zval * fi
+ if (p == NULL) {
+ for (j = 0, current_field = start_field; j < i; current_field++, j++) {
+ zval_ptr_dtor(current_field);
++ ZVAL_UNDEF(current_field);
+ }
+ DBG_RETURN(FAIL);
+ }
+@@ -1695,8 +1698,10 @@ php_mysqlnd_rowp_read_text_protocol_aux(MYSQLND_ROW_BUFFER * row_buffer, zval *
+ ZVAL_NULL(current_field);
+ } else if (len > packet_end - p) {
+ php_error_docref(NULL, E_WARNING, "Malformed server packet. Field length pointing after end of packet");
++ /* The caller's row storage still owns these zvals and destroys them again: leave them UNDEF. */
+ for (j = 0, current_field = start_field; j < i; current_field++, j++) {
+ zval_ptr_dtor(current_field);
++ ZVAL_UNDEF(current_field);
+ }
+ DBG_RETURN(FAIL);
+ } else {
diff --git a/php-cve-2026-109618-delayed-oom.patch b/php-cve-2026-109618-delayed-oom.patch
new file mode 100644
index 0000000..05b77b4
--- /dev/null
+++ b/php-cve-2026-109618-delayed-oom.patch
@@ -0,0 +1,650 @@
+From: Arkadiusz Miśkiewicz <arekm at maven.pl>
+Subject: [PATCH] Delay memory limit errors to a safe point (CVE-2026-109618)
+
+Bailing out of the allocator leaves whatever the caller was modifying
+half done. The EOL mitigation (Patch243) only replaces that bailout with
+_exit(1): no shutdown functions, no error logging, the request dies.
+
+As in 8.2, exceeding memory_limit is only recorded and the allocation
+continues; the "Allowed memory size" fatal error is raised from the VM
+interrupt check, after zend_call_function(), after the main script, after
+each shutdown function and after the POST handler, i.e. where a timeout
+bails out as well. A hard limit (twice memory_limit, at most 1G above it)
+still terminates the process at once.
+
+Not taken from 8.2: fallible allocations, zend_throw_unwind_exit() (7.4
+has no unwind exit; exit() itself is a bailout here) and the
+memory_hard_limit_percent INI setting. Output buffers are discarded before
+the error is printed (e9b005158f68) unless an output handler is running.
+
+Origin: backport, https://github.com/php/php-src/commit/3584a98bda0f
+Origin: backport, https://github.com/php/php-src/commit/e9b005158f68
+Bug: https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
+---
+diff --git a/Zend/tests/oom_after_main_script.phpt b/Zend/tests/oom_after_main_script.phpt
+new file mode 100644
+index 00000000..89d3cd71
+--- /dev/null
++++ b/Zend/tests/oom_after_main_script.phpt
+@@ -0,0 +1,25 @@
++--TEST--
++OOM: shutdown functions run after an OOM raised at the end of the main script
++--INI--
++memory_limit=8M
++--FILE--
++<?php
++
++register_shutdown_function(function () {
++ echo "Shutdown function called\n";
++});
++
++function test() {
++ $a = str_repeat('a', 3 * 1024 * 1024);
++ $b = $a . $a;
++ echo "Done\n";
++}
++
++test();
++
++?>
++--EXPECTF--
++Done
++
++Fatal error: Allowed memory size of 8388608 bytes exhausted %S(tried to allocate %d bytes) in %s on line %d
++Shutdown function called
+\ No newline at end of file
+diff --git a/Zend/tests/oom_call_function.phpt b/Zend/tests/oom_call_function.phpt
+new file mode 100644
+index 00000000..b4b63845
+--- /dev/null
++++ b/Zend/tests/oom_call_function.phpt
+@@ -0,0 +1,18 @@
++--TEST--
++OOM OOM in zend_call_function()
++--INI--
++memory_limit=10M
++--ENV--
++call_user_func=call_user_func
++--FILE--
++<?php
++
++$a = [];
++for ($i = 0; $i < 10; $i++) {
++ /* Check that call_user_func() doesn't reset vm_interrupt without checking for OOMs */
++ $a[] = getenv('call_user_func')('str_repeat', 'a', 10*1024*1024);
++}
++
++?>
++--EXPECTF--
++Fatal error: Allowed memory size of 10485760 bytes exhausted %S(tried to allocate %d bytes) in %s on line 6
+\ No newline at end of file
+diff --git a/Zend/tests/oom_error_get_last.phpt b/Zend/tests/oom_error_get_last.phpt
+new file mode 100644
+index 00000000..98a07576
+--- /dev/null
++++ b/Zend/tests/oom_error_get_last.phpt
+@@ -0,0 +1,26 @@
++--TEST--
++OOM: the message returned by error_get_last() can outlive the error state
++--INI--
++memory_limit=8M
++--FILE--
++<?php
++
++register_shutdown_function(function () {
++ $error = error_get_last();
++ error_clear_last();
++ echo $error['message'], "\n";
++});
++
++function test() {
++ $a = [];
++ while (true) {
++ $a[] = str_repeat('a', 1024);
++ }
++}
++
++test();
++
++?>
++--EXPECTF--
++Fatal error: Allowed memory size of 8388608 bytes exhausted %S(tried to allocate %d bytes) in %s on line %d
++Allowed memory size of 8388608 bytes exhausted %S(tried to allocate %d bytes)
+\ No newline at end of file
+diff --git a/Zend/tests/oom_hard_limit_exceeded.phpt b/Zend/tests/oom_hard_limit_exceeded.phpt
+new file mode 100644
+index 00000000..145c8164
+--- /dev/null
++++ b/Zend/tests/oom_hard_limit_exceeded.phpt
+@@ -0,0 +1,17 @@
++--TEST--
++OOM: Hard limit exceeded
++--INI--
++memory_limit=10M
++--FILE--
++<?php
++
++function shutdown_function() {
++ echo "Not executed\n";
++}
++
++register_shutdown_function('shutdown_function');
++str_repeat('a', 1024*1024*1024);
++
++?>
++--EXPECTF--
++Fatal error: Hard memory limit of 20971520 bytes exhausted %S(tried to allocate %d bytes) in %s on line 8
+diff --git a/Zend/tests/oom_in_callback.phpt b/Zend/tests/oom_in_callback.phpt
+new file mode 100644
+index 00000000..94b29a55
+--- /dev/null
++++ b/Zend/tests/oom_in_callback.phpt
+@@ -0,0 +1,25 @@
++--TEST--
++OOM: soft limit exceeded in a callback of an internal function
++--INI--
++memory_limit=8M
++--FILE--
++<?php
++
++register_shutdown_function(function () {
++ echo "Shutdown\n";
++});
++
++$a = [3, 1, 2];
++$big = [];
++usort($a, function ($x, $y) use (&$big) {
++ for ($i = 0; $i < 2; $i++) {
++ $big[] = str_repeat('a', 3 * 1024 * 1024);
++ }
++ return $x <=> $y;
++});
++echo "Not executed\n";
++
++?>
++--EXPECTF--
++Fatal error: Allowed memory size of 8388608 bytes exhausted %S(tried to allocate %d bytes) in %s on line 11
++Shutdown
+diff --git a/Zend/tests/oom_pending_at_shutdown.phpt b/Zend/tests/oom_pending_at_shutdown.phpt
+new file mode 100644
+index 00000000..e9f9f12a
+--- /dev/null
++++ b/Zend/tests/oom_pending_at_shutdown.phpt
+@@ -0,0 +1,16 @@
++--TEST--
++An OOM left pending by a shutdown function is reported
++--INI--
++memory_limit=16M
++--FILE--
++<?php
++register_shutdown_function(function () {
++ echo "Shutdown function called\n";
++ $a = str_repeat('a', 6 * 1024 * 1024);
++ $GLOBALS['b'] = $a . $a;
++});
++?>
++--EXPECTF--
++Shutdown function called
++
++Fatal error: Allowed memory size of 16777216 bytes exhausted %S(tried to allocate %d bytes) in %s on line %d
+\ No newline at end of file
+diff --git a/Zend/tests/oom_soft_limit_exceeded.phpt b/Zend/tests/oom_soft_limit_exceeded.phpt
+new file mode 100644
+index 00000000..32b3883f
+--- /dev/null
++++ b/Zend/tests/oom_soft_limit_exceeded.phpt
+@@ -0,0 +1,19 @@
++--TEST--
++OOM: Soft limit exceeded
++--INI--
++memory_limit=10M
++--FILE--
++<?php
++
++function f() {
++ $a = [];
++ while (true) {
++ $a[] = str_repeat('a', 1024*1024);
++ }
++}
++
++f();
++
++?>
++--EXPECTF--
++Fatal error: Allowed memory size of 10485760 bytes %s (tried to allocate %d bytes) in %s on line %d
+\ No newline at end of file
+diff --git a/Zend/tests/oom_soft_limit_exceeded_fcall.phpt b/Zend/tests/oom_soft_limit_exceeded_fcall.phpt
+new file mode 100644
+index 00000000..b547cb8a
+--- /dev/null
++++ b/Zend/tests/oom_soft_limit_exceeded_fcall.phpt
+@@ -0,0 +1,19 @@
++--TEST--
++OOM: Soft limit exceeded in FCALL
++--INI--
++memory_limit=10M
++--FILE--
++<?php
++
++function f() {
++ $a = [];
++ while (true) {
++ array_push($a, str_repeat('a', 1024*1024));
++ }
++}
++
++f();
++
++?>
++--EXPECTF--
++Fatal error: Allowed memory size of 10485760 bytes %s (tried to allocate %d bytes) in %s on line %d
+\ No newline at end of file
+diff --git a/Zend/zend.c b/Zend/zend.c
+index b6675140..cc02fb14 100644
+--- a/Zend/zend.c
++++ b/Zend/zend.c
+@@ -1686,6 +1686,7 @@ ZEND_API int zend_execute_scripts(int type, zval *retval, int file_count, ...) /
+ zend_exception_error(EG(exception), E_ERROR);
+ }
+ }
++ zend_handle_delayed_oom();
+ destroy_op_array(op_array);
+ efree_size(op_array, sizeof(zend_op_array));
+ } else if (type==ZEND_REQUIRE) {
+diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c
+index be9b9754..36175d37 100644
+--- a/Zend/zend_alloc.c
++++ b/Zend/zend_alloc.c
+@@ -196,6 +196,18 @@ typedef struct _zend_mm_free_slot zend_mm_free_slot;
+ typedef struct _zend_mm_chunk zend_mm_chunk;
+ typedef struct _zend_mm_huge_list zend_mm_huge_list;
+
++typedef struct _zend_mm_delayed_oom {
++ int state;
++ uint32_t lineno;
++ zend_string *filename; /* persistent copy */
++ size_t limit;
++ size_t size;
++#if ZEND_DEBUG
++ const char *c_filename;
++ uint32_t c_lineno;
++#endif
++} zend_mm_delayed_oom;
++
+ int zend_mm_use_huge_pages = 0;
+
+ /*
+@@ -250,7 +262,9 @@ struct _zend_mm_heap {
+ #endif
+ #if ZEND_MM_LIMIT
+ size_t limit; /* memory limit */
++ size_t hard_limit; /* exceeding it terminates the process */
+ int overflow; /* memory overflow flag */
++ zend_mm_delayed_oom delayed_oom; /* memory limit error waiting for a safe point */
+ #endif
+
+ zend_mm_huge_list *huge_list; /* list of huge allocated blocks */
+@@ -421,6 +435,73 @@ static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
+ size);
+ }
+
++#if ZEND_MM_LIMIT
++#define ZEND_MM_OOM_NONE 0
++#define ZEND_MM_OOM_PENDING 1
++#define ZEND_MM_OOM_HANDLING 2
++
++/* Twice the memory limit, but at most 1G above it */
++static size_t zend_mm_hard_limit(size_t limit)
++{
++ size_t extra = MIN(limit, (size_t)1 << 30);
++
++ return limit > SIZE_MAX - extra ? SIZE_MAX : limit + extra;
++}
++
++static zend_always_inline int zend_mm_over_limit(zend_mm_heap *heap, size_t limit, size_t add_size)
++{
++ return heap->real_size > limit || add_size > limit - heap->real_size;
++}
++
++static void zend_mm_clear_delayed_oom(zend_mm_heap *heap)
++{
++ if (heap->delayed_oom.filename) {
++ zend_string_free(heap->delayed_oom.filename);
++ heap->delayed_oom.filename = NULL;
++ }
++ heap->delayed_oom.state = ZEND_MM_OOM_NONE;
++}
++
++/* A bailout out of the allocation would leave whatever the caller was
++ * modifying half done, so the memory limit error is only recorded here and
++ * raised by zend_handle_delayed_oom() from the points where a timeout may
++ * bail out too. Only the hard limit stops at once. */
++static ZEND_COLD void zend_mm_limit_exceeded(zend_mm_heap *heap, size_t add_size, size_t size ZEND_FILE_LINE_DC)
++{
++ zend_string *filename = NULL;
++ uint32_t lineno = 0;
++
++ if (zend_mm_over_limit(heap, heap->hard_limit, add_size)) {
++#if ZEND_DEBUG
++ zend_mm_safe_error_ex("Hard memory limit of %zu bytes exhausted at %s:%d (tried to allocate %zu bytes)", heap->hard_limit, __zend_filename, __zend_lineno, size);
++#else
++ zend_mm_safe_error_ex("Hard memory limit of %zu bytes exhausted (tried to allocate %zu bytes)", heap->hard_limit, size);
++#endif
++ }
++ if (heap->overflow || heap->delayed_oom.state != ZEND_MM_OOM_NONE) {
++ return;
++ }
++
++ if (zend_is_executing()) {
++ filename = zend_get_executed_filename_ex();
++ lineno = zend_get_executed_lineno();
++ } else if (zend_is_compiling()) {
++ filename = zend_get_compiled_filename();
++ lineno = zend_get_compiled_lineno();
++ }
++ heap->delayed_oom.filename = filename ? zend_string_init(ZSTR_VAL(filename), ZSTR_LEN(filename), 1) : NULL;
++ heap->delayed_oom.lineno = lineno;
++ heap->delayed_oom.limit = heap->limit;
++ heap->delayed_oom.size = size;
++#if ZEND_DEBUG
++ heap->delayed_oom.c_filename = __zend_filename;
++ heap->delayed_oom.c_lineno = __zend_lineno;
++#endif
++ heap->delayed_oom.state = ZEND_MM_OOM_PENDING;
++ EG(vm_interrupt) = 1;
++}
++#endif
++
+ #ifdef _WIN32
+ void
+ stderr_last_error(char *msg)
+@@ -976,17 +1057,15 @@ get_chunk:
+ heap->cached_chunks = chunk->next;
+ } else {
+ #if ZEND_MM_LIMIT
+- if (UNEXPECTED(ZEND_MM_CHUNK_SIZE > heap->limit - heap->real_size)) {
++ if (UNEXPECTED(zend_mm_over_limit(heap, heap->limit, ZEND_MM_CHUNK_SIZE))) {
+ if (zend_mm_gc(heap)) {
+ goto get_chunk;
+- } else if (heap->overflow == 0) {
++ }
+ #if ZEND_DEBUG
+- zend_mm_safe_error(heap, "Allowed memory size of %zu bytes exhausted at %s:%d (tried to allocate %zu bytes)", heap->limit, __zend_filename, __zend_lineno, size);
++ zend_mm_limit_exceeded(heap, ZEND_MM_CHUNK_SIZE, size ZEND_FILE_LINE_RELAY_CC);
+ #else
+- zend_mm_safe_error(heap, "Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)", heap->limit, ZEND_MM_PAGE_SIZE * pages_count);
++ zend_mm_limit_exceeded(heap, ZEND_MM_CHUNK_SIZE, ZEND_MM_PAGE_SIZE * pages_count ZEND_FILE_LINE_RELAY_CC);
+ #endif
+- return NULL;
+- }
+ }
+ #endif
+ chunk = (zend_mm_chunk*)zend_mm_chunk_alloc(heap, ZEND_MM_CHUNK_SIZE, ZEND_MM_CHUNK_SIZE);
+@@ -1502,16 +1581,11 @@ static zend_never_inline void *zend_mm_realloc_huge(zend_mm_heap *heap, void *pt
+ }
+ } else /* if (new_size > old_size) */ {
+ #if ZEND_MM_LIMIT
+- if (UNEXPECTED(new_size - old_size > heap->limit - heap->real_size)) {
+- if (zend_mm_gc(heap) && new_size - old_size <= heap->limit - heap->real_size) {
++ if (UNEXPECTED(zend_mm_over_limit(heap, heap->limit, new_size - old_size))) {
++ if (zend_mm_gc(heap) && !zend_mm_over_limit(heap, heap->limit, new_size - old_size)) {
+ /* pass */
+- } else if (heap->overflow == 0) {
+-#if ZEND_DEBUG
+- zend_mm_safe_error(heap, "Allowed memory size of %zu bytes exhausted at %s:%d (tried to allocate %zu bytes)", heap->limit, __zend_filename, __zend_lineno, size);
+-#else
+- zend_mm_safe_error(heap, "Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)", heap->limit, size);
+-#endif
+- return NULL;
++ } else {
++ zend_mm_limit_exceeded(heap, new_size - old_size, size ZEND_FILE_LINE_RELAY_CC);
+ }
+ }
+ #endif
+@@ -1794,16 +1868,11 @@ static void *zend_mm_alloc_huge(zend_mm_heap *heap, size_t size ZEND_FILE_LINE_D
+ }
+
+ #if ZEND_MM_LIMIT
+- if (UNEXPECTED(new_size > heap->limit - heap->real_size)) {
+- if (zend_mm_gc(heap) && new_size <= heap->limit - heap->real_size) {
++ if (UNEXPECTED(zend_mm_over_limit(heap, heap->limit, new_size))) {
++ if (zend_mm_gc(heap) && !zend_mm_over_limit(heap, heap->limit, new_size)) {
+ /* pass */
+- } else if (heap->overflow == 0) {
+-#if ZEND_DEBUG
+- zend_mm_safe_error(heap, "Allowed memory size of %zu bytes exhausted at %s:%d (tried to allocate %zu bytes)", heap->limit, __zend_filename, __zend_lineno, size);
+-#else
+- zend_mm_safe_error(heap, "Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)", heap->limit, size);
+-#endif
+- return NULL;
++ } else {
++ zend_mm_limit_exceeded(heap, new_size, size ZEND_FILE_LINE_RELAY_CC);
+ }
+ }
+ #endif
+@@ -1909,7 +1978,10 @@ static zend_mm_heap *zend_mm_init(void)
+ #endif
+ #if ZEND_MM_LIMIT
+ heap->limit = ((size_t)Z_L(-1) >> (size_t)Z_L(1));
++ heap->hard_limit = zend_mm_hard_limit(heap->limit);
+ heap->overflow = 0;
++ heap->delayed_oom.state = ZEND_MM_OOM_NONE;
++ heap->delayed_oom.filename = NULL;
+ #endif
+ #if ZEND_MM_CUSTOM
+ heap->use_custom_heap = ZEND_MM_CUSTOM_HEAP_NONE;
+@@ -2236,6 +2308,10 @@ void zend_mm_shutdown(zend_mm_heap *heap, int full, int silent)
+ zend_mm_chunk *p;
+ zend_mm_huge_list *list;
+
++#if ZEND_MM_LIMIT
++ zend_mm_clear_delayed_oom(heap);
++#endif
++
+ #if ZEND_MM_CUSTOM
+ if (heap->use_custom_heap) {
+ if (heap->custom_heap.std._malloc == tracked_malloc) {
+@@ -2695,10 +2771,58 @@ ZEND_API int zend_set_memory_limit(size_t memory_limit)
+ return FAILURE;
+ }
+ AG(mm_heap)->limit = memory_limit;
++ AG(mm_heap)->hard_limit = zend_mm_hard_limit(memory_limit);
+ #endif
+ return SUCCESS;
+ }
+
++ZEND_API int zend_alloc_in_memory_limit_error_reporting(void)
++{
++#if ZEND_MM_LIMIT
++ return AG(mm_heap)->overflow;
++#else
++ return 0;
++#endif
++}
++
++#if ZEND_MM_LIMIT
++static ZEND_COLD void zend_mm_report_delayed_oom(const char *filename, uint32_t lineno, const char *format, ...)
++{
++ va_list args;
++
++ va_start(args, format);
++ zend_error_cb(E_ERROR, filename, lineno, format, args);
++ va_end(args);
++}
++
++#endif
++
++ZEND_API void ZEND_FASTCALL zend_handle_delayed_oom(void)
++{
++#if ZEND_MM_LIMIT
++ zend_mm_heap *heap = AG(mm_heap);
++ zend_mm_delayed_oom *oom = &heap->delayed_oom;
++
++ if (EXPECTED(oom->state != ZEND_MM_OOM_PENDING)) {
++ return;
++ }
++
++ oom->state = ZEND_MM_OOM_HANDLING;
++ heap->overflow = 1;
++ zend_try {
++ zend_mm_report_delayed_oom(oom->filename ? ZSTR_VAL(oom->filename) : "Unknown", oom->lineno,
++#if ZEND_DEBUG
++ "Allowed memory size of %zu bytes exhausted at %s:%d (tried to allocate %zu bytes)", oom->limit, oom->c_filename, oom->c_lineno, oom->size);
++#else
++ "Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)", oom->limit, oom->size);
++#endif
++ } zend_end_try();
++ heap->overflow = 0;
++ zend_mm_clear_delayed_oom(heap);
++ zend_bailout();
++#endif
++}
++
+ ZEND_API size_t zend_memory_usage(int real_usage)
+ {
+ #if ZEND_MM_STAT
+@@ -2967,7 +3091,10 @@ ZEND_API zend_mm_heap *zend_mm_startup_ex(const zend_mm_handlers *handlers, void
+ #endif
+ #if ZEND_MM_LIMIT
+ heap->limit = (Z_L(-1) >> Z_L(1));
++ heap->hard_limit = zend_mm_hard_limit(heap->limit);
+ heap->overflow = 0;
++ heap->delayed_oom.state = ZEND_MM_OOM_NONE;
++ heap->delayed_oom.filename = NULL;
+ #endif
+ #if ZEND_MM_CUSTOM
+ heap->use_custom_heap = 0;
+diff --git a/Zend/zend_alloc.h b/Zend/zend_alloc.h
+index 2ed2ed8b..34acd67c 100644
+--- a/Zend/zend_alloc.h
++++ b/Zend/zend_alloc.h
+@@ -220,6 +220,8 @@ ZEND_API void * __zend_realloc(void *p, size_t len) ZEND_ATTRIBUTE_ALLOC_SIZE(2)
+ #define pestrdup_rel(s, persistent) ((persistent)?strdup(s):estrdup_rel(s))
+
+ ZEND_API int zend_set_memory_limit(size_t memory_limit);
++ZEND_API int zend_alloc_in_memory_limit_error_reporting(void);
++ZEND_API void ZEND_FASTCALL zend_handle_delayed_oom(void);
+
+ ZEND_API void start_memory_manager(void);
+ ZEND_API void shutdown_memory_manager(int silent, int full_shutdown);
+diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c
+index 724b6e5c..e1e15a63 100644
+--- a/Zend/zend_execute_API.c
++++ b/Zend/zend_execute_API.c
+@@ -891,6 +891,9 @@ int zend_call_function(zend_fcall_info *fci, zend_fcall_info_cache *fci_cache) /
+ EG(current_execute_data) = dummy_execute_data.prev_execute_data;
+ }
+
++ /* internal functions and callees without jumps never reach the VM interrupt check */
++ zend_handle_delayed_oom();
++
+ if (UNEXPECTED(EG(exception))) {
+ if (UNEXPECTED(!EG(current_execute_data))) {
+ zend_throw_exception_internal(NULL);
+diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h
+index 3af90d70..262733de 100644
+--- a/Zend/zend_vm_def.h
++++ b/Zend/zend_vm_def.h
+@@ -9424,7 +9424,9 @@ ZEND_VM_HELPER(zend_interrupt_helper, ANY, ANY)
+ SAVE_OPLINE();
+ if (EG(timed_out)) {
+ zend_timeout(0);
+- } else if (zend_interrupt_function) {
++ }
++ zend_handle_delayed_oom();
++ if (zend_interrupt_function) {
+ zend_interrupt_function(execute_data);
+ ZEND_VM_ENTER();
+ }
+diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h
+index 8701e77e..266a0cf1 100644
+--- a/Zend/zend_vm_execute.h
++++ b/Zend/zend_vm_execute.h
+@@ -2894,7 +2894,9 @@ static zend_never_inline ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL zend_interrupt_he
+ SAVE_OPLINE();
+ if (EG(timed_out)) {
+ zend_timeout(0);
+- } else if (zend_interrupt_function) {
++ }
++ zend_handle_delayed_oom();
++ if (zend_interrupt_function) {
+ zend_interrupt_function(execute_data);
+ ZEND_VM_ENTER();
+ }
+diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c
+index 45746335..d9c4cf59 100644
+--- a/ext/standard/basic_functions.c
++++ b/ext/standard/basic_functions.c
+@@ -5084,6 +5084,7 @@ static int user_shutdown_function_call(zval *zv) /* {{{ */
+ {
+ zval_ptr_dtor(&retval);
+ }
++ zend_handle_delayed_oom();
+ return 0;
+ }
+ /* }}} */
+diff --git a/ext/standard/tests/streams/bug61115-1.phpt b/ext/standard/tests/streams/bug61115-1.phpt
+index 892b0f80..ffca6f3c 100644
+--- a/ext/standard/tests/streams/bug61115-1.phpt
++++ b/ext/standard/tests/streams/bug61115-1.phpt
+@@ -14,4 +14,4 @@ stream_context_get_options($fileResourceTemp);
+ ftruncate($fileResourceTemp, PHP_INT_MAX);
+ ?>
+ --EXPECTF--
+-Fatal error: Allowed memory size of %d bytes exhausted%s(tried to allocate %d bytes) in %s on line %d
++Fatal error: %rHard memory limit|Allowed memory size%r of %d bytes exhausted%s(tried to allocate %d bytes) in %s on line %d
+diff --git a/ext/standard/tests/strings/wordwrap_memory_limit.phpt b/ext/standard/tests/strings/wordwrap_memory_limit.phpt
+index 21340153..8877a6c8 100644
+--- a/ext/standard/tests/strings/wordwrap_memory_limit.phpt
++++ b/ext/standard/tests/strings/wordwrap_memory_limit.phpt
+@@ -16,4 +16,4 @@ wordwrap($str, 1, $str2);
+
+ ?>
+ --EXPECTF--
+-Fatal error: Allowed memory size of 134217728 bytes exhausted%s(tried to allocate %d bytes) in %s on line %d
++Fatal error: %rHard memory limit|Allowed memory size%r of %d bytes exhausted%s(tried to allocate %d bytes) in %s on line %d
+diff --git a/main/SAPI.c b/main/SAPI.c
+index a3755033..fbf71ffa 100644
+--- a/main/SAPI.c
++++ b/main/SAPI.c
+@@ -168,6 +168,8 @@ SAPI_API void sapi_handle_post(void *arg)
+ SG(request_info).post_entry->post_handler(SG(request_info).content_type_dup, arg);
+ efree(SG(request_info).content_type_dup);
+ SG(request_info).content_type_dup = NULL;
++ /* do not run the script with a request that did not fit */
++ zend_handle_delayed_oom();
+ }
+ }
+
+diff --git a/main/main.c b/main/main.c
+index bb8501f9..e07f7ce3 100644
+--- a/main/main.c
++++ b/main/main.c
+@@ -1328,6 +1328,13 @@ static ZEND_COLD void php_error_cb(int type, const char *error_filename, const u
+ PG(last_error_lineno) = error_lineno;
+ }
+
++ /* print the memory limit message instead of output it may not fit in;
++ * discarding frees every handler, not safe while one of them runs */
++ if (zend_alloc_in_memory_limit_error_reporting()
++ && !(php_output_get_status() & PHP_OUTPUT_LOCKED)) {
++ php_output_discard_all();
++ }
++
+ /* display/log the error if necessary */
+ if (display && (EG(error_reporting) & type || (type & E_CORE))
+ && (PG(log_errors) || PG(display_errors) || (!module_initialized))) {
+@@ -1922,12 +1929,6 @@ void php_request_shutdown(void *dummy)
+ zend_try {
+ zend_bool send_buffer = SG(request_info).headers_only ? 0 : 1;
+
+- if (CG(unclean_shutdown) && PG(last_error_type) == E_ERROR &&
+- (size_t)PG(memory_limit) < zend_memory_usage(1)
+- ) {
+- send_buffer = 0;
+- }
+-
+ if (!send_buffer) {
+ php_output_discard_all();
+ } else {
diff --git a/php-cve-2026-109618-session.patch b/php-cve-2026-109618-session.patch
new file mode 100644
index 0000000..d57dcf8
--- /dev/null
+++ b/php-cve-2026-109618-session.patch
@@ -0,0 +1,787 @@
+From: Arnaud Le Blanc <arnaud.lb at gmail.com>
+Subject: Run session upload progress without user code instead of disabling it
+
+The minimal EOL fix for CVE-2026-109618 turns session upload progress off.
+This is the session part of the PHP 8.2 fix instead (same code in master):
+the progress handler runs before RINIT and now decodes the session with every
+object kept as __PHP_Incomplete_Class (no autoload, unserialize_callback_func,
+__wakeup(), __unserialize()).
+
+Backported to 7.4: no enums here, no request_parse_body() guard (7.4 parses
+POST before RINIT only), OOM unwinding parts left out, tests use a proc_open()
+command string and display_startup_errors. Added: progress is skipped for
+third-party session serializers (igbinary, msgpack), which still resolve
+classes and call __wakeup() while decoding.
+
+Changed from 8.2: a session that cannot be decoded this way is left alone and
+its progress is not recorded, where 8.2 destroys it. This includes every
+Serializable (C:) object, which 8.2 writes back verbatim: the values that
+Serializable::serialize() nests through serialize() share the back-reference
+numbering of the session, so without the class the references after such an
+object resolve to other values or to none.
+
+Origin: backport, https://github.com/php/php-src/commit/3584a98bda0f
+Bug: https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
+---
+diff --git a/ext/session/php_session.h b/ext/session/php_session.h
+index 4c795fdb..a396efbf 100644
+--- a/ext/session/php_session.h
++++ b/ext/session/php_session.h
+@@ -203,6 +203,8 @@ typedef struct _php_ps_globals {
+ zend_bool in_save_handler; /* state if session is in save handler or not */
+ zend_bool set_handler; /* state if session module i setting handler or not */
+ zend_string *session_vars; /* serialized original session data */
++
++ zend_bool in_rfc1867_update; /* php_session_rfc1867_update() is running */
+ } php_ps_globals;
+
+ typedef php_ps_globals zend_ps_globals;
+diff --git a/ext/session/session.c b/ext/session/session.c
+index 89a7400a..06425850 100644
+--- a/ext/session/session.c
++++ b/ext/session/session.c
+@@ -120,6 +120,7 @@ static inline void php_rinit_session_globals(void) /* {{{ */
+ PS(define_sid) = 1;
+ PS(session_vars) = NULL;
+ PS(module_number) = my_module_number;
++ PS(in_rfc1867_update) = 0;
+ ZVAL_UNDEF(&PS(http_session_vars));
+ }
+ /* }}} */
+@@ -249,6 +250,10 @@ static int php_session_decode(zend_string *data) /* {{{ */
+ return FAILURE;
+ }
+ if (PS(serializer)->decode(ZSTR_VAL(data), ZSTR_LEN(data)) == FAILURE) {
++ if (PS(in_rfc1867_update)) {
++ /* the request itself may still decode the data, see php_session_unserialize_init() */
++ return FAILURE;
++ }
+ php_session_destroy();
+ php_session_track_init();
+ php_error_docref(NULL, E_WARNING, "Failed to decode session object. Session has been destroyed");
+@@ -454,7 +459,11 @@ static int php_session_initialize(void) /* {{{ */
+ if (PS(lazy_write)) {
+ PS(session_vars) = zend_string_copy(val);
+ }
+- php_session_decode(val);
++ if (php_session_decode(val) == FAILURE && PS(in_rfc1867_update)) {
++ zend_string_release_ex(val, 0);
++ php_session_abort();
++ return FAILURE;
++ }
+ zend_string_release_ex(val, 0);
+ }
+ return SUCCESS;
+@@ -835,6 +844,20 @@ PHP_INI_END()
+ /* ***************
+ * Serializers *
+ *************** */
++/* The upload progress handler (php_session_rfc1867_update()) decodes and
++ * re-encodes the session during request startup, before RINIT, when no user
++ * code may run. Objects are then kept as __PHP_Incomplete_Class: no
++ * autoloading, no unserialize_callback_func, no __wakeup(), and they are
++ * written back unchanged. Data that cannot be decoded that way (Serializable
++ * objects) is left alone: the progress is not recorded. */
++static void php_session_unserialize_init(php_unserialize_data_t *var_hash)
++{
++ PHP_VAR_UNSERIALIZE_INIT(*var_hash);
++ if (PS(in_rfc1867_update)) {
++ php_var_unserialize_set_incomplete_classes_only(*var_hash, 1);
++ }
++}
++
+ PS_SERIALIZER_ENCODE_FUNC(php_serialize) /* {{{ */
+ {
+ smart_str buf = {0};
+@@ -858,13 +881,18 @@ PS_SERIALIZER_DECODE_FUNC(php_serialize) /* {{{ */
+ zend_string *var_name = zend_string_init("_SESSION", sizeof("_SESSION") - 1, 0);
+
+ ZVAL_NULL(&session_vars);
+- PHP_VAR_UNSERIALIZE_INIT(var_hash);
++ php_session_unserialize_init(&var_hash);
+ result = php_var_unserialize(
+ &session_vars, (const unsigned char **)&val, (const unsigned char *)endptr, &var_hash);
+ PHP_VAR_UNSERIALIZE_DESTROY(var_hash);
+ if (!result) {
+ zval_ptr_dtor(&session_vars);
+ ZVAL_NULL(&session_vars);
++ if (PS(in_rfc1867_update)) {
++ /* the empty session would be written back over the data */
++ zend_string_release_ex(var_name, 0);
++ return FAILURE;
++ }
+ }
+
+ if (!Z_ISUNDEF(PS(http_session_vars))) {
+@@ -916,7 +944,7 @@ PS_SERIALIZER_DECODE_FUNC(php_binary) /* {{{ */
+ php_unserialize_data_t var_hash;
+ zval *current, rv;
+
+- PHP_VAR_UNSERIALIZE_INIT(var_hash);
++ php_session_unserialize_init(&var_hash);
+
+ for (p = val; p < endptr; ) {
+ namelen = ((unsigned char)(*p)) & (~PS_BIN_UNDEF);
+@@ -987,7 +1015,7 @@ PS_SERIALIZER_DECODE_FUNC(php) /* {{{ */
+ php_unserialize_data_t var_hash;
+ zval *current, rv;
+
+- PHP_VAR_UNSERIALIZE_INIT(var_hash);
++ php_session_unserialize_init(&var_hash);
+
+ p = val;
+
+@@ -2935,6 +2963,7 @@ static PHP_GINIT_FUNCTION(ps) /* {{{ */
+ ps_globals->mod_user_implemented = 0;
+ ps_globals->mod_user_is_open = 0;
+ ps_globals->session_vars = NULL;
++ ps_globals->in_rfc1867_update = 0;
+ ps_globals->set_handler = 0;
+ for (i = 0; i < PS_NUM_APIS; i++) {
+ ZVAL_UNDEF(&ps_globals->mod_user_names.names[i]);
+@@ -3141,8 +3170,12 @@ static void php_session_rfc1867_update(php_session_rfc1867_progress *progress, i
+ progress->next_update = Z_LVAL_P(progress->post_bytes_processed) + progress->update_step;
+ }
+
+- php_session_initialize();
+- PS(session_status) = php_session_active;
++ /* Runs before RINIT: no user code may be invoked, see php_session_unserialize_init() */
++ PS(in_rfc1867_update) = 1;
++ if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
++ PS(in_rfc1867_update) = 0;
++ return;
++ }
+ IF_SESSION_VARS() {
+ zval *sess_var = Z_REFVAL(PS(http_session_vars));
+ SEPARATE_ARRAY(sess_var);
+@@ -3152,20 +3185,33 @@ static void php_session_rfc1867_update(php_session_rfc1867_progress *progress, i
+ zend_hash_update(Z_ARRVAL_P(sess_var), progress->key.s, &progress->data);
+ }
+ php_session_flush(1);
++ PS(in_rfc1867_update) = 0;
+ } /* }}} */
+
+ static void php_session_rfc1867_cleanup(php_session_rfc1867_progress *progress) /* {{{ */
+ {
+- php_session_initialize();
+- PS(session_status) = php_session_active;
++ PS(in_rfc1867_update) = 1;
++ if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
++ PS(in_rfc1867_update) = 0;
++ return;
++ }
+ IF_SESSION_VARS() {
+ zval *sess_var = Z_REFVAL(PS(http_session_vars));
+ SEPARATE_ARRAY(sess_var);
+ zend_hash_del(Z_ARRVAL_P(sess_var), progress->key.s);
+ }
+ php_session_flush(1);
++ PS(in_rfc1867_update) = 0;
+ } /* }}} */
+
++/* incomplete_classes_only covers the predefined serializers only; third-party
++ * ones (igbinary, msgpack) look classes up and call __wakeup() while decoding */
++static zend_bool php_session_rfc1867_serializer_supported(void)
++{
++ return PS(serializer) >= ps_serializers
++ && PS(serializer) < ps_serializers + PREDEFINED_SERIALIZERS;
++}
++
+ static int php_session_rfc1867_callback(unsigned int event, void *event_data, void **extra) /* {{{ */
+ {
+ php_session_rfc1867_progress *progress;
+@@ -3174,7 +3220,7 @@ static int php_session_rfc1867_callback(unsigned int event, void *event_data, vo
+ if (php_session_rfc1867_orig_callback) {
+ retval = php_session_rfc1867_orig_callback(event, event_data, extra);
+ }
+- if (!PS(rfc1867_enabled) || true) {
++ if (!PS(rfc1867_enabled) || !php_session_rfc1867_serializer_supported()) {
+ return retval;
+ }
+
+@@ -3183,6 +3229,10 @@ static int php_session_rfc1867_callback(unsigned int event, void *event_data, vo
+ switch(event) {
+ case MULTIPART_EVENT_START: {
+ multipart_event_start *data = (multipart_event_start *) event_data;
++
++ /* This runs before RINIT, so we need to clear these early */
++ php_rinit_session_globals();
++
+ progress = ecalloc(1, sizeof(php_session_rfc1867_progress));
+ progress->content_length = data->content_length;
+ progress->sname_len = strlen(PS(session_name));
+@@ -3340,6 +3390,11 @@ static int php_session_rfc1867_callback(unsigned int event, void *event_data, vo
+ efree(progress);
+ progress = NULL;
+ PS(rfc1867_progress) = NULL;
++
++ /* $_SESSION was populated in incomplete_classes_only mode */
++ zend_string *session_var_name = zend_string_init("_SESSION", sizeof("_SESSION") - 1, 0);
++ zend_delete_global_variable(session_var_name);
++ zend_string_release_ex(session_var_name, 0);
+ }
+ break;
+ }
+diff --git a/ext/session/tests/rfc1867_incomplete_class_reserved_props.phpt b/ext/session/tests/rfc1867_incomplete_class_reserved_props.phpt
+new file mode 100644
+index 00000000..b40530ea
+--- /dev/null
++++ b/ext/session/tests/rfc1867_incomplete_class_reserved_props.phpt
+@@ -0,0 +1,194 @@
++--TEST--
++session rfc1867: the upload progress update leaves session data it cannot decode without user code untouched
++--SKIPIF--
++<?php
++include('skipif.inc');
++if (!getenv('TEST_PHP_CGI_EXECUTABLE')) {
++ die('skip CGI not available');
++}
++?>
++--FILE--
++<?php
++
++$dir = __DIR__ . '/rfc1867_incomplete_class_reserved_props';
++ at mkdir($dir);
++$sid = 'rfc1867reservedprops';
++$file = "$dir/sess_$sid";
++$worker = "$dir/worker.php";
++file_put_contents($worker, '<?php');
++
++/* The handler decodes the record with every object kept as
++ * __PHP_Incomplete_Class, which stores the class name of each object under
++ * __PHP_Incomplete_Class_Name. Data that already carries that property cannot
++ * be told apart from it. A Serializable payload (C:) can only be parsed by its
++ * class, and the values it holds take part in the back-reference numbering
++ * (b|r:6 below points past the three values of the payload). Such records,
++ * like corrupt ones, must reach the request unchanged: the progress is not
++ * recorded and the session is not destroyed. */
++/* name => serialized value; $ref is the number of the first top-level value,
++ * which php_serialize spends on the enclosing array */
++$cases = [
++ 'control' => function ($ref) { return ['x' => 'O:8:"stdClass":1:{s:1:"a";i:1;}']; },
++ 'Class_Name' => function ($ref) { return ['x' => 'O:8:"stdClass":1:{s:27:"__PHP_Incomplete_Class_Name";s:3:"foo";}']; },
++ 'nested' => function ($ref) { return ['x' => 'a:1:{i:0;O:8:"stdClass":1:{s:27:"__PHP_Incomplete_Class_Name";s:3:"foo";}}']; },
++ 'mangled' => function ($ref) { return ['x' => 'O:8:"stdClass":1:{s:30:"' . "\0" . 'A' . "\0" . '__PHP_Incomplete_Class_Name";s:3:"foo";}']; },
++ 'Serializable' => function ($ref) { return ['x' => 'C:4:"Cust":7:{payload}']; },
++ 'Serializable_ref' => function ($ref) { return [
++ 'p' => 'C:1:"P":30:{a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}}',
++ 'a' => 'O:8:"stdClass":1:{s:1:"v";i:42;}',
++ 'b' => 'r:' . ($ref + 5) . ';',
++ ]; },
++ 'corrupt' => function ($ref) { return ['x' => 'not-serialized-at-all']; },
++];
++
++function encode_var($handler, $name, $value) {
++ if ($handler === 'php') {
++ return "$name|$value";
++ } else if ($handler === 'php_binary') {
++ return chr(strlen($name)) . $name . $value;
++ }
++ return 's:' . strlen($name) . ":\"$name\";$value";
++}
++
++function encode_record($handler, array $vars) {
++ $record = '';
++ foreach ($vars as $name => $value) {
++ $record .= encode_var($handler, $name, $value);
++ }
++ return $handler === 'php_serialize' ? 'a:' . count($vars) . ":{{$record}}" : $record;
++}
++
++$boundary = '---------------------------20896060251896012921717172737';
++$body = "--$boundary\r\n"
++ . "Content-Disposition: form-data; name=\"PHPSESSID\"\r\n\r\n$sid\r\n"
++ . "--$boundary\r\n"
++ . "Content-Disposition: form-data; name=\"PHP_SESSION_UPLOAD_PROGRESS\"\r\n\r\nkey\r\n"
++ . "--$boundary\r\n"
++ . "Content-Disposition: form-data; name=\"file1\"; filename=\"file1.txt\"\r\n\r\n1\r\n"
++ . "--$boundary--\r\n";
++
++$ini = [
++ 'error_reporting' => 'E_ALL',
++ 'html_errors' => '0',
++ /* the handler runs before RINIT; off by default before PHP 8.0 */
++ 'display_startup_errors' => '1',
++ 'file_uploads' => '1',
++ 'upload_max_filesize' => '1024',
++ 'session.save_path' => $dir,
++ 'session.name' => 'PHPSESSID',
++ 'session.use_strict_mode' => '0',
++ 'session.use_cookies' => '0',
++ 'session.use_only_cookies' => '0',
++ 'session.cache_limiter' => '',
++ 'session.save_handler' => 'files',
++ 'session.upload_progress.enabled' => '1',
++ 'session.upload_progress.cleanup' => '0',
++ 'session.upload_progress.prefix' => 'upload_progress_',
++ 'session.upload_progress.name' => 'PHP_SESSION_UPLOAD_PROGRESS',
++ 'session.upload_progress.freq' => '1%',
++ 'session.upload_progress.min_freq' => '0.000000001',
++];
++
++/* php-cgi is started with -n, so shared spl and session modules have to be named again. */
++$extension_dir = ini_get('extension_dir');
++$extensions = [];
++foreach (['spl', 'session'] as $ext) {
++ foreach (["$ext." . PHP_SHLIB_SUFFIX, "php_$ext." . PHP_SHLIB_SUFFIX] as $module) {
++ if ($extension_dir !== false && file_exists("$extension_dir/$module")) {
++ $ini['extension_dir'] = $extension_dir;
++ $extensions[] = $module;
++ break;
++ }
++ }
++}
++
++/* proc_open() takes a command array from PHP 7.4 on */
++$cmd = escapeshellarg(getenv('TEST_PHP_CGI_EXECUTABLE')) . ' -n -q';
++foreach ($ini as $name => $value) {
++ $cmd .= ' -d ' . escapeshellarg("$name=$value");
++}
++foreach ($extensions as $module) {
++ $cmd .= ' -d ' . escapeshellarg("extension=$module");
++}
++
++$env = getenv();
++$env['REDIRECT_STATUS'] = '1';
++$env['REQUEST_METHOD'] = 'POST';
++$env['CONTENT_TYPE'] = "multipart/form-data; boundary=$boundary";
++$env['CONTENT_LENGTH'] = (string) strlen($body);
++$env['SCRIPT_FILENAME'] = $worker;
++$env['PATH_TRANSLATED'] = $worker;
++$env['QUERY_STRING'] = '';
++
++foreach (['php', 'php_binary', 'php_serialize'] as $handler) {
++ echo "$handler:\n";
++ foreach ($cases as $label => $vars) {
++ $vars = $vars($handler === 'php_serialize' ? 2 : 1);
++ $record = encode_record($handler, $vars);
++ file_put_contents($file, $record);
++
++ $pipes = [];
++ $proc = proc_open(
++ $cmd . ' -d session.serialize_handler=' . $handler . ' -f ' . escapeshellarg($worker),
++ [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
++ $pipes,
++ $dir,
++ $env
++ );
++ fwrite($pipes[0], $body);
++ fclose($pipes[0]);
++ $out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
++ fclose($pipes[1]);
++ fclose($pipes[2]);
++ proc_close($proc);
++
++ /* without the progress the file must be byte for byte the same, with it
++ * every variable must still be there */
++ $after = file_exists($file) ? file_get_contents($file) : '';
++ $progress = strpos($after, 'upload_progress_key') !== false;
++ $kept = $progress || $after === $record;
++ foreach ($vars as $name => $value) {
++ $kept = $kept && strpos($after, encode_var($handler, $name, $value)) !== false;
++ }
++ printf("%-16s warnings: %d, progress: %-5s record kept: %s\n", $label,
++ substr_count($out, 'Warning:'),
++ var_export($progress, true),
++ var_export($kept, true));
++ }
++}
++
++?>
++--CLEAN--
++<?php
++$dir = __DIR__ . '/rfc1867_incomplete_class_reserved_props';
++ at unlink("$dir/worker.php");
++foreach (glob("$dir/sess_*") as $f) {
++ unlink($f);
++}
++ at rmdir($dir);
++?>
++--EXPECT--
++php:
++control warnings: 0, progress: true record kept: true
++Class_Name warnings: 0, progress: false record kept: true
++nested warnings: 0, progress: false record kept: true
++mangled warnings: 0, progress: true record kept: true
++Serializable warnings: 0, progress: false record kept: true
++Serializable_ref warnings: 0, progress: false record kept: true
++corrupt warnings: 0, progress: false record kept: true
++php_binary:
++control warnings: 0, progress: true record kept: true
++Class_Name warnings: 0, progress: false record kept: true
++nested warnings: 0, progress: false record kept: true
++mangled warnings: 0, progress: true record kept: true
++Serializable warnings: 0, progress: false record kept: true
++Serializable_ref warnings: 0, progress: false record kept: true
++corrupt warnings: 0, progress: false record kept: true
++php_serialize:
++control warnings: 0, progress: true record kept: true
++Class_Name warnings: 0, progress: false record kept: true
++nested warnings: 0, progress: false record kept: true
++mangled warnings: 0, progress: true record kept: true
++Serializable warnings: 0, progress: false record kept: true
++Serializable_ref warnings: 0, progress: false record kept: true
++corrupt warnings: 0, progress: false record kept: true
+diff --git a/ext/session/tests/rfc1867_no_user_code.phpt b/ext/session/tests/rfc1867_no_user_code.phpt
+new file mode 100644
+index 00000000..d03aecc3
+--- /dev/null
++++ b/ext/session/tests/rfc1867_no_user_code.phpt
+@@ -0,0 +1,187 @@
++--TEST--
++session rfc1867: the upload progress update resolves no classes, keeps the session data intact and leaves no $_SESSION behind
++--SKIPIF--
++<?php
++include('skipif.inc');
++if (!getenv('TEST_PHP_CGI_EXECUTABLE')) {
++ die('skip CGI not available');
++}
++?>
++--FILE--
++<?php
++
++$dir = __DIR__ . '/rfc1867_no_user_code';
++ at mkdir($dir);
++$sid = 'rfc1867nousercode';
++$file = "$dir/sess_$sid";
++$worker = "$dir/worker.php";
++
++/* A hand-written record, as session_encode() produces it for the `php` handler:
++ * objects with __wakeup(), one of them shared through a back-reference (r:),
++ * and a class that does not exist. Writing it directly leaves the session
++ * module untouched until the upload progress handler runs, as in a real request. */
++$record = 'w|O:1:"W":1:{s:1:"a";i:1;}'
++ . 'r|a:2:{i:0;O:1:"W":1:{s:1:"a";i:2;}i:1;r:4;}'
++ . 'm|O:7:"Missing":1:{s:1:"x";i:3;}';
++file_put_contents($file, $record);
++
++/* None of these classes exist while the handler decodes the record: the request
++ * script is not even compiled yet. The handler keeps every object as
++ * __PHP_Incomplete_Class instead and writes it back unchanged. */
++file_put_contents($worker, <<<'PHP'
++<?php
++$record = getenv('TEST_SESSION_RECORD');
++$file = getenv('TEST_SESSION_FILE');
++
++spl_autoload_register(function ($class) { echo "autoload($class)\n"; });
++
++class W {
++ public $a = 1;
++ function __wakeup() {
++ echo "W::__wakeup\n";
++ }
++ function __sleep() {
++ echo "W::__sleep\n"; return ['a'];
++ }
++}
++
++/* The handler needs a $_SESSION to decode the record into, and registers it in
++ * the global symbol table. This script has not called session_start(), so it
++ * must not be left behind: it would otherwise expose the session data of
++ * whatever id the request body carried. */
++var_dump(array_key_exists('_SESSION', $GLOBALS));
++var_dump(session_status() === PHP_SESSION_NONE);
++
++echo "--- record after the update:\n";
++$after = file_get_contents($file);
++var_dump(substr($after, 0, strlen($record)) === $record);
++var_dump(substr($after, strlen($record), strlen('upload_progress_key|')));
++
++echo "--- session_start():\n";
++session_start();
++var_dump(session_id());
++var_dump($_SESSION['w']->a, $_SESSION['r'][0]->a);
++var_dump($_SESSION['r'][0] === $_SESSION['r'][1]);
++var_dump($_SESSION['m']);
++var_dump(isset($_SESSION['upload_progress_key']['bytes_processed']));
++session_destroy();
++PHP);
++
++$boundary = '---------------------------20896060251896012921717172737';
++$body = "--$boundary\r\n"
++ . "Content-Disposition: form-data; name=\"PHPSESSID\"\r\n\r\n$sid\r\n"
++ . "--$boundary\r\n"
++ . "Content-Disposition: form-data; name=\"PHP_SESSION_UPLOAD_PROGRESS\"\r\n\r\nkey\r\n"
++ . "--$boundary\r\n"
++ . "Content-Disposition: form-data; name=\"file1\"; filename=\"file1.txt\"\r\n\r\n1\r\n"
++ . "--$boundary--\r\n";
++
++$ini = [
++ 'error_reporting' => 'E_ALL&~E_DEPRECATED',
++ 'file_uploads' => '1',
++ 'upload_max_filesize' => '1024',
++ 'session.save_path' => $dir,
++ 'session.name' => 'PHPSESSID',
++ 'session.use_strict_mode' => '0',
++ 'session.use_cookies' => '0',
++ 'session.use_only_cookies' => '0',
++ 'session.cache_limiter' => '',
++ 'session.save_handler' => 'files',
++ 'session.serialize_handler' => 'php',
++ 'session.upload_progress.enabled' => '1',
++ 'session.upload_progress.cleanup' => '0',
++ 'session.upload_progress.prefix' => 'upload_progress_',
++ 'session.upload_progress.name' => 'PHP_SESSION_UPLOAD_PROGRESS',
++ 'session.upload_progress.freq' => '1%',
++ 'session.upload_progress.min_freq' => '0.000000001',
++];
++
++/* php-cgi is started with -n, so shared spl and session modules have to be named again. */
++$extension_dir = ini_get('extension_dir');
++$extensions = [];
++foreach (['spl', 'session'] as $ext) {
++ foreach (["$ext." . PHP_SHLIB_SUFFIX, "php_$ext." . PHP_SHLIB_SUFFIX] as $module) {
++ if ($extension_dir !== false && file_exists("$extension_dir/$module")) {
++ $ini['extension_dir'] = $extension_dir;
++ $extensions[] = $module;
++ break;
++ }
++ }
++}
++
++/* proc_open() takes a command array from PHP 7.4 on */
++$cmd = escapeshellarg(getenv('TEST_PHP_CGI_EXECUTABLE')) . ' -n -q';
++foreach ($ini as $name => $value) {
++ $cmd .= ' -d ' . escapeshellarg("$name=$value");
++}
++foreach ($extensions as $module) {
++ $cmd .= ' -d ' . escapeshellarg("extension=$module");
++}
++$cmd .= ' -f ' . escapeshellarg($worker);
++
++$env = getenv();
++$env['REDIRECT_STATUS'] = '1';
++$env['REQUEST_METHOD'] = 'POST';
++$env['CONTENT_TYPE'] = "multipart/form-data; boundary=$boundary";
++$env['CONTENT_LENGTH'] = (string) strlen($body);
++$env['SCRIPT_FILENAME'] = $worker;
++$env['PATH_TRANSLATED'] = $worker;
++$env['QUERY_STRING'] = '';
++$env['TEST_SESSION_RECORD'] = $record;
++$env['TEST_SESSION_FILE'] = $file;
++
++$pipes = [];
++$proc = proc_open(
++ $cmd,
++ [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
++ $pipes,
++ $dir,
++ $env
++);
++fwrite($pipes[0], $body);
++fclose($pipes[0]);
++$out = stream_get_contents($pipes[1]);
++$err = stream_get_contents($pipes[2]);
++fclose($pipes[1]);
++fclose($pipes[2]);
++proc_close($proc);
++
++echo $out;
++if ($err !== '') {
++ echo "--- stderr:\n$err";
++}
++
++?>
++--CLEAN--
++<?php
++$dir = __DIR__ . '/rfc1867_no_user_code';
++ at unlink("$dir/worker.php");
++foreach (glob("$dir/sess_*") as $f) {
++ unlink($f);
++}
++ at rmdir($dir);
++?>
++--EXPECTF--
++X-Powered-By: PHP/%s
++Content-type: text/html; charset=UTF-8
++
++bool(false)
++bool(true)
++--- record after the update:
++bool(true)
++string(20) "upload_progress_key|"
++--- session_start():
++autoload(Missing)
++W::__wakeup
++W::__wakeup
++string(17) "rfc1867nousercode"
++int(1)
++int(2)
++bool(true)
++object(__PHP_Incomplete_Class)#%d (2) {
++ ["__PHP_Incomplete_Class_Name"]=>
++ string(7) "Missing"
++ ["x"]=>
++ int(3)
++}
++bool(true)
+diff --git a/ext/session/tests/rfc1867_sid_invalid.phpt b/ext/session/tests/rfc1867_sid_invalid.phpt
+index 881b1d5b..77211b65 100644
+--- a/ext/session/tests/rfc1867_sid_invalid.phpt
++++ b/ext/session/tests/rfc1867_sid_invalid.phpt
+@@ -44,7 +44,7 @@ session_start();
+ var_dump(session_id());
+ var_dump(basename(__FILE__) == $_POST[ini_get("session.upload_progress.name")]);
+ var_dump($_FILES);
+-var_dump($_SESSION["upload_progress_" . basename(__FILE__)]);
++var_dump($_SESSION);
+ session_destroy();
+ ?>
+ --EXPECTF--
+@@ -52,13 +52,9 @@ Warning: Unknown: The session id is too long or contains illegal characters, val
+
+ Warning: Unknown: Failed to read session data: files (path: ) in Unknown on line 0
+
+-Warning: Unknown: Failed to write session data (files). Please verify that the current setting of session.save_path is correct () in Unknown on line 0
+-
+ Warning: Unknown: The session id is too long or contains illegal characters, valid characters are a-z, A-Z, 0-9 and '-,' in Unknown on line 0
+
+ Warning: Unknown: Failed to read session data: files (path: ) in Unknown on line 0
+-
+-Warning: Unknown: Failed to write session data (files). Please verify that the current setting of session.save_path is correct () in Unknown on line 0
+ string(%d) ""
+ bool(true)
+ array(2) {
+@@ -89,50 +85,4 @@ array(2) {
+ int(1)
+ }
+ }
+-array(5) {
+- ["start_time"]=>
+- int(%d)
+- ["content_length"]=>
+- int(469)
+- ["bytes_processed"]=>
+- int(469)
+- ["done"]=>
+- bool(true)
+- ["files"]=>
+- array(2) {
+- [0]=>
+- array(7) {
+- ["field_name"]=>
+- string(5) "file1"
+- ["name"]=>
+- string(9) "file1.txt"
+- ["tmp_name"]=>
+- string(%d) "%s"
+- ["error"]=>
+- int(0)
+- ["done"]=>
+- bool(true)
+- ["start_time"]=>
+- int(%d)
+- ["bytes_processed"]=>
+- int(1)
+- }
+- [1]=>
+- array(7) {
+- ["field_name"]=>
+- string(5) "file2"
+- ["name"]=>
+- string(9) "file2.txt"
+- ["tmp_name"]=>
+- string(%d) "%s"
+- ["error"]=>
+- int(0)
+- ["done"]=>
+- bool(true)
+- ["start_time"]=>
+- int(%d)
+- ["bytes_processed"]=>
+- int(1)
+- }
+- }
+-}
++NULL
+diff --git a/ext/standard/php_var.h b/ext/standard/php_var.h
+index 1342ae25..e8802972 100644
+--- a/ext/standard/php_var.h
++++ b/ext/standard/php_var.h
+@@ -51,6 +51,7 @@ PHPAPI php_unserialize_data_t php_var_unserialize_init(void);
+ PHPAPI void php_var_unserialize_destroy(php_unserialize_data_t d);
+ PHPAPI HashTable *php_var_unserialize_get_allowed_classes(php_unserialize_data_t d);
+ PHPAPI void php_var_unserialize_set_allowed_classes(php_unserialize_data_t d, HashTable *classes);
++PHPAPI void php_var_unserialize_set_incomplete_classes_only(php_unserialize_data_t d, zend_bool incomplete_classes_only);
+ PHPAPI void php_var_unserialize_set_max_depth(php_unserialize_data_t d, zend_long max_depth);
+ PHPAPI zend_long php_var_unserialize_get_max_depth(php_unserialize_data_t d);
+ PHPAPI void php_var_unserialize_set_cur_depth(php_unserialize_data_t d, zend_long cur_depth);
+diff --git a/ext/standard/var_unserializer.re b/ext/standard/var_unserializer.re
+index a444cdef..b96bed4c 100644
+--- a/ext/standard/var_unserializer.re
++++ b/ext/standard/var_unserializer.re
+@@ -51,6 +51,11 @@ struct php_unserialize_data {
+ HashTable *ref_props;
+ zend_long cur_depth;
+ zend_long max_depth;
++ /* Represent every object as __PHP_Incomplete_Class, without resolving its
++ * class: no autoloading, no unserialize_callback_func, no __wakeup() or
++ * __unserialize(). O: values serialize back to the same bytes, C: values
++ * are refused. */
++ zend_bool incomplete_classes_only;
+ var_entries entries;
+ };
+
+@@ -65,6 +70,7 @@ PHPAPI php_unserialize_data_t php_var_unserialize_init() {
+ d->ref_props = NULL;
+ d->cur_depth = 0;
+ d->max_depth = BG(unserialize_max_depth);
++ d->incomplete_classes_only = 0;
+ d->entries.used_slots = 0;
+ d->entries.next = NULL;
+ if (!BG(serialize_lock)) {
+@@ -95,6 +101,9 @@ PHPAPI HashTable *php_var_unserialize_get_allowed_classes(php_unserialize_data_t
+ PHPAPI void php_var_unserialize_set_allowed_classes(php_unserialize_data_t d, HashTable *classes) {
+ d->allowed_classes = classes;
+ }
++PHPAPI void php_var_unserialize_set_incomplete_classes_only(php_unserialize_data_t d, zend_bool incomplete_classes_only) {
++ d->incomplete_classes_only = incomplete_classes_only;
++}
+
+ PHPAPI void php_var_unserialize_set_max_depth(php_unserialize_data_t d, zend_long max_depth) {
+ d->max_depth = max_depth;
+@@ -510,6 +519,15 @@ numeric_key:
+ goto failure;
+ }
+ } else {
++ /* incomplete_classes_only mode stores the class name of every object
++ * under this name; data that carries it already cannot be told apart */
++ if (UNEXPECTED(var_hash && (*var_hash)->incomplete_classes_only)
++ && Z_TYPE(key) == IS_STRING
++ && UNEXPECTED(zend_string_equals_literal(Z_STR(key), MAGIC_MEMBER))) {
++ zval_ptr_dtor(&key);
++ goto failure;
++ }
++
+ if (EXPECTED(Z_TYPE(key) == IS_STRING)) {
+ string_key:
+ if (obj && zend_hash_num_elements(&obj->ce->properties_info) > 0) {
+@@ -1070,6 +1088,13 @@ object ":" uiv ":" ["] {
+ class_name = zend_string_init(str, len, 0);
+
+ do {
++ if ((*var_hash)->incomplete_classes_only) {
++ /* Do not look the class up at all, so that no user code can run. */
++ incomplete_class = 1;
++ ce = PHP_IC_ENTRY;
++ break;
++ }
++
+ if(!unserialize_allowed_class(class_name, var_hash)) {
+ incomplete_class = 1;
+ ce = PHP_IC_ENTRY;
+@@ -1149,6 +1174,14 @@ object ":" uiv ":" ["] {
+ if (custom_object) {
+ int ret;
+
++ if ((*var_hash)->incomplete_classes_only) {
++ /* Values that Serializable::unserialize() would decode from the payload
++ * share the reference numbering of the enclosing data: without the
++ * class, the back-references after it cannot be resolved */
++ zend_string_release_ex(class_name, 0);
++ return 0;
++ }
++
+ ret = object_custom(UNSERIALIZE_PASSTHRU, ce);
+
+ if (ret && incomplete_class) {
diff --git a/php-gh18953.patch b/php-gh18953.patch
new file mode 100644
index 0000000..0fc0b2a
--- /dev/null
+++ b/php-gh18953.patch
@@ -0,0 +1,117 @@
+From 32344c4dc463affe588496976eadf55ba6658178 Mon Sep 17 00:00:00 2001
+From: Niels Dossche <7771979+nielsdos at users.noreply.github.com>
+Date: Fri, 27 Jun 2025 11:05:33 +0800
+Subject: [PATCH] Fix stream double free in phar
+
+Converting a phar with an entry added in the same request (compress(),
+convertToData()) leaves the new entry sharing the source entry's stream;
+both free it at shutdown (use after free, double free).
+
+Test: SKIPIF instead of --EXTENSIONS--.
+
+Origin: upstream, https://github.com/php/php-src/commit/32344c4dc463affe588496976eadf55ba6658178
+Bug: https://github.com/php/php-src/issues/18953
+---
+diff --git a/ext/phar/phar_object.c b/ext/phar/phar_object.c
+index b0de6e4f..8f893953 100644
+--- a/ext/phar/phar_object.c
++++ b/ext/phar/phar_object.c
+@@ -1964,7 +1964,8 @@ static int phar_copy_file_contents(phar_entry_info *entry, php_stream *fp) /* {{
+ {
+ char *error;
+ zend_off_t offset;
+- phar_entry_info *link;
++
++ ZEND_ASSERT(!entry->link);
+
+ if (FAILURE == phar_open_entry_fp(entry, &error, 1)) {
+ if (error) {
+@@ -1979,26 +1980,14 @@ static int phar_copy_file_contents(phar_entry_info *entry, php_stream *fp) /* {{
+ }
+
+ /* copy old contents in entirety */
+- phar_seek_efp(entry, 0, SEEK_SET, 0, 1);
++ phar_seek_efp(entry, 0, SEEK_SET, 0, 0);
+ offset = php_stream_tell(fp);
+- link = phar_get_link_source(entry);
+-
+- if (!link) {
+- link = entry;
+- }
+-
+- if (SUCCESS != php_stream_copy_to_stream_ex(phar_get_efp(link, 0), fp, link->uncompressed_filesize, NULL)) {
++ if (SUCCESS != php_stream_copy_to_stream_ex(phar_get_efp(entry, 0), fp, entry->uncompressed_filesize, NULL)) {
+ zend_throw_exception_ex(spl_ce_UnexpectedValueException, 0,
+ "Cannot convert phar archive \"%s\", unable to copy entry \"%s\" contents", entry->phar->fname, entry->filename);
+ return FAILURE;
+ }
+
+- if (entry->fp_type == PHAR_MOD) {
+- /* save for potential restore on error */
+- entry->cfp = entry->fp;
+- entry->fp = NULL;
+- }
+-
+ /* set new location of file contents */
+ entry->fp_type = PHAR_FP;
+ entry->offset = offset;
+@@ -2330,6 +2319,10 @@ static zend_object *phar_convert_to_other(phar_archive_data *source, int convert
+ return NULL;
+ }
+ no_copy:
++ /* Reset file pointers, they have to be reset here such that if a copy happens the original
++ * source fp can be accessed. */
++ newentry.fp = NULL;
++ newentry.cfp = NULL;
+ newentry.filename = estrndup(newentry.filename, newentry.filename_len);
+
+ if (Z_TYPE(newentry.metadata) != IS_UNDEF) {
+diff --git a/ext/phar/tests/gh18953.phpt b/ext/phar/tests/gh18953.phpt
+new file mode 100644
+index 00000000..9df0b789
+--- /dev/null
++++ b/ext/phar/tests/gh18953.phpt
+@@ -0,0 +1,43 @@
++--TEST--
++GH-18953 (Phar: Stream double free)
++--SKIPIF--
++<?php
++if (!extension_loaded("phar")) die("skip phar extension not loaded");
++if (!extension_loaded("zlib")) die("skip zlib extension not loaded");
++?>
++--INI--
++phar.readonly=0
++--FILE--
++<?php
++
++$phar = new Phar(__DIR__ . "/gh18953.phar");
++$phar->addFromString("file", str_repeat("123", random_int(1, 1)));
++$phar->addEmptyDir("dir");
++$phar2 = $phar->compress(Phar::GZ);
++
++var_dump($phar["dir"]);
++var_dump($phar2["dir"]);
++var_dump($phar["file"]->openFile()->fread(100));
++var_dump($phar2["file"]->openFile()->fread(100));
++
++?>
++--CLEAN--
++<?php
++ at unlink(__DIR__ . "/gh18953.phar");
++ at unlink(__DIR__ . "/gh18953.phar.gz");
++?>
++--EXPECTF--
++object(PharFileInfo)#%d (2) {
++ ["pathName":"SplFileInfo":private]=>
++ string(%d) "%sphar%sdir"
++ ["fileName":"SplFileInfo":private]=>
++ string(3) "dir"
++}
++object(PharFileInfo)#%d (2) {
++ ["pathName":"SplFileInfo":private]=>
++ string(%d) "%sphar.gz%sdir"
++ ["fileName":"SplFileInfo":private]=>
++ string(3) "dir"
++}
++string(3) "123"
++string(3) "123"
================================================================
---- gitweb:
http://git.pld-linux.org/gitweb.cgi/packages/php.git/commitdiff/d2e02a402afd4d7d4112166f0b07619ba9c9bcd2
More information about the pld-cvs-commit
mailing list