From 30ed81314b119fb0d1e0112ec95eee3d29f143ba Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+ndossche@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:45:43 +0100 Subject: [PATCH 1/2] Fix GH-18139: Memory leak when overriding some settings via readline_info() The reason why freeing was not done yet is because the pointer in these variables may be: - Static data set by the readline/libedit library initially, not heap data. - Data set by another thread. Although the libraries appear to be not thread-safe anyway. To solve this, introduce some TLS variables to hold a pointer for us when we override the settings, such that we can free them and are certain they are allocated by us. Closes GH-20794. --- NEWS | 4 ++- ext/readline/readline.c | 44 +++++++++++++++++---------------- ext/readline/tests/gh18139.phpt | 18 ++++++++++++++ 3 files changed, 44 insertions(+), 22 deletions(-) create mode 100644 ext/readline/tests/gh18139.phpt diff --git a/NEWS b/NEWS index 16c8d51cba9b..c0bc33dcae16 100644 --- a/NEWS +++ b/NEWS @@ -2,7 +2,9 @@ PHP NEWS ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| ?? ??? ????, PHP 8.4.18 - +- Readline: + . Fixed bug GH-18139 (Memory leak when overriding some settings + via readline_info()). (ndossche) 15 Jan 2026, PHP 8.4.17 diff --git a/ext/readline/readline.c b/ext/readline/readline.c index d29292185690..838c74da86ce 100644 --- a/ext/readline/readline.c +++ b/ext/readline/readline.c @@ -47,6 +47,11 @@ static zval _prepped_callback; static zval _readline_completion; static zval _readline_array; +ZEND_TLS char *php_readline_custom_readline_name = NULL; +#if defined(PHP_WIN32) || defined(HAVE_LIBEDIT) +ZEND_TLS char *php_readline_custom_line_buffer = NULL; +#endif + PHP_MINIT_FUNCTION(readline); PHP_MSHUTDOWN_FUNCTION(readline); PHP_RSHUTDOWN_FUNCTION(readline); @@ -146,7 +151,6 @@ PHP_FUNCTION(readline_info) zend_string *what = NULL; zval *value = NULL; size_t oldval; - char *oldstr; if (zend_parse_parameters(ZEND_NUM_ARGS(), "|S!z!", &what, &value) == FAILURE) { RETURN_THROWS(); @@ -181,35 +185,29 @@ PHP_FUNCTION(readline_info) add_assoc_long(return_value,"attempted_completion_over",rl_attempted_completion_over); } else { if (zend_string_equals_literal_ci(what,"line_buffer")) { - oldstr = strdup(rl_line_buffer ? rl_line_buffer : ""); + RETVAL_STRING(SAFE_STRING(rl_line_buffer)); if (value) { if (!try_convert_to_string(value)) { RETURN_THROWS(); } + /* XXX: These stores would need to be atomic ideally or use a memory barrier */ #if !defined(PHP_WIN32) && !defined(HAVE_LIBEDIT) - if (!rl_line_buffer) { - rl_line_buffer = malloc(Z_STRLEN_P(value) + 1); - } else if (strlen(oldstr) < Z_STRLEN_P(value)) { - rl_extend_line_buffer(Z_STRLEN_P(value) + 1); - free(oldstr); - oldstr = strdup(rl_line_buffer ? rl_line_buffer : ""); + rl_extend_line_buffer(Z_STRLEN_P(value) + 1); + if (EXPECTED(rl_line_buffer)) { + memcpy(rl_line_buffer, Z_STRVAL_P(value), Z_STRLEN_P(value) + 1); } - memcpy(rl_line_buffer, Z_STRVAL_P(value), Z_STRLEN_P(value) + 1); #else - char *tmp = strdup(Z_STRVAL_P(value)); - if (tmp) { - if (rl_line_buffer) { - free(rl_line_buffer); - } - rl_line_buffer = tmp; + char *copy = strdup(Z_STRVAL_P(value)); + rl_line_buffer = copy; + if (php_readline_custom_line_buffer) { + free(php_readline_custom_line_buffer); } + php_readline_custom_line_buffer = copy; #endif #if !defined(PHP_WIN32) rl_end = Z_STRLEN_P(value); #endif } - RETVAL_STRING(SAFE_STRING(oldstr)); - free(oldstr); } else if (zend_string_equals_literal_ci(what, "point")) { RETVAL_LONG(rl_point); #ifndef PHP_WIN32 @@ -268,15 +266,19 @@ PHP_FUNCTION(readline_info) RETVAL_STRING((char *)SAFE_STRING(rl_library_version)); #endif } else if (zend_string_equals_literal_ci(what, "readline_name")) { - oldstr = (char*)rl_readline_name; + RETVAL_STRING(SAFE_STRING(rl_readline_name)); if (value) { - /* XXX if (rl_readline_name) free(rl_readline_name); */ if (!try_convert_to_string(value)) { RETURN_THROWS(); } - rl_readline_name = strdup(Z_STRVAL_P(value)); + char *copy = strdup(Z_STRVAL_P(value)); + /* XXX: This store would need to be atomic ideally or use a memory barrier */ + rl_readline_name = copy; + if (php_readline_custom_readline_name) { + free(php_readline_custom_readline_name); + } + php_readline_custom_readline_name = copy; } - RETVAL_STRING(SAFE_STRING(oldstr)); } else if (zend_string_equals_literal_ci(what, "attempted_completion_over")) { oldval = rl_attempted_completion_over; if (value) { diff --git a/ext/readline/tests/gh18139.phpt b/ext/readline/tests/gh18139.phpt new file mode 100644 index 000000000000..a2de1f9720c7 --- /dev/null +++ b/ext/readline/tests/gh18139.phpt @@ -0,0 +1,18 @@ +--TEST-- +GH-18139 (Memory leak when overriding some settings via readline_info()) +--EXTENSIONS-- +readline +--FILE-- + +--EXPECTF-- +string(%d) "%S" +string(5) "first" +string(%d) "%S" +string(5) "third" From 3f7bfaf3aecb35ea9af75933b16e9a01371c1cbd Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+ndossche@users.noreply.github.com> Date: Fri, 2 Jan 2026 06:00:47 -0800 Subject: [PATCH 2/2] uri: Fix RFC3986 to_string implementation with ExcludeFragment returning non-terminated strings (#20811) zend_string_truncate() doesn't put a NUL byte. Right now this doesn't matter as this code path is only hittable via the equals() method, but if other extension (or future other code) starts using this code path, then it can be problematic as all user-exposed zend_strings need to end with a NUL byte. --- ext/uri/uri_parser_rfc3986.c | 1 + 1 file changed, 1 insertion(+) diff --git a/ext/uri/uri_parser_rfc3986.c b/ext/uri/uri_parser_rfc3986.c index 29d202589942..6c0bdec4b112 100644 --- a/ext/uri/uri_parser_rfc3986.c +++ b/ext/uri/uri_parser_rfc3986.c @@ -595,6 +595,7 @@ ZEND_ATTRIBUTE_NONNULL static zend_string *php_uri_parser_rfc3986_to_string(void const char *pos = zend_memrchr(ZSTR_VAL(uri_string), '#', ZSTR_LEN(uri_string)); if (pos != NULL) { uri_string = zend_string_truncate(uri_string, (pos - ZSTR_VAL(uri_string)), false); + ZSTR_VAL(uri_string)[ZSTR_LEN(uri_string)] = '\0'; } }