掌握安全函数和设置的变化

PHP 安全特性的任何变化都值得注意。不幸的是,鉴于当今世界的现状,任何面向网络的代码都会受到攻击。因此,在本节中,我们将讨论 PHP 8 中与安全相关的 PHP 函数的一些变化。受影响的更改函数如下:

  • assert()

  • password_hash()

  • crypt()

此外,PHP 8 还改变了使用 disable_functions 指令处理 php.ini 文件中定义的任何函数的方式。让我们先来看看这个指令。

了解禁用函数处理的变化

虚拟主机公司通常提供折扣很大的共享主机套餐。客户注册后,托管公司的 IT 人员会在共享服务器上创建一个账户,分配一个磁盘配额以控制磁盘空间的使用,并在网络服务上创建一个虚拟主机定义。然而,这类托管公司面临的问题是,允许不受限制地访问 PHP 会给共享托管公司和同一服务器上的其他用户带来安全风险。

为了解决这个问题,IT 人员通常会给 php.ini 指令 disable_functions 指定一个逗号分隔的函数列表。这样,该服务器上运行的 PHP 代码就不能使用该列表中的任何函数。通常会出现在此列表中的函数是那些允许操作系统访问的函数,如 system()shell_exec()

只有 PHP 内部函数才能出现在此列表中。内部函数是指那些包含在 PHP 内核中的函数,以及通过扩展提供的函数。用户自定义函数不受该指令影响。

检查禁用函数的处理差异

在 PHP 7 及以前的版本中,禁用的函数不能重新定义。在 PHP 8 中,禁用的函数被当作从未存在过,这意味着可以重新定义。

在 PHP 8 中可以重新定义禁用的函数并不意味着恢复了原来的功能!

为了说明这一概念,我们首先在 php.ini 文件中添加这一行:disable_functions=system

请注意,我们需要在两个 Docker 容器(PHP 7 和 PHP 8)中都添加这一行才能完成说明。更新 php.ini 文件的命令如下所示:

root@php8_tips_php7 [ /repo/ch06 ]#
echo "disable_functions=system">>/etc/php.ini
root@php8_tips_php8 [ /repo/ch06 ]#
echo "disable_functions=system">>/etc/php.ini

如果我们尝试使用 system() 函数,在 PHP 7 和 PHP 8 中都会失败。 这里我们显示 PHP 8 的输出结果:

root@php8_tips_php8 [ /repo/ch06 ]#
php -r "system('ls -l');"
PHP Fatal error: Uncaught Error: Call to undefined function system() in Command line code:1

然后我们定义一些程序代码来重新定义被禁止的函数:

// /repo/ch06/php8_disabled_funcs_redefine.php
function system(string $cmd, string $path = NULL) {
    $output = '';
    $path = $path ?? __DIR__;
    if ($cmd === 'ls -l') {
        $iter = new RecursiveDirectoryIterator($path);
        foreach ($iter as $fn => $obj)
            $output .= $fn . "\n";
    }
    return $output;
}
echo system('ls -l');

从代码示例中可以看到,我们创建了一个模仿 ls -l Linux 系统调用行为的函数,但只使用了安全的 PHP 函数和类。但是,如果我们尝试在 PHP 7 中运行这个函数,就会出现一个致命错误。下面是 PHP 7 的输出结果:

root@php8_tips_php7 [ /repo/ch06 ]#
php php8_disabled_funcs_redefine.php
PHP Fatal error: Cannot redeclare system() in /repo/ch06/php8_disabled_funcs_redefine.php on line 17

然而,在 PHP 8 中,我们的函数重新定义成功,如下所示:

root@php8_tips_php8 [ /repo/ch06 ]#
php php8_disabled_funcs_redefine.php
/repo/ch06/php8_printf_vs_vprintf.php
/repo/ch06/php8_num_str_non_wf_extracted.php
/repo/ch06/php8_vprintf_bc_break.php
/repo/ch06/php7_vprintf_bc_break.php
... not all output is shown ...
/repo/ch06/php7_curly_brace_usage.php
/repo/ch06/php7_compare_num_str_valid.php
/repo/ch06/php8_compare_num_str.php
/repo/ch06/php8_disabled_funcs_redefine.php

现在你已经知道如何使用禁用函数了。接下来,让我们看看重要的 crypt() 函数的变化。

了解 crypt() 函数的更改

自 PHP 第 4 版以来,crypt() 函数一直是生成 PHP 哈希值的主要函数。其生命力顽强的原因之一是它有如此多的选项。如果您的代码直接使用 crypt() ,您会很高兴地注意到,如果提供了一个不可用的盐值,那么长期以来被认为已经失效的防御加密标准(DES)在 PHP 8 中就不再是后备方案了!盐值有时也被称为初始化向量(IV)。

另一个重要变化涉及 rounds 值。一轮就像洗牌:洗牌的次数越多,随机化程度就越高(除非你面对的是拉斯维加斯的鲨鱼牌!)。在密码学中,区块与纸牌类似。在每一轮中,每个区块都要应用一个加密函数。如果加密函数比较简单,则可以更快地生成哈希值;但要完全随机化区块,则需要更多轮。

SHA-1(安全散列算法)系列使用快速但简单的算法,因此需要更多的轮次。另一方面,SHA-2 系列使用的是更复杂的散列函数,需要的资源更多,但轮数更少。

在使用 PHP crypt() 函数和 CRYPT_SHA256(SHA-2 系列)时,PHP 8 不会再将轮数参数静默解析为最接近的限制。取而代之的是,crypt() 将以 *0 返回失败,这与 glibc 的行为一致。此外,在 PHP 8 中,第二个参数(盐)现在是强制性的。

下面的示例说明了使用 crypt() 函数时 PHP 7 和 PHP 8 之间的差异:

  1. 首先,我们定义了代表不可用盐值和非法回合数的变量:

    // /repo/ch06/php8_crypt_sha256.php
    $password = 'password';
    $salt = str_repeat('+x=', CRYPT_SALT_LENGTH + 1);
    $rounds = 1;
  2. 然后,我们使用 crypt() 函数创建两个哈希值。在第一种用法中,$default 是提供无效盐参数后的结果。在第二种用法中,$sha256 提供了有效的盐值,但轮数无效:

    $default = crypt($password, $salt);
    $sha256 = crypt($password,
    '$5$rounds=' . $rounds . '$' . $salt . '$');
    echo "Default : $default\n";
    echo "SHA-256 : $sha256\n";

下面是在 PHP 7 中运行的示例代码的输出结果:

root@php8_tips_php7 [ /repo/ch06 ]#
php php8_crypt_sha256.php
PHP Deprecated: crypt(): Supplied salt is not valid for DES.
Possible bug in provided salt format. in /repo/ch06/php8_crypt_
sha256.php on line 7
Default : +xj31ZMTZzkVA
SHA-256 : $5$rounds=1000$+x=+x=+x=+x=+x=+
$3Si/vFn6/xmdTdyleJl7Rb9Heg6DWgkRVKS9T0ZZy/B

请注意 PHP 7 是如何静默地修改原始请求的。在第一种情况下,crypt() 返回到 DES (!)。在第二种情况下,PHP 7 将轮值从 1 默默地改为最接近的 1000。

而在 PHP 8 中运行的相同代码则会失败并返回 *0,如图所示:

root@php8_tips_php8 [ /repo/ch06 ]#
php php8_crypt_sha256.php
Default : *0
SHA-256 : *0

正如我们在本书中反复强调的那样,当 PHP 为您做出假设时,最终您会得到产生不一致结果的糟糕代码。在刚刚显示的代码示例中,最佳实践是定义一个类方法或函数,对其参数施加更大的控制。通过这种方式,您可以验证参数并避免依赖 PHP 假设。

接下来,我们看一下对 password_hash() 函数的更改。

处理对 password_hash() 的更改

多年来,许多开发人员滥用 crypt(),因此 PHP 核心团队决定添加一个封装函数 password_hash()。事实证明,这个函数大获成功,现在已成为使用最广泛的安全函数之一。以下是 password_hash() 的函数签名:

password_hash(string $password, mixed $algo, array $options=?)

目前支持的算法包括 bcryptArgon2iArgon2id。建议使用预定义的算法常量: PASSWORD_BCRYPTPASSWORD_ARGON2IPASSWORD_ARGON2IDPASSWORD_DEFAULT 算法目前设置为 bcrypt。不同的算法有不同的选项。如果使用 PASSWORD_BCRYPTPASSWORD_DEFAULT 算法,选项包括成本(cost)和盐(salt)。

传统观点认为,最好使用 password_hash() 函数随机生成的盐(salt)。在 PHP 7 中,盐选项已被弃用,现在在 PHP 8 中被忽略。这不会导致向后兼容中断,除非出于其他原因依赖于 salt

在本代码示例中,使用了一个非随机的盐值:

// /repo/ch06/php8_password_hash.php
$salt = 'xxxxxxxxxxxxxxxxxxxxxx';
$password = 'password';
$hash = password_hash($password, PASSWORD_DEFAULT, ['salt' => $salt]);
echo $hash . "\n";
var_dump(password_get_info($hash));

在 PHP 7 的输出中,会发出一个废弃通知:

root@php8_tips_php7 [ /repo/ch06 ]# php php8_password_hash.php
PHP Deprecated: password_hash(): Use of the 'salt' option to
password_hash is deprecated in /repo/ch06/php8_password_hash.
php on line 6
$2y$10$xxxxxxxxxxxxxxxxxxxxxuOd9YtxiLKHM/l98x//sqUV1V2XTZEZ.
/repo/ch06/php8_password_hash.php:8:
array(3) {
    'algo' => int(1)
    'algoName' => string(6) "bcrypt"
    'options' => array(1) { 'cost' => int(10) }
}

从 PHP 7 的输出中还可以清楚地看到非随机的盐(salt)值。还需要注意的一点是,当 password_get_info() 被执行时,algo key 会显示一个整数值,该值与预定义的算法常量之一相对应。

PHP 8 的输出则有些不同,如图所示:

root@php8_tips_php8 [ /repo/ch06 ]# php php8_password_hash.
php PHP Warning: password_hash(): The "salt" option has been
ignored, since providing a custom salt is no longer supported
in /repo/ch06/php8_password_hash.php on line 6
$2y$10$HQNRjL.kCkXaR1ZAOFI3TuBJd11k4YCRWmtrI1B7ZDaX1Jngh9UNW
array(3) {
    ["algo"]=> string(2) "2y"
    ["algoName"]=> string(6) "bcrypt"
    ["options"]=> array(1) { ["cost"]=> int(10) }
}

您可以看到盐值被忽略,而是使用随机盐(salt)。PHP 8 发出有关使用 salt 选项的警告,而不是通知。 输出中需要注意的另一点是,当调用 password_get_info() 时,算法密钥返回一个字符串,而不是 PHP 8 中的整数。这是因为预定义的算法常量现在是字符串值,在用于 crypt() 函数。

在下一小节中,我们将检查的最后一个函数是 assert()

了解 assert() 的更改

assert() 函数通常与测试和诊断有关。我们将其纳入本小节,因为它通常具有安全含义。开发人员有时会在试图跟踪潜在安全漏洞时使用该函数。

要使用 assert() 函数,首先必须在 php.ini 文件中设置 zend.assertions=1,从而启用该函数。一旦启用,你就可以在应用程序代码的任何地方调用一个或多个 assert() 函数。

了解 assert() 使用方法的变化

从 PHP 8 开始,assert() 不能再提供要求值的字符串参数:取而代之的是必须提供一个表达式。这可能会造成代码中断,因为在 PHP 8 中,字符串被视为表达式,因此总是解析为布尔值 TRUE。此外,assert.quiet_eval php.ini 指令和与 assert_options() 一起使用的 ASSERT_QUIET_EVAL 预定义常量在 PHP 8 中已被删除,因为它们现在没有任何作用。

为了说明潜在的问题,我们首先通过设置 php.ini 指令 zend.assertions=1 激活断言。然后我们定义一个示例程序如下:

  1. 我们使用 ini_set() 使 assert() 引发异常。我们还定义了一个变量 $pi

    // /repo/ch06/php8_assert.php
    ini_set('assert.exception', 1);
    $pi = 22/7;
    echo 'Value of 22/7: ' . $pi . "\n";
    echo 'Value of M_PI: ' . M_PI . "\n";
  2. 然后我们尝试将断言作为表达式 $pi === M_PI

    try {
        $line = __LINE__ + 2;
        $message = "Assertion expression failed ${line}\n";
        $result = assert($pi === M_PI,
            new AssertionError($message));
        echo ($result) ? "Everything's OK\n"
            : "We have a problem\n";
    } catch (Throwable $t) {
        echo $t->getMessage() . "\n";
    }
  3. 在最后一个 try/catch 块中,我们以字符串的形式尝试断言:

    try {
        $line = __LINE__ + 2;
        $message = "Assertion string failed ${line}\n";
        $result = assert('$pi === M_PI', new AssertionError($message));
        echo ($result) ? "Everything's OK\n" : "We have a problem\n";
    } catch (Throwable $t) {
        echo $t->getMessage() . "\n";
    }
  4. 在 PHP 7 中运行该程序时,一切正常:

    root@php8_tips_php7 [ /repo/ch06 ]# php php8_assert.php
    Value of 22/7: 3.1428571428571
    Value of M_PI: 3.1415926535898
    Assertion as expression failed on line 18
    Assertion as a string failed on line 28
  5. M_PI 的值来自数学扩展,比简单地用 22 除以 7 要精确得多!因此,两个断言都会抛出异常。但在 PHP 8 中,输出结果有很大不同:

    root@php8_tips_php8 [ /repo/ch06 ]# php php8_assert.php
    Value of 22/7: 3.1428571428571
    Value of M_PI: 3.1415926535898
    Assertion as expression failed on line 18
    Everything's OK

作为字符串的断言被解释为表达式。由于字符串不为空,因此布尔结果为 TRUE,返回假阳性。如果代码依赖于作为字符串的断言的结果,则必然会失败。不过,从 PHP 8 的输出中可以看到,断言作为表达式在 PHP 8 和 PHP 7 中的工作原理是一样的。

最佳实践:不要在生产代码中使用 assert()。如果使用 assert(),请务必提供表达式,而不是字符串。

在了解了安全相关功能的更改后,本章就告一段落了。

总结

在本章中,您了解了 PHP 8 与早期版本在字符串处理方面的差异,以及如何开发解决字符串处理差异的变通方法。正如所学,PHP 8 对字符串函数参数的数据类型进行了更严格的控制,并在参数缺失或为空时引入了一致性。正如所学到的,PHP 早期版本的一个大问题是,有几个假设是默默地替你做出的,这就很有可能导致意想不到的结果。

在本章中,我们还强调了涉及数字字符串和数字数据之间比较的问题。您不仅了解了数字字符串、类型杂耍和非严格比较,还学习了 PHP 8 如何纠正早期版本中数字字符串处理的固有缺陷。本章涉及的另一个主题是与 PHP 8 中几个运算符的不同行为有关的潜在问题。 你学会了如何发现潜在问题,并获得了提高代码适应性的最佳实践。

本章还讨论了一些 PHP 函数是如何依赖于本地设置的,以及 PHP 8 是如何解决这个问题的。 在 PHP 8 中,浮点表示现在是统一的,不再依赖于本地设置。您还了解了 PHP 8 在处理数组元素方面的变化,以及几个与安全相关的函数的变化。

本章中涉及的提示、技巧和技术提高了对早期版本 PHP 中不一致行为的认识。有了这种新的认识,你就能更好地控制 PHP 代码的使用。现在,你也能更好地发现 PHP 8 移植后可能导致代码断开的情况,从而比其他开发者更有优势,最终编写出性能稳定可靠的 PHP 代码。

下一章将向你介绍如何避免修改 PHP 扩展时可能出现的代码中断。