From 2ae6b1ab7d8a93c05347379dac573deaca278004 Mon Sep 17 00:00:00 2001 From: Andrey Andreev Date: Tue, 13 Oct 2026 21:00:36 +0300 Subject: [PATCH 1/5] GH-24203 follow-up: Stricter bcrypt hash format validation --- ext/standard/password.c | 51 ++++++++++--------- .../tests/password/password_get_info.phpt | 17 +++++++ .../tests/password/password_needs_rehash.phpt | 6 +++ 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/ext/standard/password.c b/ext/standard/password.c index 9d6bd6f2003d..d79ed80317e4 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -113,30 +113,40 @@ static zend_string* php_password_get_salt(zval *unused_, size_t required_salt_le /* bcrypt implementation */ -static bool php_password_bcrypt_get_cost(const zend_string *hash, zend_long *cost) { - const char *h = ZSTR_VAL(hash); - - if ((ZSTR_LEN(hash) != 60) || - (h[0] != '$') || (h[1] != '2') || (h[2] != 'y') || (h[3] != '$') || - !ZEND_IS_DIGIT(h[4]) || !ZEND_IS_DIGIT(h[5]) || (h[6] != '$')) { - return false; +static bool php_password_bcrypt_get_cost(zend_array *options, zend_long *cost) { + zval *zcost; + if (options && (zcost = zend_hash_str_find(options, "cost", sizeof("cost")-1)) != NULL) { + *cost = zval_get_long(zcost); } - *cost = (h[4] - '0') * 10 + (h[5] - '0'); - return *cost >= 4 && *cost <= 31; } -static bool php_password_bcrypt_valid(const zend_string *hash) { - zend_long cost; +static bool php_password_bcrypt_parse(const zend_string *hash, zend_long *cost) { + unsigned int c = 0; + int r = 0; + + if (ZSTR_LEN(hash) == 60 && ZSTR_VAL(hash)[5] != '$' && + 1 == sscanf(ZSTR_VAL(hash), "$2y$%2u$%*21[./A-Za-z0-9]%*1[.Oeu]%*31[./A-Za-z0-9]%n", &c, &r) && + r == 60 && c >= 4 && c <= 31 + ) { + if (cost != NULL) { + *cost = (zend_long) c; + } + return true; + } - return php_password_bcrypt_get_cost(hash, &cost); + return false; +} + +static bool php_password_bcrypt_valid(const zend_string *hash) { + return php_password_bcrypt_parse(hash, NULL); } static int php_password_bcrypt_get_info(zval *return_value, const zend_string *hash) { zend_long cost; - if (!php_password_bcrypt_get_cost(hash, &cost)) { + if (!php_password_bcrypt_parse(hash, &cost)) { /* Should never get called this way. */ return FAILURE; } @@ -146,17 +156,15 @@ static int php_password_bcrypt_get_info(zval *return_value, const zend_string *h } static bool php_password_bcrypt_needs_rehash(const zend_string *hash, zend_array *options) { - zval *znew_cost; zend_long old_cost; zend_long new_cost = PHP_PASSWORD_BCRYPT_COST; - if (!php_password_bcrypt_get_cost(hash, &old_cost)) { + if (!php_password_bcrypt_parse(hash, &old_cost)) { /* Should never get called this way. */ return 1; } - if (options && (znew_cost = zend_hash_str_find(options, "cost", sizeof("cost")-1)) != NULL) { - new_cost = zval_get_long(znew_cost); - } + + php_password_bcrypt_get_cost(options, &new_cost); return old_cost != new_cost; } @@ -188,7 +196,6 @@ static zend_string* php_password_bcrypt_hash(const zend_string *password, zend_a char hash_format[10]; size_t hash_format_len; zend_string *result, *hash, *salt; - zval *zcost; zend_long cost = PHP_PASSWORD_BCRYPT_COST; if (memchr(ZSTR_VAL(password), '\0', ZSTR_LEN(password))) { @@ -196,11 +203,7 @@ static zend_string* php_password_bcrypt_hash(const zend_string *password, zend_a return NULL; } - if (options && (zcost = zend_hash_str_find(options, "cost", sizeof("cost")-1)) != NULL) { - cost = zval_get_long(zcost); - } - - if (cost < 4 || cost > 31) { + if (!php_password_bcrypt_get_cost(options, &cost)) { zend_value_error("Invalid bcrypt cost parameter specified: " ZEND_LONG_FMT, cost); return NULL; } diff --git a/ext/standard/tests/password/password_get_info.phpt b/ext/standard/tests/password/password_get_info.phpt index 29dd78fbc1f0..6ca8abd63b4d 100644 --- a/ext/standard/tests/password/password_get_info.phpt +++ b/ext/standard/tests/password/password_get_info.phpt @@ -9,6 +9,7 @@ var_dump(password_get_info('$2y$10$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vj var_dump(password_get_info('$2y$11$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100y')); // Test Bcrypt Invalid Length var_dump(password_get_info('$2y$11$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100')); +var_dump(password_get_info('$2y$11$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj1000y')); // Test Non-Bcrypt var_dump(password_get_info('$1$rasmusle$rISCgZzpwk3UhDidwXvin0')); @@ -26,7 +27,11 @@ $invalidHashes = [ 'below range' => '$2y$03$' . $suffix, 'above range' => '$2y$32$' . $suffix, 'missing separator' => '$2y$' . str_repeat('9', 56), + 'malformed salt segment' => '$2y$04$MTIzNDU2Nzg5MDEyMzQ1NEj0NmcAWSLR.oP7XOR9HD/vjUuOj100y', + 'malformed hash segment' => '$2y$04$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR+oP7XOR9HD/vjUuOj100y', + 'malicious misalignment' => '$2y$4$MTIzNDU2Nzg5MDEyMzQ1Neej0NmcAWSLR+oP7XOR9HD/vjUuOj100y', ]; + foreach ($invalidHashes as $description => $hash) { $info = password_get_info($hash); printf("%s: %s, options %d\n", $description, $info['algoName'], count($info['options'])); @@ -75,6 +80,15 @@ array(3) { array(0) { } } +array(3) { + ["algo"]=> + NULL + ["algoName"]=> + string(7) "unknown" + ["options"]=> + array(0) { + } +} valid 4: bcrypt, cost 4 valid 31: bcrypt, cost 31 non-digit: unknown, options 0 @@ -82,4 +96,7 @@ malformed separator: unknown, options 0 below range: unknown, options 0 above range: unknown, options 0 missing separator: unknown, options 0 +malformed salt segment: unknown, options 0 +malformed hash segment: unknown, options 0 +malicious misalignment: unknown, options 0 OK! diff --git a/ext/standard/tests/password/password_needs_rehash.phpt b/ext/standard/tests/password/password_needs_rehash.phpt index f8390996f234..9db2094bdf0d 100644 --- a/ext/standard/tests/password/password_needs_rehash.phpt +++ b/ext/standard/tests/password/password_needs_rehash.phpt @@ -9,6 +9,10 @@ var_dump(password_needs_rehash('', PASSWORD_BCRYPT)); var_dump(password_needs_rehash('', 1)); var_dump(password_needs_rehash('', '2y')); +// Plausible-looking, but malformed hashes +var_dump(password_needs_rehash('$2y$04$MTIzNDU2Nzg5MDEyMzQ1NEj0NmcAWSLR.oP7XOR9HD/vjUuOj100y', PASSWORD_BCRYPT)); +var_dump(password_needs_rehash('$2y$04$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR+oP7XOR9HD/vjUuOj100y', PASSWORD_BCRYPT)); + // Valid, as it's an unknown algorithm var_dump(password_needs_rehash('', PASSWORD_DEFAULT)); var_dump(password_needs_rehash('', 0)); @@ -63,6 +67,8 @@ bool(true) bool(true) bool(true) bool(true) +bool(true) +bool(true) bool(false) bool(false) bool(true) From e12ea56ccddee689d1bb9b56502ad777df5cf4fb Mon Sep 17 00:00:00 2001 From: Andrey Andreev Date: Thu, 15 Oct 2026 12:16:27 +0300 Subject: [PATCH 2/5] GH-24302 php_password_bcrypt_parse() guard against sscanf() quirks --- ext/standard/password.c | 5 +++-- ext/standard/tests/password/password_get_info.phpt | 8 ++++++++ ext/standard/tests/password/password_needs_rehash.phpt | 8 ++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/ext/standard/password.c b/ext/standard/password.c index d79ed80317e4..97a243b174d4 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -125,9 +125,10 @@ static bool php_password_bcrypt_get_cost(zend_array *options, zend_long *cost) { static bool php_password_bcrypt_parse(const zend_string *hash, zend_long *cost) { unsigned int c = 0; int r = 0; + const char *h = ZSTR_VAL(hash); - if (ZSTR_LEN(hash) == 60 && ZSTR_VAL(hash)[5] != '$' && - 1 == sscanf(ZSTR_VAL(hash), "$2y$%2u$%*21[./A-Za-z0-9]%*1[.Oeu]%*31[./A-Za-z0-9]%n", &c, &r) && + if (ZSTR_LEN(hash) == 60 && ZEND_IS_DIGIT(h[4]) && ZEND_IS_DIGIT(h[5]) && + 1 == sscanf(h, "$2y$%2u$%*21[./A-Za-z0-9]%*1[.Oeu]%*31[./A-Za-z0-9]%n", &c, &r) && r == 60 && c >= 4 && c <= 31 ) { if (cost != NULL) { diff --git a/ext/standard/tests/password/password_get_info.phpt b/ext/standard/tests/password/password_get_info.phpt index 6ca8abd63b4d..e8b153db352b 100644 --- a/ext/standard/tests/password/password_get_info.phpt +++ b/ext/standard/tests/password/password_get_info.phpt @@ -30,6 +30,10 @@ $invalidHashes = [ 'malformed salt segment' => '$2y$04$MTIzNDU2Nzg5MDEyMzQ1NEj0NmcAWSLR.oP7XOR9HD/vjUuOj100y', 'malformed hash segment' => '$2y$04$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR+oP7XOR9HD/vjUuOj100y', 'malicious misalignment' => '$2y$4$MTIzNDU2Nzg5MDEyMzQ1Neej0NmcAWSLR+oP7XOR9HD/vjUuOj100y', + 'minus sign' => '$2y$-4$' . $suffix, + 'plus sign' => '$2y$+4$' . $suffix, + 'newline' => '$2y$'."\n".'4$' . $suffix, + 'space' => '$2y$ 4$' . $suffix, ]; foreach ($invalidHashes as $description => $hash) { @@ -99,4 +103,8 @@ missing separator: unknown, options 0 malformed salt segment: unknown, options 0 malformed hash segment: unknown, options 0 malicious misalignment: unknown, options 0 +minus sign: unknown, options 0 +plus sign: unknown, options 0 +newline: unknown, options 0 +space: unknown, options 0 OK! diff --git a/ext/standard/tests/password/password_needs_rehash.phpt b/ext/standard/tests/password/password_needs_rehash.phpt index 9db2094bdf0d..111822bca041 100644 --- a/ext/standard/tests/password/password_needs_rehash.phpt +++ b/ext/standard/tests/password/password_needs_rehash.phpt @@ -46,6 +46,10 @@ $costCases = [ 'malformed separator' => ['$2y$10x' . $suffix, 10], 'below range' => ['$2y$03$' . $suffix, 3], 'above range' => ['$2y$32$' . $suffix, 32], + 'minus sign' => ['$2y$-4$' . $suffix, 4], + 'plus sign' => ['$2y$+4$' . $suffix, 4], + 'newline' => ['$2y$'."\n".'4$' . $suffix, 4], + 'space' => ['$2y$ 4$' . $suffix, 4], ]; foreach ($costCases as $description => $case) { echo $description, ': '; @@ -81,6 +85,10 @@ non-digit: bool(true) malformed separator: bool(true) below range: bool(true) above range: bool(true) +minus sign: bool(true) +plus sign: bool(true) +newline: bool(true) +space: bool(true) bool(true) bool(true) OK! From 18dd432bb1b189b35d0f9955aa71d3f151d51a7b Mon Sep 17 00:00:00 2001 From: Andrey Andreev Date: Tue, 3 Nov 2026 18:37:01 +0200 Subject: [PATCH 3/5] GH-24302 Revert away from sscanf() usage --- ext/standard/password.c | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/ext/standard/password.c b/ext/standard/password.c index 97a243b174d4..41c2a2a19ac4 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -123,21 +123,32 @@ static bool php_password_bcrypt_get_cost(zend_array *options, zend_long *cost) { } static bool php_password_bcrypt_parse(const zend_string *hash, zend_long *cost) { - unsigned int c = 0; - int r = 0; const char *h = ZSTR_VAL(hash); - if (ZSTR_LEN(hash) == 60 && ZEND_IS_DIGIT(h[4]) && ZEND_IS_DIGIT(h[5]) && - 1 == sscanf(h, "$2y$%2u$%*21[./A-Za-z0-9]%*1[.Oeu]%*31[./A-Za-z0-9]%n", &c, &r) && - r == 60 && c >= 4 && c <= 31 + if (ZSTR_LEN(hash) != 60 || + h[0] != '$' || h[1] != '2' || h[2] != 'y' || h[3] != '$' || + !ZEND_IS_DIGIT(h[4]) || !ZEND_IS_DIGIT(h[5]) || h[6] != '$' || + (h[28] != '.' && h[28] != 'O' && h[28] != 'e' && h[28] != 'u') ) { - if (cost != NULL) { - *cost = (zend_long) c; + return false; + } + + unsigned char c = (h[4] - '0') * 10 + (h[5] - '0'); + if (c < 4 || c > 31) { + return false; + } + + for (size_t i = 7; i < 60; i++) { + if (h[i] < '.' || h[i] > 'z' || (h[i] > '9' && h[i] < 'A') || (h[i] > 'Z' && h[i] < 'a')) { + return false; } - return true; } - return false; + if (cost != NULL) { + *cost = (zend_long) c; + } + + return true; } static bool php_password_bcrypt_valid(const zend_string *hash) { From bd93ce70668a370dde6c1bd6367d4b8a5f16588b Mon Sep 17 00:00:00 2001 From: Andrey Andreev Date: Wed, 4 Nov 2026 13:46:51 +0200 Subject: [PATCH 4/5] Make bcrypt_get_cost() assign a default --- ext/standard/password.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ext/standard/password.c b/ext/standard/password.c index 41c2a2a19ac4..5e5e7303007e 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -117,9 +117,11 @@ static bool php_password_bcrypt_get_cost(zend_array *options, zend_long *cost) { zval *zcost; if (options && (zcost = zend_hash_str_find(options, "cost", sizeof("cost")-1)) != NULL) { *cost = zval_get_long(zcost); + return *cost >= 4 && *cost <= 31; } - return *cost >= 4 && *cost <= 31; + *cost = PHP_PASSWORD_BCRYPT_COST; + return true; } static bool php_password_bcrypt_parse(const zend_string *hash, zend_long *cost) { From dcde5007a551d8fef2a8472bccc16062732e325e Mon Sep 17 00:00:00 2001 From: Andrey Andreev Date: Wed, 4 Nov 2026 21:53:19 +0200 Subject: [PATCH 5/5] Remove redundant cost initialization --- ext/standard/password.c | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ext/standard/password.c b/ext/standard/password.c index 5e5e7303007e..a6fe1126d4e6 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -170,8 +170,7 @@ static int php_password_bcrypt_get_info(zval *return_value, const zend_string *h } static bool php_password_bcrypt_needs_rehash(const zend_string *hash, zend_array *options) { - zend_long old_cost; - zend_long new_cost = PHP_PASSWORD_BCRYPT_COST; + zend_long old_cost, new_cost; if (!php_password_bcrypt_parse(hash, &old_cost)) { /* Should never get called this way. */ @@ -210,7 +209,7 @@ static zend_string* php_password_bcrypt_hash(const zend_string *password, zend_a char hash_format[10]; size_t hash_format_len; zend_string *result, *hash, *salt; - zend_long cost = PHP_PASSWORD_BCRYPT_COST; + zend_long cost; if (memchr(ZSTR_VAL(password), '\0', ZSTR_LEN(password))) { zend_value_error("Bcrypt password must not contain null character");