Commit 2f7951d6490 for php

commit 2f7951d6490822ad91c13afc35b0c90e69a101a7
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Thu Sep 24 08:57:17 2026 -0400

    ext/standard: Validate the bcrypt cost before reading it

    password_get_info() and password_needs_rehash() identified any 60-byte
    "$2y" hash as bcrypt and read its cost with sscanf(), so they reported
    costs that crypt() rejects, such as 03 or 32, and overflowed zend_long on a
    run of 56 digits. Require exactly two digits in the 04-31 range followed by
    '$'. password_verify() is unaffected, as unidentified hashes still go
    through crypt().

    Closes GH-24203

diff --git a/NEWS b/NEWS
index f2c2a126aa6..64d75dfc03a 100644
--- a/NEWS
+++ b/NEWS
@@ -32,6 +32,8 @@ PHP                                                                        NEWS
 - Standard:
   . Fixed chown() and lchown() failing to resolve user names in ZTS builds
     when getpwnam_r() needs a larger buffer. (Ilia Alshanetsky)
+  . Fixed password_get_info() and password_needs_rehash() accepting malformed
+    bcrypt costs. (Ilia Alshanetsky)

 - Zip:
   . Fixed use-after-free when re-entering ZipArchive during destruction or
diff --git a/ext/standard/password.c b/ext/standard/password.c
index 1e647bb301c..9d6bd6f2003 100644
--- a/ext/standard/password.c
+++ b/ext/standard/password.c
@@ -113,21 +113,33 @@ static zend_string* php_password_get_salt(zval *unused_, size_t required_salt_le

 /* bcrypt implementation */

-static bool php_password_bcrypt_valid(const zend_string *hash) {
+static bool php_password_bcrypt_get_cost(const zend_string *hash, zend_long *cost) {
 	const char *h = ZSTR_VAL(hash);
-	return (ZSTR_LEN(hash) == 60) &&
-		(h[0] == '$') && (h[1] == '2') && (h[2] == 'y');
+
+	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;
+	}
+
+	*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;
+
+	return php_password_bcrypt_get_cost(hash, &cost);
 }

 static int php_password_bcrypt_get_info(zval *return_value, const zend_string *hash) {
-	zend_long cost = PHP_PASSWORD_BCRYPT_COST;
+	zend_long cost;

-	if (!php_password_bcrypt_valid(hash)) {
+	if (!php_password_bcrypt_get_cost(hash, &cost)) {
 		/* Should never get called this way. */
 		return FAILURE;
 	}
-
-	sscanf(ZSTR_VAL(hash), "$2y$" ZEND_LONG_FMT "$", &cost);
 	add_assoc_long(return_value, "cost", cost);

 	return SUCCESS;
@@ -135,15 +147,13 @@ 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 = PHP_PASSWORD_BCRYPT_COST;
+	zend_long old_cost;
 	zend_long new_cost = PHP_PASSWORD_BCRYPT_COST;

-	if (!php_password_bcrypt_valid(hash)) {
+	if (!php_password_bcrypt_get_cost(hash, &old_cost)) {
 		/* Should never get called this way. */
 		return 1;
 	}
-
-	sscanf(ZSTR_VAL(hash), "$2y$" ZEND_LONG_FMT "$", &old_cost);
 	if (options && (znew_cost = zend_hash_str_find(options, "cost", sizeof("cost")-1)) != NULL) {
 		new_cost = zval_get_long(znew_cost);
 	}
diff --git a/ext/standard/tests/password/password_get_info.phpt b/ext/standard/tests/password/password_get_info.phpt
index 22c4ce4c52f..29dd78fbc1f 100644
--- a/ext/standard/tests/password/password_get_info.phpt
+++ b/ext/standard/tests/password/password_get_info.phpt
@@ -12,6 +12,26 @@
 // Test Non-Bcrypt
 var_dump(password_get_info('$1$rasmusle$rISCgZzpwk3UhDidwXvin0'));

+// Valid cost boundaries
+$suffix = 'MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100y';
+foreach ([4, 31] as $cost) {
+	$info = password_get_info('$2y$' . sprintf('%02d', $cost) . '$' . $suffix);
+	printf("valid %d: %s, cost %d\n", $cost, $info['algoName'], $info['options']['cost']);
+}
+
+// Invalid cost grammar and range
+$invalidHashes = [
+	'non-digit' => '$2y$a0$' . $suffix,
+	'malformed separator' => '$2y$10x' . $suffix,
+	'below range' => '$2y$03$' . $suffix,
+	'above range' => '$2y$32$' . $suffix,
+	'missing separator' => '$2y$' . str_repeat('9', 56),
+];
+foreach ($invalidHashes as $description => $hash) {
+	$info = password_get_info($hash);
+	printf("%s: %s, options %d\n", $description, $info['algoName'], count($info['options']));
+}
+
 echo "OK!";
 ?>
 --EXPECT--
@@ -55,4 +75,11 @@
   array(0) {
   }
 }
+valid 4: bcrypt, cost 4
+valid 31: bcrypt, cost 31
+non-digit: unknown, options 0
+malformed separator: unknown, options 0
+below range: unknown, options 0
+above range: unknown, options 0
+missing separator: 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 d88270884e2..f8390996f23 100644
--- a/ext/standard/tests/password/password_needs_rehash.phpt
+++ b/ext/standard/tests/password/password_needs_rehash.phpt
@@ -33,6 +33,21 @@
 // Should Issue Needs Rehash, Since Foo is cast to 0...
 var_dump(password_needs_rehash('$2y$10$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100y', PASSWORD_BCRYPT, array('cost' => 'foo')));

+// Valid cost boundaries
+$suffix = 'MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100y';
+$costCases = [
+	'valid lower boundary' => ['$2y$04$' . $suffix, 4],
+	'valid upper boundary' => ['$2y$31$' . $suffix, 31],
+	'non-digit' => ['$2y$a0$' . $suffix, 12],
+	'malformed separator' => ['$2y$10x' . $suffix, 10],
+	'below range' => ['$2y$03$' . $suffix, 3],
+	'above range' => ['$2y$32$' . $suffix, 32],
+];
+foreach ($costCases as $description => $case) {
+	echo $description, ': ';
+	var_dump(password_needs_rehash($case[0], PASSWORD_BCRYPT, ['cost' => $case[1]]));
+}
+
 // CRYPT_MD5
 var_dump(password_needs_rehash(crypt('Example', '$1$'), PASSWORD_DEFAULT));

@@ -54,6 +69,12 @@
 bool(true)
 bool(false)
 bool(true)
+valid lower boundary: bool(false)
+valid upper boundary: bool(false)
+non-digit: bool(true)
+malformed separator: bool(true)
+below range: bool(true)
+above range: bool(true)
 bool(true)
 bool(true)
 OK!