From 68be6fe188cb148f98c5909db5ffad65a88572cd Mon Sep 17 00:00:00 2001 From: lacatoire Date: Fri, 24 Apr 2026 11:51:32 +0200 Subject: [PATCH 1/2] Fix GH-21738: undefined behavior in url_decode with non-ASCII bytes The isxdigit() family requires its argument to be representable as unsigned char (0-255) or EOF. Casting a signed char value holding a high-bit byte (e.g. 0x80) to int produces a negative number (-128) which triggers undefined behavior, and on some libc implementations (e.g. NetBSD) can lead to out-of-bounds reads through the internal character classification table. --- NEWS | 2 ++ ext/standard/url.c | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/NEWS b/NEWS index 3144cdf88c59..8ed2b4871629 100644 --- a/NEWS +++ b/NEWS @@ -162,6 +162,8 @@ PHP NEWS . Fix NUL byte truncation in sqlite3 TEXT column handling. (ndossche) - Standard: + . Fixed bug GH-21738 (undefined behavior in url_decode functions when + passing non-ASCII bytes to isxdigit()). (lacatoire) . Fixed bug GH-19926 (reset internal pointer earlier while splicing array while COW violation flag is still set). (alexandre-daubois) . Added form feed (\f) in the default trimmed characters of trim(), rtrim() diff --git a/ext/standard/url.c b/ext/standard/url.c index 089dca315f43..4a35713fb37e 100644 --- a/ext/standard/url.c +++ b/ext/standard/url.c @@ -589,8 +589,8 @@ PHPAPI size_t php_url_decode_ex(char *dest, const char *src, size_t src_len) if (*data == '+') { *dest = ' '; } - else if (*data == '%' && src_len >= 2 && isxdigit((int) *(data + 1)) - && isxdigit((int) *(data + 2))) { + else if (*data == '%' && src_len >= 2 && isxdigit((unsigned char) *(data + 1)) + && isxdigit((unsigned char) *(data + 2))) { *dest = (char) php_htoi(data + 1); data += 2; src_len -= 2; @@ -662,8 +662,8 @@ PHPAPI size_t php_raw_url_decode_ex(char *dest, const char *src, size_t src_len) const char *data = src; while (src_len--) { - if (*data == '%' && src_len >= 2 && isxdigit((int) *(data + 1)) - && isxdigit((int) *(data + 2))) { + if (*data == '%' && src_len >= 2 && isxdigit((unsigned char) *(data + 1)) + && isxdigit((unsigned char) *(data + 2))) { *dest = (char) php_htoi(data + 1); data += 2; src_len -= 2; From 839bbf13b0064f2515e5e2badf67db983c7e3305 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Fri, 24 Apr 2026 15:32:32 +0200 Subject: [PATCH 2/2] Extend isxdigit() UB fix to quoted_printable_decode and stripcslashes Same signed-char UB as in url_decode: isxdigit() is called on byte values from char-pointers (str_in in quoted_printable_decode, source in php_stripcslashes), which sign-extend to negative int on signed-char platforms when the byte has its high bit set. --- NEWS | 5 +++-- ext/standard/quot_print.c | 4 ++-- ext/standard/string.c | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/NEWS b/NEWS index 8ed2b4871629..dbc21db83417 100644 --- a/NEWS +++ b/NEWS @@ -162,8 +162,9 @@ PHP NEWS . Fix NUL byte truncation in sqlite3 TEXT column handling. (ndossche) - Standard: - . Fixed bug GH-21738 (undefined behavior in url_decode functions when - passing non-ASCII bytes to isxdigit()). (lacatoire) + . Fixed bug GH-21738 (undefined behavior when passing non-ASCII bytes to + isxdigit() in url_decode, quoted_printable_decode and stripcslashes). + (lacatoire) . Fixed bug GH-19926 (reset internal pointer earlier while splicing array while COW violation flag is still set). (alexandre-daubois) . Added form feed (\f) in the default trimmed characters of trim(), rtrim() diff --git a/ext/standard/quot_print.c b/ext/standard/quot_print.c index f4954a88f8fa..e82a31b786b8 100644 --- a/ext/standard/quot_print.c +++ b/ext/standard/quot_print.c @@ -210,8 +210,8 @@ PHP_FUNCTION(quoted_printable_decode) switch (str_in[i]) { case '=': if (str_in[i + 1] && str_in[i + 2] && - isxdigit((int) str_in[i + 1]) && - isxdigit((int) str_in[i + 2])) + isxdigit((unsigned char) str_in[i + 1]) && + isxdigit((unsigned char) str_in[i + 2])) { ZSTR_VAL(str_out)[j++] = (php_hex2int((int) str_in[i + 1]) << 4) + php_hex2int((int) str_in[i + 2]); diff --git a/ext/standard/string.c b/ext/standard/string.c index ef9e66ab53f8..21f25435f52a 100644 --- a/ext/standard/string.c +++ b/ext/standard/string.c @@ -3760,9 +3760,9 @@ PHPAPI void php_stripcslashes(zend_string *str) case 'f': *target++='\f'; nlen--; break; case '\\': *target++='\\'; nlen--; break; case 'x': - if (source+1 < end && isxdigit((int)(*(source+1)))) { + if (source+1 < end && isxdigit((unsigned char)(*(source+1)))) { numtmp[0] = *++source; - if (source+1 < end && isxdigit((int)(*(source+1)))) { + if (source+1 < end && isxdigit((unsigned char)(*(source+1)))) { numtmp[1] = *++source; numtmp[2] = '\0'; nlen-=3;