From 169226847426368c62992af43c2c39b0fdbff4d5 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Fri, 29 May 2026 21:44:14 +0100 Subject: [PATCH 1/4] ext/openssl: openssl_encrypt() zend mm heap overflow on AES-WRAP-PAD mode. Fix #22186 close GH-22187 --- NEWS | 4 ++++ ext/openssl/openssl.c | 17 +++++++++++++++-- ext/openssl/tests/gh22186.phpt | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 ext/openssl/tests/gh22186.phpt diff --git a/NEWS b/NEWS index e0375b21d4fc..3c7f104a24d3 100644 --- a/NEWS +++ b/NEWS @@ -48,6 +48,10 @@ PHP NEWS . Fixed bug GH-20469 (Unsafe inheritance cache replay with reentrant autoloading). (Levi Morrison) +- OpenSSL: + . Fixed bug GH-22187 (Memory corruption (zend_mm_heap corrupted) in + openssl_encrypt with AES-WRAP-PAD). (David Carlier) + - Phar: . Fixed a bypass of the magic ".phar" directory protection in Phar::addEmptyDir() for paths starting with "/.phar", while allowing diff --git a/ext/openssl/openssl.c b/ext/openssl/openssl.c index 6d179cebabda..1689662f07a3 100644 --- a/ext/openssl/openssl.c +++ b/ext/openssl/openssl.c @@ -7932,6 +7932,7 @@ static int php_openssl_cipher_update(const EVP_CIPHER *cipher_type, const char *aad, size_t aad_len, int enc) /* {{{ */ { int i = 0; + size_t outlen = data_len + EVP_CIPHER_block_size(cipher_type); if (mode->is_single_run_aead && !EVP_CipherUpdate(cipher_ctx, NULL, &i, NULL, (int)data_len)) { php_openssl_store_errors(); @@ -7945,7 +7946,19 @@ static int php_openssl_cipher_update(const EVP_CIPHER *cipher_type, return FAILURE; } - *poutbuf = zend_string_alloc((int)data_len + EVP_CIPHER_block_size(cipher_type), 0); +#ifdef EVP_CIPH_WRAP_MODE + if ((EVP_CIPHER_mode(cipher_type)) == EVP_CIPH_WRAP_MODE) { + /* + * RFC 5649 wrap-with-padding rounds the input up to the block size + * and prepends an integrity block, we reserve one extra block. + * See EVP_EncryptUpdate(3): wrap mode may write up to + * inl + cipher_block_size bytes. + */ + outlen += EVP_CIPHER_block_size(cipher_type); + } +#endif + + *poutbuf = zend_string_alloc(outlen, false); if (!EVP_CipherUpdate(cipher_ctx, (unsigned char*)ZSTR_VAL(*poutbuf), &i, (const unsigned char *)data, (int)data_len)) { @@ -7957,7 +7970,7 @@ static int php_openssl_cipher_update(const EVP_CIPHER *cipher_type, } */ php_openssl_store_errors(); - zend_string_release_ex(*poutbuf, 0); + zend_string_release_ex(*poutbuf, false); return FAILURE; } diff --git a/ext/openssl/tests/gh22186.phpt b/ext/openssl/tests/gh22186.phpt new file mode 100644 index 000000000000..8f28e6c45b58 --- /dev/null +++ b/ext/openssl/tests/gh22186.phpt @@ -0,0 +1,32 @@ +--TEST-- +GH-22186 (Heap buffer overflow in openssl_encrypt with AES-WRAP-PAD) +--EXTENSIONS-- +openssl +--SKIPIF-- + +--FILE-- + +--EXPECT-- +done From 4f35c154c74f9dc54f7167e5e927179862a81978 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sun, 14 Jun 2026 12:01:11 -0400 Subject: [PATCH 2/4] Fix SplObjectStorage getHash() guard leaking across a bailout The getHash() recursion guard increments a request-persistent counter around the userland getHash() call but decrements it only on the normal return path. A bailout inside an overridden getHash() (out-of-memory, timeout, or any fatal) skips the decrement, and the counter is never reset per request, so on a persistent SAPI every later request on the same worker wrongly throws "Modification of SplObjectStorage during getHash() is prohibited". Reset the counter in the SPL request init so each request starts at zero regardless of how the previous one exited. Closes GH-22308 --- ext/spl/php_spl.c | 1 + ext/spl/spl_observer.c | 5 +++ ext/spl/spl_observer.h | 2 + .../spl_object_storage_gethash_bailout.phpt | 44 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 ext/spl/tests/spl_object_storage_gethash_bailout.phpt diff --git a/ext/spl/php_spl.c b/ext/spl/php_spl.c index 2477b371ea4e..0610e79196f9 100644 --- a/ext/spl/php_spl.c +++ b/ext/spl/php_spl.c @@ -563,6 +563,7 @@ PHP_MINIT_FUNCTION(spl) PHP_RINIT_FUNCTION(spl) /* {{{ */ { spl_autoload_extensions = NULL; + spl_object_storage_reset_get_hash_depth(); return SUCCESS; } /* }}} */ diff --git a/ext/spl/spl_observer.c b/ext/spl/spl_observer.c index f897ab1350cc..613bf5384dcb 100644 --- a/ext/spl/spl_observer.c +++ b/ext/spl/spl_observer.c @@ -47,6 +47,11 @@ static zend_object_handlers spl_handler_MultipleIterator; ZEND_TLS uint32_t spl_object_storage_get_hash_depth; +void spl_object_storage_reset_get_hash_depth(void) +{ + spl_object_storage_get_hash_depth = 0; +} + typedef struct _spl_SplObjectStorage { /* {{{ */ HashTable storage; zend_long index; diff --git a/ext/spl/spl_observer.h b/ext/spl/spl_observer.h index 08d3126d9c8b..bbb3ed656ccb 100644 --- a/ext/spl/spl_observer.h +++ b/ext/spl/spl_observer.h @@ -31,4 +31,6 @@ extern PHPAPI zend_class_entry *spl_ce_MultipleIterator; PHP_MINIT_FUNCTION(spl_observer); +void spl_object_storage_reset_get_hash_depth(void); + #endif /* SPL_OBSERVER_H */ diff --git a/ext/spl/tests/spl_object_storage_gethash_bailout.phpt b/ext/spl/tests/spl_object_storage_gethash_bailout.phpt new file mode 100644 index 000000000000..a6327bd62095 --- /dev/null +++ b/ext/spl/tests/spl_object_storage_gethash_bailout.phpt @@ -0,0 +1,44 @@ +--TEST-- +SplObjectStorage getHash() depth counter is reset after a bailout in a user getHash() +--SKIPIF-- + +--INI-- +allow_url_fopen=1 +--FILE-- +offsetSet(new stdClass()); + echo "poison"; +} else { + $s = new SplObjectStorage(); + $s->offsetSet(new stdClass()); + echo "check-ok count=", count($s); +} +PHP; + +php_cli_server_start($code, 'router.php'); + +$base = 'http://' . PHP_CLI_SERVER_ADDRESS; +// Request 1 bails out (OOM) inside the overridden getHash() mid-offsetSet. +@file_get_contents($base . '/poison'); +// A later request on the same worker must not be poisoned by a stuck counter. +echo @file_get_contents($base . '/check'), "\n"; +echo @file_get_contents($base . '/check'), "\n"; +?> +--EXPECT-- +check-ok count=1 +check-ok count=1 From 0c52780287d92d6e7479aa16e5279026b9ed106d Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sun, 14 Jun 2026 12:09:36 -0400 Subject: [PATCH 3/4] Fix zend_string leak on case-variant duplicate setcookie() options php_head_parse_cookie_options_array() matches option keys case insensitively, but array keys are case sensitive, so a duplicate differing only in case (e.g. "path" and "Path") overwrote the previously fetched path/domain/samesite string without releasing it. Release any value already stored before fetching the next one. Closes GH-22309 --- ext/standard/head.c | 9 +++++++++ .../setcookie_option_case_variant_leak.phpt | 15 +++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 ext/standard/tests/network/setcookie_option_case_variant_leak.phpt diff --git a/ext/standard/head.c b/ext/standard/head.c index ccef4be16bdf..27626e15f2d3 100644 --- a/ext/standard/head.c +++ b/ext/standard/head.c @@ -205,14 +205,23 @@ static zend_result php_head_parse_cookie_options_array(HashTable *options, zend_ if (zend_string_equals_literal_ci(key, "expires")) { *expires = zval_get_long(value); } else if (zend_string_equals_literal_ci(key, "path")) { + if (*path) { + zend_string_release(*path); + } *path = zval_get_string(value); } else if (zend_string_equals_literal_ci(key, "domain")) { + if (*domain) { + zend_string_release(*domain); + } *domain = zval_get_string(value); } else if (zend_string_equals_literal_ci(key, "secure")) { *secure = zval_is_true(value); } else if (zend_string_equals_literal_ci(key, "httponly")) { *httponly = zval_is_true(value); } else if (zend_string_equals_literal_ci(key, "samesite")) { + if (*samesite) { + zend_string_release(*samesite); + } *samesite = zval_get_string(value); } else { zend_value_error("%s(): option \"%s\" is invalid", get_active_function_name(), ZSTR_VAL(key)); diff --git a/ext/standard/tests/network/setcookie_option_case_variant_leak.phpt b/ext/standard/tests/network/setcookie_option_case_variant_leak.phpt new file mode 100644 index 000000000000..4797d2f259c9 --- /dev/null +++ b/ext/standard/tests/network/setcookie_option_case_variant_leak.phpt @@ -0,0 +1,15 @@ +--TEST-- +setcookie() does not leak when an option array has case-variant duplicate keys +--FILE-- + '/aaaaaaaaaaaaaaaa' . $i, 'Path' => '/bbbbbbbbbbbbbbbb' . $i]); + header_remove(); +} +// Each duplicate-key call leaked the first path string before the fix, +// growing usage by tens of bytes per iteration (hundreds of KB here). +var_dump(memory_get_usage() - $base < 50000); +?> +--EXPECT-- +bool(true) From dee1317c3334ca624bab6b03b3e4e0c64e90c0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20D=C3=BCsterhus?= Date: Tue, 16 Jun 2026 00:14:37 +0200 Subject: [PATCH 4/4] zend_ast: Always print encaps list variables with braces when exporting AST (#22293) The `"{$foo}"` variant of interpolating variables into a string is the only one that reliably works all the time. Always use it for simplicity. Fixes php/php-src#22291. --- NEWS | 2 ++ Zend/tests/assert/expect_015.phpt | 8 ++++---- Zend/tests/assert/expect_020.phpt | 2 +- Zend/zend_ast.c | 26 -------------------------- ext/standard/tests/assert/gh22291.phpt | 19 +++++++++++++++++++ 5 files changed, 26 insertions(+), 31 deletions(-) create mode 100644 ext/standard/tests/assert/gh22291.phpt diff --git a/NEWS b/NEWS index efc85058719e..e947b61b9d4c 100644 --- a/NEWS +++ b/NEWS @@ -26,6 +26,8 @@ PHP NEWS (kocsismate) . Fixed bug GH-22292 (AST pretty printing does not correctly handle invalid variable names). (timwolla) + . Fixed bug GH-22291 (AST pretty printing does not correctly handle braces + in string interpolation). (timwolla) - BCMath: . Added NUL-byte validation to BCMath functions. (jorgsowa) diff --git a/Zend/tests/assert/expect_015.phpt b/Zend/tests/assert/expect_015.phpt index 79ea3703ead6..3bb08504f581 100644 --- a/Zend/tests/assert/expect_015.phpt +++ b/Zend/tests/assert/expect_015.phpt @@ -201,7 +201,7 @@ assert(0 && ($a = function &(array &$a, ?X $b = null) use($c, &$d): ?X { $s[$i] = $a[$j]; } foreach ($a as $key => &$val) { - print "$key => $val\n"; + print "{$key} => {$val}\n"; } while ($s[$i]) { $i++; @@ -299,12 +299,12 @@ assert(0 && ($a = function (): ?static { echo 1; } $x = '\'"`$a'; - $x = "'\"`$a"; - $x = `'"\`$a`; + $x = "'\"`{$a}"; + $x = `'"\`{$a}`; $x = "{$a}b"; $x = "{$a}b"; $x = " {$foo->bar} {${$foo->bar}} "; - $x = " ${'---'} "; + $x = " {${'---'}} "; foo(); \foo(); namespace\foo(); diff --git a/Zend/tests/assert/expect_020.phpt b/Zend/tests/assert/expect_020.phpt index d305667a28d7..2146abc96045 100644 --- a/Zend/tests/assert/expect_020.phpt +++ b/Zend/tests/assert/expect_020.phpt @@ -16,5 +16,5 @@ assert(0 && ($a = function () { --EXPECT-- assert(): assert(0 && ($a = function () { $var = 'test'; - $str = "$var, {$var[1]}, {$var}[], {$var[1]}[], {$var}[], {$var[1]}[]"; + $str = "{$var}, {$var[1]}, {$var}[], {$var[1]}[], {$var}[], {$var[1]}[]"; })) failed diff --git a/Zend/zend_ast.c b/Zend/zend_ast.c index 983299c0a9d8..c908f6b18c7f 100644 --- a/Zend/zend_ast.c +++ b/Zend/zend_ast.c @@ -1675,19 +1675,6 @@ static ZEND_COLD void zend_ast_export_ns_name(smart_str *str, zend_ast *ast, int zend_ast_export_ex(str, ast, priority, indent); } -static ZEND_COLD bool zend_ast_valid_var_char(char ch) -{ - unsigned char c = (unsigned char)ch; - - if (c != '_' && c < 127 && - (c < '0' || c > '9') && - (c < 'A' || c > 'Z') && - (c < 'a' || c > 'z')) { - return false; - } - return true; -} - static ZEND_COLD bool zend_ast_valid_var_name(const char *s, size_t len) { unsigned char c; @@ -1714,11 +1701,6 @@ static ZEND_COLD bool zend_ast_valid_var_name(const char *s, size_t len) return true; } -static ZEND_COLD bool zend_ast_var_needs_braces(char ch) -{ - return ch == '[' || zend_ast_valid_var_char(ch); -} - static ZEND_COLD void zend_ast_export_var(smart_str *str, zend_ast *ast, int indent) { if (ast->kind == ZEND_AST_ZVAL) { @@ -1775,14 +1757,6 @@ static ZEND_COLD void zend_ast_export_encaps_list(smart_str *str, char quote, co ZEND_ASSERT(Z_TYPE_P(zv) == IS_STRING); zend_ast_export_qstr(str, quote, Z_STR_P(zv)); - } else if (ast->kind == ZEND_AST_VAR && - ast->child[0]->kind == ZEND_AST_ZVAL && - (i + 1 == list->children || - list->child[i + 1]->kind != ZEND_AST_ZVAL || - !zend_ast_var_needs_braces( - *Z_STRVAL_P( - zend_ast_get_zval(list->child[i + 1]))))) { - zend_ast_export_ex(str, ast, 0, indent); } else { smart_str_appendc(str, '{'); zend_ast_export_ex(str, ast, 0, indent); diff --git a/ext/standard/tests/assert/gh22291.phpt b/ext/standard/tests/assert/gh22291.phpt new file mode 100644 index 000000000000..a50bd5861f05 --- /dev/null +++ b/ext/standard/tests/assert/gh22291.phpt @@ -0,0 +1,19 @@ +--TEST-- +GH-22291: AST pretty printing does not correctly handle braces in string interpolation +--FILE-- +getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +string(5) "{abc}" +string(3) "abc" +assert(!"{{$foo}}")