我们想要将服务器从 PHP 7.4 升级到 PHP 8.1,但是当我们尝试此操作时,所有用户在尝试登录时都会收到错误的密码消息。
数据库中的所有哈希密码都是使用此函数生成的:
define("BLOWFISH_PRE", "$2y$05$");
define("BLOWFISH_SUF", "$");
function pwhash($password) {
$allowed_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789./';
$salt = "";
for($i=0; $i<21; $i++) { // 21 is standard salt length
$salt .= $allowed_chars[mt_rand(0,strlen($allowed_chars)-1)];
}
return crypt($password, BLOWFISH_PRE . $salt . BLOWFISH_SUF);
}
下面是用户登录时运行的内容。这在 PHP 7.4 中有效 - 如果用户输入的密码与创建帐户时输入的密码相同,则哈希后的用户输入与数据库中的内容相匹配。但在 PHP 8.1 中,无论传入什么内容,
crypt
函数都会返回字符串 *0
。
$salt = substr($dbValue, 7, 21);
$hashedUserInput = crypt($password, BLOWFISH_PRE . $salt . BLOWFISH_SUF);
echo $hashedUserInput;
要么我需要让它工作,要么我需要使用password_verify。但这也行不通;它总是返回 false。
echo "password_verify returns: " . password_verify($password, $hash);
我怎样才能让其中一个或另一个工作?
事实证明,这并不是 PHP 8.1 或任何特定版本中的更改,而是应用于所有当时支持的分支的安全修复程序。我们可以通过将盐设置为可预测的值并比较多个版本的结果来看到这一点:开始返回“*0”的版本是 8.0.28、8.1.16、8.2.3 和 8.3.0。 查看这些版本的
详细变更日志,我们发现安全错误#81744:Password_verify() 始终返回 true 并带有一些哈希值。这听起来很相关,所以我们继续阅读链接的安全公告,其标题为:
BCrypt 哈希值错误地验证盐是否被$
缩短这很有趣 - 正如 Sammitch 和 gview 在评论中指出的,您生成的是 21 个字符的盐而不是 22 个 - 但因为您添加了
BLOWFISH_SUF
,
您实际上生成了以
$
结尾的 22 个字符的盐。 (请注意,正确的格式实际上不需要后缀。)这就是为什么你必须手动提取盐并重新运行原始哈希 -
crypt
的预期用途是在验证时传入整个哈希,盐和选项将自动提取。
那么,当 PHP 中的旧代码发现这个$
时,它在做什么?
按照安全公告中的源代码链接,我们看到黑客攻击是在一个名为 BF_safe_atoi64
的宏中,它打破了函数
while
中的 BF_decode
循环。这本质上是一个 base64 解码例程,它被赋予目标数量的 output字节,并读取输入,直到它可以足够解码。 搜索该函数的用途,我们发现
BF_decode(data.binary.salt, &setting[7], 16)
。这是有道理的:base64 需要 4 个编码字节来表示 3 个原始字节,因此对于 16 个原始字节,您需要 21⅓ 的 base64 字符,这为我们提供了 22 个可读的 salt 字符。 (这也意味着生成 22 个随机字符
也是错误的;您应该生成 16 个随机字节然后对它们进行编码。) 旧版本的 PHP 在遇到
$
时所做的事情是打破编码循环,无论它们到达什么地方,然后用足够的 0 字节填充
output以获得所需的长度。因此,我们需要创建一个盐,解码后以相同数量的零字节结尾。 每个完整循环消耗 4 个字节的输入来创建 3 个字节的输出,因此经过 5 次循环后,它消耗了 20 个字节,输出 15 个。然后它需要从剩余的两个输入字节中再获取一个输出字节,这里:
BF_safe_atoi64(c1, *sptr++);
BF_safe_atoi64(c2, *sptr++);
*dptr++ = (c1 << 2) | ((c2 & 0x30) >> 4);
if (dptr >= end) break;
但是在旧代码中,当它尝试使用
c2
宏读取这些字节中的第二个 (
BF_safe_atoi64
) 时,它会在使用 c1
执行任何操作之前退出循环。所以事实证明 你的盐的第 21 个字符总是被忽略。 使用的特定 base64 字母表中的 0 为
.
,因此从 2 个字节的输入中获取 0 很容易:
..
如果您查看当前的哈希值,您会发现它们已经有一个 .
盐的末尾 - 这不是分隔符,它是在格式化例程中添加的填充,以给出预期的长度。所以你需要做的就是 将盐的第 21 个字符设置为 .
因为它始终位于字符串中的固定偏移量(7 字节前缀 + 20 字节实际变化的盐),你可以只覆盖存储的哈希值的字节并验证它,而无需提取盐:
// Example generated using your `pwhash` function on an older version of PHP
$hash = '$2y$05$/yNDcx14r6hos5BlB6zDn.deQG1FV34L6x/bZcY4Grn764Lgiy2km';
// Fix the salt to match the old algorithm
$hash[27] = '.';
// Now gives '$2y$05$/yNDcx14r6hos5BlB6zD..deQG1FV34L6x/bZcY4Grn764Lgiy2km'
// Confirm that it verifies
var_dump($hash === crypt('test', $hash));
var_dump(password_verify('test', $hash));
然后,将必须执行此操作的任何哈希视为“需要重新哈希”,并在拥有纯文本时使用
password_hash 生成新的哈希。