[packages/php/PHP_5_4] Rel 55. CVE-2026-109618 as soft memory limit (upstream delayed OOM, Patch546) instead of _exit; sess

arekm arekm at pld-linux.org
Thu Oct 22 15:12:05 CEST 2026


commit 8024adcb30049929d469cc4b29b361786b58db33
Author: Arkadiusz Miśkiewicz <arekm at maven.pl>
Date:   Thu Oct 22 15:09:36 2026 +0200

    Rel 55. CVE-2026-109618 as soft memory limit (upstream delayed OOM, Patch546) instead of _exit; session upload progress refuses back-references after kept C: payloads.

 php-cve-2026-109618-delayed-oom.patch | 430 ++++++++++++++++++++++++++++++++++
 php-cve-2026-109618-session.patch     | 390 +++++++++++++++++++++++++++++-
 php-cve-2026-109618.patch             |   1 +
 php.spec                              |   4 +-
 4 files changed, 812 insertions(+), 13 deletions(-)
---
diff --git a/php.spec b/php.spec
index ac02b85..ef71619 100644
--- a/php.spec
+++ b/php.spec
@@ -138,7 +138,7 @@ ERROR: You need to select at least one Apache SAPI to build shared modules.
 %undefine	with_filter
 %endif
 
-%define		rel	54
+%define		rel	55
 %define		orgname	php
 %define		ver_suffix 54
 %define		php_suffix %{!?with_default_php:%{ver_suffix}}
@@ -523,6 +523,7 @@ Patch542:	php-bug77022.patch
 Patch543:	php-bug70979.patch
 Patch544:	php-cve-2026-109618-session.patch
 Patch545:	php-cve-2026-109618-post_max_size.patch
+Patch546:	php-cve-2026-109618-delayed-oom.patch
 URL:		https://www.php.net/
 %{?with_interbase:%{!?with_interbase_inst:BuildRequires:	Firebird-devel >= 1.0.2.908-2}}
 %{?with_pspell:BuildRequires:	aspell-devel >= 2:0.50.0}
@@ -2652,6 +2653,7 @@ cp -p php.ini-production php.ini
 %patch -P543 -p1
 %patch -P544 -p1
 %patch -P545 -p1
+%patch -P546 -p1
 
 # backports patch only the .re sources; date has no make rule and the
 # unserializer one is skipped silently when configure finds no re2c
diff --git a/php-cve-2026-109618-delayed-oom.patch b/php-cve-2026-109618-delayed-oom.patch
new file mode 100644
index 0000000..0405a73
--- /dev/null
+++ b/php-cve-2026-109618-delayed-oom.patch
@@ -0,0 +1,430 @@
+From: Arkadiusz Miśkiewicz <arekm at maven.pl>
+Date: Thu Oct 22 13:37:01 2026 +0200
+Subject: [PATCH] Raise memory limit errors at a safe point instead of exiting
+
+The minimal EOL fix for CVE-2026-109618 replaces the bailout at memory_limit
+with _exit(1): no shutdown functions, no error log, the mod_php or FPM worker
+dies. As upstream does, exceeding memory_limit is now only recorded and the
+allocation continues; the "Allowed memory size" fatal error is raised at the
+next safe point (between opcodes, around zend_call_function(), after the main
+script and after the POST handler). The hard limit (twice memory_limit, or
+memory_limit + 1G above 1G) still exits at once. memory_limit cannot be set
+below the current usage.
+
+5.4: the state is appended to the executor globals. Fallible allocations are
+not backported, so a single allocation above the hard limit still exits. bug45392.phpt
+gets back its upstream expectation, without the empty line of the direct
+stderr report.
+
+Source: 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/Zend/zend.c b/Zend/zend.c
+--- a/Zend/zend.c
++++ b/Zend/zend.c
+@@ -575,6 +575,7 @@ static void executor_globals_ctor(zend_executor_globals *executor_globals TSRMLS
+ #endif
+ 	EG(saved_fpu_cw_ptr) = NULL;
+ 	EG(active) = 0;
++	memset(&EG(delayed_oom), 0, sizeof(EG(delayed_oom)));
+ }
+ /* }}} */
+ 
+@@ -1327,6 +1328,9 @@ ZEND_API int zend_execute_scripts(int type TSRMLS_DC, zval **retval, int file_co
+ 		if (EG(active_op_array)) {
+ 			EG(return_value_ptr_ptr) = retval ? retval : NULL;
+ 			zend_execute(EG(active_op_array) TSRMLS_CC);
++			if (ZEND_HAS_OOM()) {
++				zend_handle_delayed_oom(TSRMLS_C);
++			}
+ 			zend_exception_restore(TSRMLS_C);
+ 			if (EG(exception)) {
+ 				if (EG(user_exception_handler)) {
+diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c
+--- a/Zend/zend_alloc.c
++++ b/Zend/zend_alloc.c
+@@ -1784,6 +1784,59 @@ static void zend_mm_safe_error(zend_mm_heap *heap,
+ 		size);
+ }
+ 
++/* Twice the limit, or the limit plus 1G for limits above 1G, as upstream */
++static size_t zend_mm_hard_limit(size_t limit)
++{
++	size_t offset = (size_t) 1 << 30;
++
++	if (limit <= offset) {
++		return limit * 2;
++	}
++	return limit > (size_t) -1 - offset ? (size_t) -1 : limit + offset;
++}
++
++/* Bailing out of the allocator may leave whatever its caller was updating
++ * half done, and request shutdown would then use it. Let the allocation
++ * succeed and raise the error at the next safe point in
++ * zend_handle_delayed_oom() instead; only the hard limit stops the process
++ * right away. */
++static void zend_mm_limit_exceeded(zend_mm_heap *heap, size_t add_size, size_t size ZEND_FILE_LINE_DC)
++{
++	size_t hard_limit = zend_mm_hard_limit(heap->limit);
++	TSRMLS_FETCH();
++
++	if (add_size > hard_limit || heap->real_size > hard_limit - add_size) {
++		zend_mm_safe_error_ex("Hard memory limit of %lu bytes exhausted (tried to allocate %lu bytes)", (unsigned long) hard_limit, (unsigned long) size);
++	}
++	if (EG(delayed_oom).state == ZEND_OOM_STATE_NONE) {
++		EG(delayed_oom).state = ZEND_OOM_STATE_PENDING;
++		EG(delayed_oom).limit = heap->limit;
++		EG(delayed_oom).size = size;
++#if ZEND_DEBUG
++		EG(delayed_oom).filename = __zend_filename;
++		EG(delayed_oom).lineno = __zend_lineno;
++#endif
++	}
++}
++
++ZEND_API void zend_handle_delayed_oom(TSRMLS_D)
++{
++	zend_oom_info *info = &EG(delayed_oom);
++
++	if (info->state != ZEND_OOM_STATE_PENDING) {
++		return;
++	}
++	/* error reporting itself may now go over the limit, up to the hard one */
++	info->state = ZEND_OOM_STATE_HANDLED;
++#if ZEND_DEBUG
++	zend_error(E_ERROR, "Allowed memory size of %ld bytes exhausted at %s:%d (tried to allocate %lu bytes)", (long) info->limit, info->filename, info->lineno, (unsigned long) info->size);
++#else
++	zend_error(E_ERROR, "Allowed memory size of %ld bytes exhausted (tried to allocate %lu bytes)", (long) info->limit, (unsigned long) info->size);
++#endif
++	/* php_error_cb() bails out only once the modules are initialized */
++	zend_bailout();
++}
++
+ static zend_mm_free_block *zend_mm_search_large_block(zend_mm_heap *heap, size_t true_size)
+ {
+ 	zend_mm_free_block *best_fit;
+@@ -1945,18 +1998,17 @@ static void *_zend_mm_alloc_int(zend_mm_heap *heap, size_t size ZEND_FILE_LINE_D
+ 			segment_size = heap->block_size;
+ 		}
+ 
+-		if (segment_size < true_size ||
+-		    heap->real_size + segment_size > heap->limit) {
++		if (segment_size < true_size) {
++			goto out_of_memory;
++		}
++		if (heap->real_size + segment_size > heap->limit) {
+ 			/* Memory limit overflow */
+ #if ZEND_MM_CACHE
+ 			zend_mm_free_cache(heap);
+ #endif
+-			HANDLE_UNBLOCK_INTERRUPTIONS();
+-#if ZEND_DEBUG
+-			zend_mm_safe_error(heap, "Allowed memory size of %ld bytes exhausted at %s:%d (tried to allocate %lu bytes)", heap->limit, __zend_filename, __zend_lineno, size);
+-#else
+-			zend_mm_safe_error(heap, "Allowed memory size of %ld bytes exhausted (tried to allocate %lu bytes)", heap->limit, size);
+-#endif
++			if (heap->real_size + segment_size > heap->limit) {
++				zend_mm_limit_exceeded(heap, segment_size, size ZEND_FILE_LINE_RELAY_CC);
++			}
+ 		}
+ 
+ 		segment = (zend_mm_segment *) ZEND_MM_STORAGE_ALLOC(segment_size);
+@@ -2256,21 +2308,16 @@ realloc_segment:
+ 		}
+ 
+ 		segment_copy = (zend_mm_segment *) ((char *)mm_block - ZEND_MM_ALIGNED_SEGMENT_SIZE);
+-		if (segment_size < true_size ||
+-		    heap->real_size + segment_size - segment_copy->size > heap->limit) {
++		if (segment_size < true_size) {
+ 			if (ZEND_MM_IS_FREE_BLOCK(next_block)) {
+ 				zend_mm_add_to_free_list(heap, (zend_mm_free_block *) next_block);
+ 			}
+-#if ZEND_MM_CACHE
+-			zend_mm_free_cache(heap);
+-#endif
+-			HANDLE_UNBLOCK_INTERRUPTIONS();
+-#if ZEND_DEBUG
+-			zend_mm_safe_error(heap, "Allowed memory size of %ld bytes exhausted at %s:%d (tried to allocate %ld bytes)", heap->limit, __zend_filename, __zend_lineno, size);
+-#else
+-			zend_mm_safe_error(heap, "Allowed memory size of %ld bytes exhausted (tried to allocate %ld bytes)", heap->limit, size);
+-#endif
+-			return NULL;
++			goto out_of_memory;
++		}
++		if (heap->real_size + segment_size - segment_copy->size > heap->limit) {
++			/* next_block is out of the free list here, so the cache cannot be
++			 * merged back to make room as in _zend_mm_alloc_int() */
++			zend_mm_limit_exceeded(heap, segment_size > segment_copy->size ? segment_size - segment_copy->size : 0, size ZEND_FILE_LINE_RELAY_CC);
+ 		}
+ 
+ 		segment = ZEND_MM_STORAGE_REALLOC(segment_copy, segment_size);
+@@ -2624,9 +2671,23 @@ ZEND_API char *zend_strndup(const char *s, uint length)
+ 
+ ZEND_API int zend_set_memory_limit(size_t memory_limit)
+ {
++	zend_mm_heap *heap;
+ 	TSRMLS_FETCH();
+ 
+-	AG(mm_heap)->limit = (memory_limit >= AG(mm_heap)->block_size) ? memory_limit : AG(mm_heap)->block_size;
++	heap = AG(mm_heap);
++	if (memory_limit < heap->block_size) {
++		memory_limit = heap->block_size;
++	}
++	/* the hard limit is derived from this one and must stay above the memory in use */
++	if (memory_limit < heap->real_size) {
++#if ZEND_MM_CACHE
++		zend_mm_free_cache(heap);
++#endif
++		if (memory_limit < heap->real_size) {
++			return FAILURE;
++		}
++	}
++	heap->limit = memory_limit;
+ 
+ 	return SUCCESS;
+ }
+diff --git a/Zend/zend_alloc.h b/Zend/zend_alloc.h
+--- a/Zend/zend_alloc.h
++++ b/Zend/zend_alloc.h
+@@ -167,6 +167,25 @@ ZEND_API size_t zend_memory_peak_usage(int real_usage TSRMLS_DC);
+ 
+ ZEND_API void zend_mm_safe_error_ex(const char *format, ...);
+ 
++typedef enum _zend_oom_state {
++	ZEND_OOM_STATE_NONE = 0,
++	ZEND_OOM_STATE_PENDING,
++	ZEND_OOM_STATE_HANDLED
++} zend_oom_state;
++
++/* memory_limit exceeded, raised later by zend_handle_delayed_oom() */
++typedef struct _zend_oom_info {
++	zend_oom_state state;
++	size_t limit;
++	size_t size;
++	const char *filename;
++	uint lineno;
++} zend_oom_info;
++
++#define ZEND_HAS_OOM() UNEXPECTED(EG(delayed_oom).state != ZEND_OOM_STATE_NONE)
++
++ZEND_API void zend_handle_delayed_oom(TSRMLS_D);
++
+ END_EXTERN_C()
+ 
+ /* fast cache for zval's */
+diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c
+--- a/Zend/zend_execute_API.c
++++ b/Zend/zend_execute_API.c
+@@ -781,6 +781,10 @@ int zend_call_function(zend_fcall_info *fci, zend_fcall_info_cache *fci_cache TS
+ 		return FAILURE; /* executor is already inactive */
+ 	}
+ 
++	if (ZEND_HAS_OOM()) {
++		zend_handle_delayed_oom(TSRMLS_C);
++	}
++
+ 	if (EG(exception)) {
+ 		return FAILURE; /* we would result in an instable executor otherwise */
+ 	}
+@@ -1032,6 +1036,11 @@ int zend_call_function(zend_fcall_info *fci, zend_fcall_info_cache *fci_cache TS
+ 	EG(This) = current_this;
+ 	EG(current_execute_data) = EX(prev_execute_data);
+ 
++	/* internal functions do not reach the check in the executor loop */
++	if (ZEND_HAS_OOM()) {
++		zend_handle_delayed_oom(TSRMLS_C);
++	}
++
+ 	if (EG(exception)) {
+ 		zend_throw_exception_internal(NULL TSRMLS_CC);
+ 	}
+diff --git a/Zend/zend_globals.h b/Zend/zend_globals.h
+--- a/Zend/zend_globals.h
++++ b/Zend/zend_globals.h
+@@ -263,6 +263,9 @@ struct _zend_executor_globals {
+ #endif
+ 
+ 	void *reserved[ZEND_MAX_RESERVED_RESOURCES];
++
++	/* last, so that the offsets used by extensions do not change */
++	zend_oom_info delayed_oom;
+ };
+ 
+ struct _zend_ini_scanner_globals {
+diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h
+--- a/Zend/zend_vm_execute.h
++++ b/Zend/zend_vm_execute.h
+@@ -406,6 +406,10 @@ zend_vm_enter:
+ 			zend_timeout(0);
+ 		}
+ #endif
++		/* between two opcodes is a safe point for the memory limit error */
++		if (ZEND_HAS_OOM()) {
++			zend_handle_delayed_oom(TSRMLS_C);
++		}
+ 
+ 		if ((ret = OPLINE->handler(execute_data TSRMLS_CC)) > 0) {
+ 			switch (ret) {
+diff --git a/Zend/zend_vm_execute.skl b/Zend/zend_vm_execute.skl
+--- a/Zend/zend_vm_execute.skl
++++ b/Zend/zend_vm_execute.skl
+@@ -69,6 +69,10 @@ zend_vm_enter:
+ 			zend_timeout(0);
+ 		}
+ #endif
++		/* between two opcodes is a safe point for the memory limit error */
++		if (ZEND_HAS_OOM()) {
++			zend_handle_delayed_oom(TSRMLS_C);
++		}
+ 
+ 		{%ZEND_VM_DISPATCH%} {
+ 			{%INTERNAL_EXECUTOR%}
+diff --git a/main/SAPI.c b/main/SAPI.c
+--- a/main/SAPI.c
++++ b/main/SAPI.c
+@@ -193,6 +193,10 @@ SAPI_API void sapi_handle_post(void *arg TSRMLS_DC)
+ 		}
+ 		efree(SG(request_info).content_type_dup);
+ 		SG(request_info).content_type_dup = NULL;
++
++		if (ZEND_HAS_OOM()) {
++			zend_handle_delayed_oom(TSRMLS_C);
++		}
+ 	}
+ }
+ 
+diff --git a/main/main.c b/main/main.c
+--- a/main/main.c
++++ b/main/main.c
+@@ -175,12 +175,23 @@ static PHP_INI_MH(OnSetPrecision)
+  */
+ static PHP_INI_MH(OnChangeMemoryLimit)
+ {
++	long value;
++
+ 	if (new_value) {
+-		PG(memory_limit) = zend_atol(new_value, new_value_length);
++		value = zend_atol(new_value, new_value_length);
+ 	} else {
+-		PG(memory_limit) = 1<<30;		/* effectively, no limit */
++		value = 1<<30;		/* effectively, no limit */
++	}
++	if (zend_set_memory_limit(value) == FAILURE) {
++		/* the request memory is still in use while ini entries are restored,
++		 * php_request_shutdown() sets the limit again once it is released */
++		if (stage != ZEND_INI_STAGE_DEACTIVATE) {
++			zend_error(E_WARNING, "Failed to set memory limit to %ld bytes (Current memory usage is %ld bytes)", value, (long) zend_memory_usage(1 TSRMLS_CC));
++			return FAILURE;
++		}
+ 	}
+-	return zend_set_memory_limit(PG(memory_limit));
++	PG(memory_limit) = value;
++	return SUCCESS;
+ }
+ /* }}} */
+ 
+@@ -713,6 +724,15 @@ PHPAPI void php_verror(const char *docref, const char *params, int type, const c
+ 	char *message;
+ 	int is_function = 0;
+ 
++	if (ZEND_HAS_OOM()) {
++		/* callers do not expect a fatal error to return: raise the pending one */
++		if (type & (E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR)) {
++			zend_handle_delayed_oom(TSRMLS_C);
++		} else {
++			return;
++		}
++	}
++
+ 	/* get error text into buffer and escape for html if necessary */
+ 	buffer_len = vspprintf(&buffer, 0, format, args);
+ 
+@@ -1743,6 +1763,9 @@ void php_request_shutdown(void *dummy)
+ 
+ 	php_deactivate_ticks(TSRMLS_C);
+ 
++	/* a memory limit error is reported again if shutdown code goes over it */
++	EG(delayed_oom).state = ZEND_OOM_STATE_NONE;
++
+ 	/* 1. Call all possible shutdown functions registered with register_shutdown_function() */
+ 	if (PG(modules_activated)) zend_try {
+ 		php_call_shutdown_functions(TSRMLS_C);
+@@ -1831,6 +1854,8 @@ void php_request_shutdown(void *dummy)
+ 	zend_try {
+ 		shutdown_memory_manager(CG(unclean_shutdown) || !report_memleaks, 0 TSRMLS_CC);
+ 	} zend_end_try();
++	EG(delayed_oom).state = ZEND_OOM_STATE_NONE;
++	zend_set_memory_limit(PG(memory_limit));
+ 	zend_interned_strings_restore(TSRMLS_C);
+ 
+ 	/* 12. Reset max_execution_time */
+diff --git a/main/php_variables.c b/main/php_variables.c
+--- a/main/php_variables.c
++++ b/main/php_variables.c
+@@ -162,6 +162,13 @@ PHPAPI void php_register_variable_ex(char *var_name, zval *val, zval *track_vars
+ 				return;
+ 			}
+ 
++			/* Each level retains an array, so stop once over the limit. */
++			if (ZEND_HAS_OOM()) {
++				zval_dtor(val);
++				free_alloca(var_orig, use_heap);
++				return;
++			}
++
+ 			ip++;
+ 			index_s = ip;
+ 			if (isspace(*ip)) {
+@@ -260,7 +267,8 @@ SAPI_API SAPI_POST_HANDLER_FUNC(php_std_post_handler)
+ 	zval *array_ptr = (zval *) arg;
+ 	long count = 0;
+ 
+-	if (SG(request_info).post_data == NULL) {
++	/* reading the body may already have gone over the limit */
++	if (SG(request_info).post_data == NULL || ZEND_HAS_OOM()) {
+ 		return;
+ 	}	
+ 
+@@ -282,10 +290,18 @@ last_value:
+ 			val++;
+ 			val_len = php_url_decode(val, (p - val));
+ 			val = estrndup(val, val_len);
++			/* registering copies the value once more */
++			if (ZEND_HAS_OOM()) {
++				efree(val);
++				return;
++			}
+ 			if (sapi_module.input_filter(PARSE_POST, var, &val, val_len, &new_val_len TSRMLS_CC)) {
+ 				php_register_variable_safe(var, val, new_val_len, array_ptr TSRMLS_CC);
+ 			}
+ 			efree(val);
++			if (ZEND_HAS_OOM()) {
++				return;
++			}
+ 		}
+ 		s = p + 1;
+ 	}
+diff --git a/main/rfc1867.c b/main/rfc1867.c
+--- a/main/rfc1867.c
++++ b/main/rfc1867.c
+@@ -820,6 +820,11 @@ SAPI_API SAPI_POST_HANDLER_FUNC(rfc1867_post_handler) /* {{{ */
+ 
+ 		zend_llist_clean(&header);
+ 
++		if (ZEND_HAS_OOM()) {
++			/* A previous body part has reached the soft memory limit: Stop here */
++			goto fileupload_done;
++		}
++
+ 		if (!multipart_buffer_headers(mbuff, &header TSRMLS_CC)) {
+ 			goto fileupload_done;
+ 		}
+diff --git a/tests/lang/bug45392.phpt b/tests/lang/bug45392.phpt
+--- a/tests/lang/bug45392.phpt
++++ b/tests/lang/bug45392.phpt
+@@ -20,5 +20,4 @@ ob_end_clean();
+ ?>
+ --EXPECTF--
+ 2
+-
+ Fatal error: Allowed memory size of %d bytes exhausted%s
diff --git a/php-cve-2026-109618-session.patch b/php-cve-2026-109618-session.patch
index 3fd3699..518a953 100644
--- a/php-cve-2026-109618-session.patch
+++ b/php-cve-2026-109618-session.patch
@@ -1,4 +1,5 @@
 From: Arnaud Le Blanc <arnaud.lb at gmail.com>
+Date: Thu Oct 22 10:36:33 2026 +0200
 Subject: [PATCH] Run session upload progress without user code instead of disabling it
 
 The minimal EOL fix for CVE-2026-109618 turns session upload progress off.
@@ -20,6 +21,7 @@ __wakeup() while decoding.
 
 Source: backport, https://github.com/php/php-src/commit/3584a98bda0f
 Bug: https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
+PLD: a back-reference after a C: payload kept verbatim fails the decode, since the slots its unserialize() would register (ArrayObject, SplObjectStorage) are not counted; the handler leaves such a session alone
 ---
 diff --git a/ext/session/php_session.h b/ext/session/php_session.h
 --- a/ext/session/php_session.h
@@ -206,6 +208,179 @@ diff --git a/ext/session/session.c b/ext/session/session.c
  		}
  		break;
  	}
+diff --git a/ext/session/tests/rfc1867_custom_backref.phpt b/ext/session/tests/rfc1867_custom_backref.phpt
+new file mode 100644
+--- /dev/null
++++ b/ext/session/tests/rfc1867_custom_backref.phpt
+@@ -0,0 +1,168 @@
++--TEST--
++session rfc1867: a back-reference after a Serializable payload stops the upload progress update without touching the session
++--SKIPIF--
++<?php
++include('skipif.inc');
++if (!getenv('TEST_PHP_CGI_EXECUTABLE')) {
++    die('skip CGI not available');
++}
++?>
++--FILE--
++<?php
++
++$dir = __DIR__ . '/rfc1867_custom_backref';
++ at mkdir($dir);
++$sid = 'rfc1867custombackref';
++$file = "$dir/sess_$sid";
++$worker = "$dir/worker.php";
++
++/* The C: payload is kept verbatim while the handler decodes the record, so the
++ * slots that Cust::unserialize() registers when the class is loaded are not
++ * counted, and r: after it may point elsewhere. The handler leaves such a
++ * record alone instead of writing back one with shifted references. */
++$record = 'c|C:4:"Cust":1:{a}'
++        . 'o|O:8:"stdClass":1:{s:1:"p";i:1;}'
++        . 'r|r:2;';
++file_put_contents($file, $record);
++
++file_put_contents($worker, <<<'PHP'
++<?php
++$record = getenv('TEST_SESSION_RECORD');
++$file = getenv('TEST_SESSION_FILE');
++
++class Cust implements Serializable {
++    public $x;
++    function serialize() {
++        return $this->x;
++    }
++    function unserialize($d) {
++        $this->x = $d;
++    }
++}
++
++var_dump(array_key_exists('_SESSION', $GLOBALS));
++echo "--- record after the update:\n";
++var_dump(file_get_contents($file) === $record);
++
++echo "--- session_start():\n";
++session_start();
++var_dump($_SESSION['c']->x, $_SESSION['r'] === $_SESSION['o']);
++var_dump(isset($_SESSION['upload_progress_key']));
++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 = array(
++    'error_reporting' => 'E_ALL&~E_DEPRECATED',
++    /* the update runs during request startup; On by default since 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.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 modules have to be named again;
++ * session requires spl. */
++$extension_dir = ini_get('extension_dir');
++$modules = array();
++foreach (array('spl', 'session') as $ext) {
++    foreach (array("$ext.", "php_$ext.") as $prefix) {
++        if ($extension_dir !== false && file_exists("$extension_dir/$prefix" . PHP_SHLIB_SUFFIX)) {
++            $ini['extension_dir'] = $extension_dir;
++            $modules[] = $prefix . PHP_SHLIB_SUFFIX;
++            break;
++        }
++    }
++}
++
++$cmd = escapeshellarg(getenv('TEST_PHP_CGI_EXECUTABLE')) . ' -n -q';
++foreach ($ini as $name => $value) {
++    $cmd .= ' -d ' . escapeshellarg("$name=$value");
++}
++foreach ($modules as $module) {
++    $cmd .= ' -d ' . escapeshellarg("extension=$module");
++}
++$cmd .= ' -f ' . escapeshellarg($worker);
++
++$env = array();
++foreach (array('PATH', 'LD_LIBRARY_PATH', 'TMPDIR') as $name) {
++    if (getenv($name) !== false) {
++        $env[$name] = getenv($name);
++    }
++}
++$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 = array();
++$proc = proc_open(
++    $cmd,
++    array(0 => array('pipe', 'r'), 1 => array('pipe', 'w'), 2 => array('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_custom_backref';
++ 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%A
++%sWarning%s:  Unknown: Trying to destroy uninitialized session in %s on line %s
++%A
++%sWarning%s:  Unknown: Failed to decode session object. Session has been destroyed in %s on line %s
++bool(false)
++--- record after the update:
++bool(true)
++--- session_start():
++string(1) "a"
++bool(true)
++bool(false)
 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
 --- /dev/null
@@ -363,7 +538,7 @@ diff --git a/ext/session/tests/rfc1867_no_user_code.phpt b/ext/session/tests/rfc
 new file mode 100644
 --- /dev/null
 +++ b/ext/session/tests/rfc1867_no_user_code.phpt
-@@ -0,0 +1,204 @@
+@@ -0,0 +1,206 @@
 +--TEST--
 +session rfc1867: the upload progress update resolves no classes, keeps the session data intact and leaves no $_SESSION behind
 +--SKIPIF--
@@ -383,12 +558,14 @@ new file mode 100644
 +$worker = "$dir/worker.php";
 +
 +/* A hand-written record, as session_encode() produces it for the `php` handler:
-+ * an object with __wakeup(), a Serializable payload (C:), an object 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. */
++ * an object with __wakeup(), an object shared through a back-reference (r:), a
++ * Serializable payload (C:), and a class that does not exist. The reference
++ * comes first: after a kept C: payload the handler leaves the session alone
++ * (rfc1867_custom_backref.phpt). 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:8:"stdClass":1:{s:1:"x";s:7:"payload";}i:1;r:4;}'
 +        . 'c|C:4:"Cust":7:{payload}'
-+        . 'r|a:2:{i:0;C:4:"Cust":7:{payload}i:1;r:5;}'
 +        . 'm|O:7:"Missing":1:{s:1:"x";i:3;}';
 +file_put_contents($file, $record);
 +
@@ -568,29 +745,193 @@ new file mode 100644
 +  int(3)
 +}
 +bool(true)
+diff --git a/ext/session/tests/rfc1867_serializer_wddx.phpt b/ext/session/tests/rfc1867_serializer_wddx.phpt
+new file mode 100644
+--- /dev/null
++++ b/ext/session/tests/rfc1867_serializer_wddx.phpt
+@@ -0,0 +1,157 @@
++--TEST--
++session rfc1867: with the wddx serializer, which may call __wakeup(), the upload progress update leaves the session alone
++--SKIPIF--
++<?php
++include('skipif.inc');
++/* wddx registers its session serializer only when built with a static session */
++if (@ini_set('session.serialize_handler', 'wddx') === false) {
++    die('skip wddx session serializer not available');
++}
++if (!getenv('TEST_PHP_CGI_EXECUTABLE')) {
++    die('skip CGI not available');
++}
++?>
++--FILE--
++<?php
++
++$dir = __DIR__ . '/rfc1867_serializer_wddx';
++ at mkdir($dir);
++$sid = 'rfc1867serializerwddx';
++$file = "$dir/sess_$sid";
++$worker = "$dir/worker.php";
++
++/* Only the serializers of the session extension decode without resolving
++ * classes before RINIT; wddx looks classes up and calls __wakeup(). */
++$record = "<wddxPacket version='1.0'><header/><data><struct><var name='o'><struct>"
++        . "<var name='php_class_name'><string>stdClass</string></var>"
++        . "<var name='p'><number>1</number></var></struct></var></struct></data></wddxPacket>";
++file_put_contents($file, $record);
++
++file_put_contents($worker, <<<'PHP'
++<?php
++$record = getenv('TEST_SESSION_RECORD');
++$file = getenv('TEST_SESSION_FILE');
++
++var_dump(array_key_exists('_SESSION', $GLOBALS));
++echo "--- record after the update:\n";
++var_dump(file_get_contents($file) === $record);
++
++echo "--- session_start():\n";
++session_start();
++var_dump($_SESSION['o']->p);
++var_dump(isset($_SESSION['upload_progress_key']));
++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 = array(
++    'error_reporting' => 'E_ALL&~E_DEPRECATED',
++    /* the update runs during request startup; On by default since 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.serialize_handler' => 'wddx',
++    '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 modules have to be named again;
++ * session requires spl. */
++$extension_dir = ini_get('extension_dir');
++$modules = array();
++foreach (array('spl', 'session', 'xml', 'wddx') as $ext) {
++    foreach (array("$ext.", "php_$ext.") as $prefix) {
++        if ($extension_dir !== false && file_exists("$extension_dir/$prefix" . PHP_SHLIB_SUFFIX)) {
++            $ini['extension_dir'] = $extension_dir;
++            $modules[] = $prefix . PHP_SHLIB_SUFFIX;
++            break;
++        }
++    }
++}
++
++$cmd = escapeshellarg(getenv('TEST_PHP_CGI_EXECUTABLE')) . ' -n -q';
++foreach ($ini as $name => $value) {
++    $cmd .= ' -d ' . escapeshellarg("$name=$value");
++}
++foreach ($modules as $module) {
++    $cmd .= ' -d ' . escapeshellarg("extension=$module");
++}
++$cmd .= ' -f ' . escapeshellarg($worker);
++
++$env = array();
++foreach (array('PATH', 'LD_LIBRARY_PATH', 'TMPDIR') as $name) {
++    if (getenv($name) !== false) {
++        $env[$name] = getenv($name);
++    }
++}
++$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 = array();
++$proc = proc_open(
++    $cmd,
++    array(0 => array('pipe', 'r'), 1 => array('pipe', 'w'), 2 => array('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_serializer_wddx';
++ 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%A
++
++bool(false)
++--- record after the update:
++bool(true)
++--- session_start():
++int(1)
++bool(false)
 diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c
 --- a/ext/standard/basic_functions.c
 +++ b/ext/standard/basic_functions.c
-@@ -3452,6 +3452,7 @@ static void basic_globals_ctor(php_basic_globals *basic_globals_p TSRMLS_DC) /*
+@@ -3452,6 +3452,8 @@ static void basic_globals_ctor(php_basic_globals *basic_globals_p TSRMLS_DC) /*
  	BG(user_tick_functions) = NULL;
  	BG(user_filter_map) = NULL;
  	BG(serialize_lock) = 0;
 +	BG(var_incomplete_classes_only) = 0;
++	BG(var_incomplete_custom_seen) = 0;
  
  	memset(&BG(serialize), 0, sizeof(BG(serialize)));
  	memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
-@@ -3688,6 +3689,7 @@ PHP_RINIT_FUNCTION(basic) /* {{{ */
+@@ -3688,6 +3690,8 @@ PHP_RINIT_FUNCTION(basic) /* {{{ */
  	memset(BG(strtok_table), 0, 256);
  
  	BG(serialize_lock) = 0;
 +	BG(var_incomplete_classes_only) = 0;
++	BG(var_incomplete_custom_seen) = 0;
  	memset(&BG(serialize), 0, sizeof(BG(serialize)));
  	memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
  
 diff --git a/ext/standard/basic_functions.h b/ext/standard/basic_functions.h
 --- a/ext/standard/basic_functions.h
 +++ b/ext/standard/basic_functions.h
-@@ -230,6 +230,10 @@ typedef struct _php_basic_globals {
+@@ -230,6 +230,12 @@ typedef struct _php_basic_globals {
  #endif
  
  	int umask;
@@ -598,6 +939,8 @@ diff --git a/ext/standard/basic_functions.h b/ext/standard/basic_functions.h
 +	/* var.c: keep every object as __PHP_Incomplete_Class while unserializing
 +	 * and write C: payloads back verbatim, see php_var_set_incomplete_classes_only() */
 +	zend_bool var_incomplete_classes_only;
++	/* set once such a C: payload is kept, see the R: and r: rules */
++	zend_bool var_incomplete_custom_seen;
  } php_basic_globals;
  
  #ifdef ZTS
@@ -681,13 +1024,14 @@ diff --git a/ext/standard/var.c b/ext/standard/var.c
  				if (ce && ce->serialize != NULL) {
  					/* has custom handler */
  					unsigned char *serialized_data = NULL;
-@@ -910,6 +945,12 @@ PHPAPI void php_var_serialize(smart_str *buf, zval **struc, php_serialize_data_t
+@@ -910,6 +945,13 @@ PHPAPI void php_var_serialize(smart_str *buf, zval **struc, php_serialize_data_t
  }
  /* }}} */
  
 +PHPAPI void php_var_set_incomplete_classes_only(zend_bool incomplete_classes_only TSRMLS_DC) /* {{{ */
 +{
 +	BG(var_incomplete_classes_only) = incomplete_classes_only;
++	BG(var_incomplete_custom_seen) = 0;
 +}
 +/* }}} */
 +
@@ -739,7 +1083,7 @@ diff --git a/ext/standard/var_unserializer.re b/ext/standard/var_unserializer.re
  {
  	long datalen;
  
-@@ -425,7 +444,16 @@ static inline int object_custom(UNSERIALIZE_PARAMETER, zend_class_entry *ce)
+@@ -425,7 +444,18 @@ static inline int object_custom(UNSERIALIZE_PARAMETER, zend_class_entry *ce)
  		return 0;
  	}
  
@@ -753,11 +1097,33 @@ diff --git a/ext/standard/var_unserializer.re b/ext/standard/var_unserializer.re
 +		MAKE_STD_ZVAL(payload);
 +		ZVAL_STRINGL(payload, (char *) *p, datalen, 1);
 +		zend_hash_update(Z_OBJPROP_PP(rval), MAGIC_MEMBER_SERIALIZED, sizeof(MAGIC_MEMBER_SERIALIZED), &payload, sizeof(payload), NULL);
++		/* the class's own unserialize() may register slots for what it nests */
++		BG(var_incomplete_custom_seen) = 1;
 +	} else if (ce->unserialize == NULL) {
  		zend_error(E_WARNING, "Class %s has no unserializer", ce->name);
  		object_init_ex(*rval, ce);
  	} else if (ce->unserialize(rval, ce, (const unsigned char*)*p, datalen, (zend_unserialize_data *)var_hash TSRMLS_CC) != SUCCESS) {
-@@ -782,6 +810,13 @@ object ":" uiv ":" ["]	{
+@@ -536,6 +566,9 @@ PHPAPI int php_var_unserialize(UNSERIALIZE_PARAMETER)
+  	*p = YYCURSOR;
+ 	if (!var_hash) return 0;
+ 
++	/* a C: payload kept verbatim holds slots that were not counted */
++	if (UNEXPECTED(BG(var_incomplete_custom_seen))) return 0;
++
+ 	id = parse_iv(start + 2) - 1;
+ 	if (id == -1 || var_access(var_hash, id, &rval_ref) != SUCCESS) {
+ 		return 0;
+@@ -557,6 +590,9 @@ PHPAPI int php_var_unserialize(UNSERIALIZE_PARAMETER)
+  	*p = YYCURSOR;
+ 	if (!var_hash) return 0;
+ 
++	/* a C: payload kept verbatim holds slots that were not counted */
++	if (UNEXPECTED(BG(var_incomplete_custom_seen))) return 0;
++
+ 	id = parse_iv(start + 2) - 1;
+ 	if (id == -1 || var_access(var_hash, id, &rval_ref) != SUCCESS) {
+ 		return 0;
+@@ -782,6 +818,13 @@ object ":" uiv ":" ["]	{
  	class_name = estrndup(class_name, len);
  
  	do {
@@ -771,7 +1137,7 @@ diff --git a/ext/standard/var_unserializer.re b/ext/standard/var_unserializer.re
  		/* Try to find class directly */
  		BG(serialize_lock)++;
  		if (zend_lookup_class(class_name, len2, &pce TSRMLS_CC) == SUCCESS) {
-@@ -861,7 +896,7 @@ object ":" uiv ":" ["]	{
+@@ -861,7 +904,7 @@ object ":" uiv ":" ["]	{
  	if (custom_object) {
  		int ret;
  
diff --git a/php-cve-2026-109618.patch b/php-cve-2026-109618.patch
index 9161f9c..9f1435c 100644
--- a/php-cve-2026-109618.patch
+++ b/php-cve-2026-109618.patch
@@ -7,6 +7,7 @@ Upstream: https://github.com/php/php-src/commit/d9a582cf50a7710ad1d7b19e8b44a132
 5.4 backport: https://github.com/shivammathur/php-src-backports/commit/6aaef249588e92a6a59de15bfb2f20bf18b31cb2
 PLD: the "|| 1" that switches session upload progress off is replaced by php-cve-2026-109618-session.patch, a port of the full upstream fix
 PLD: bug45392.phpt expects the empty line the direct stderr report of a memory limit error starts with
+PLD: the _exit(1) at memory_limit is replaced by php-cve-2026-109618-delayed-oom.patch, which raises the error at a safe point
 ---
 diff --git a/Zend/zend_API.c b/Zend/zend_API.c
 --- a/Zend/zend_API.c
================================================================

---- gitweb:

http://git.pld-linux.org/gitweb.cgi/packages/php.git/commitdiff/8024adcb30049929d469cc4b29b361786b58db33



More information about the pld-cvs-commit mailing list