PHP的安全实例分享

小云云
发布: 2023-03-21 09:20:01
原创
1914 人浏览过

一个有趣的请求已开通针对PHP来作出bin2hex()一定的时间。这导致了一些关于邮件列表的有趣讨论(甚至让我回复:-X)。PHP在远程计时攻击方面的报道非常好,但他们谈论了字符串比较。我想谈谈其他类型的定时攻击。

什么是远程定时攻击?

好的,让我们假设你有以下代码:

function containsTheLetterC($string) {
    for ($i = 0; $i < strlen($string); $i++) {
        if ($string[$i] == "c") {
            return true;
        }
        sleep(1);
    }
    return false;}var_dump(containsTheLetterC($_GET[&#39;query&#39;]));
登录后复制

说出它的作用应该很容易。它接受query来自URL 的参数,然后逐字逐句通过它,检查它是否是小写字母c。如果是,它会返回。如果不是,它睡一秒钟。

所以让我们想象我们通过了字符串?query=abcdef。我们预计检查将花费2秒钟。

现在,让我们想象一下,我们不知道要查找哪封信。让我们想象一下,这"c"是一个我们不知道的不同价值。你能想出如何弄清楚那封信是什么吗?

这很简单。我们构造一个字符串"abcdefghijklmnopqrstuvwxyzABCDEFGHIJ....",并将其传入。然后我们可以计算返回需要多长时间。然后我们知道哪个角色不同!

这是定时攻击的基础。

但是我们没有在现实世界中看起来像那样的代码。我们来看一个真实的例子:

$secret = "thisismykey";if ($_GET[&#39;secret&#39;] !== $secret) {
    die("Not Allowed!");}
登录后复制

为了理解发生了什么,我们需要is_identical_function从PHP的源代码中看到。如果你看看这个函数,你会发现结果是由以下情况定义的:

case IS_STRING:
    if (Z_STR_P(op1) == Z_STR_P(op2)) {
        ZVAL_BOOL(result, 1);
    } else {
        ZVAL_BOOL(result, (Z_STRLEN_P(op1) == Z_STRLEN_P(op2))
        && (!memcmp(Z_STRVAL_P(op1), Z_STRVAL_P(op2), Z_STRLEN_P(op1))));
    }
    break;
登录后复制

该if如果两个变量都相同的变量(如主要是要求$secret === $secret)。在我们的情况下,这是不可能的,所以我们只需要看else块。

Z_STRLEN_P(op1) == Z_STRLEN_P(op2)
登录后复制

所以如果字符串长度不匹配,我们立即返回。

这意味着如果字符串的长度相同,就可以完成更多的工作!

如果我们花费多长时间来执行不同的长度,我们会看到类似这样的内容:

长度时间运行1时间跑2时间运行3平均时间
70.012410.011520.011910.01194
80.011510.012120.011890.01184
90.011140.012510.011750.01180
100.012120.011710.011200.01197
110.012100.012310.012160.01219
120.011210.012110.011940.01175
130.011420.011740.012510.01189
140.012510.011210.011410.01171

如果您忽略平均列,您会注意到似乎没有多少模式。这些数字都在彼此的原因之内。

但是,如果您平均进行多次跑步,您就会注意到一种模式。你会注意到长度11需要更长的时间(略),然后是其他长度。

这个例子非常夸张。但它说明了这一点。它已经显示了可以使用约49000(所以49000次尝试,而不是在上述实施例3)的样品大小远程检测的差异在时间缩短到约15纳秒。

但是,我们发现了这个长度。那不会给我们太多的收入......但第二部分呢?那怎么样memcmp(...)?

如果我们看的执行memcmp()::

int memcmp(const void *s1, const void *s2, size_t n){
    unsigned char u1, u2;
    for ( ; n-- ; s1++, s2++) {
        u1 = * (unsigned char *) s1;
        u2 = * (unsigned char *) s2;
        if ( u1 != u2) {
            return (u1-u2);
        }
    }
    return 0;}
登录后复制

等一下!这返回两个字符串之间的第一个区别!

所以一旦我们确定了字符串的长度,我们可以尝试不同的字符串开始检测差异:

axxxxxxxxxxbxxxxxxxxxxcxxxxxxxxxxdxxxxxxxxxx...yxxxxxxxxxxzxxxxxxxxxx
登录后复制

并通过相同的技术,发现与“txxxxxxxxxx”的差异比其他时间略长。

为什么?

让我们看看在memcmp中一步一步发生的事情。

  1. 首先,它查看每个字符串的第一个字符。

    如果第一个字符不同,请立即返回。

  2. 接下来,看看每个字符串的第二个字符。

    如果它们不同,立即返回。

  3. 等等。

因此"axxxxxxxxxx",它只执行第一步(因为我们正在比较的字符串"thisismykey")。但是"txxxxxxxxxx",第一步和第二步相匹配。所以它做更多的工作,因此需要更长的时间。

所以一旦你看到了,你知道t是第一个字符。

那么这只是一个重复这个过程的问题:

taxxxxxxxxxtbxxxxxxxxxtcxxxxxxxxxtdxxxxxxxxx...tyxxxxxxxxxtzxxxxxxxxx
登录后复制

为每个角色做到这一点,你就完成了。你已经成功推断出一个秘密!

防止比较攻击

所以这是一个基本的比较攻击。==并且===在PHP中都容易受到攻击。

有两种基本的防御方法。

首先是手动比较两个字符串,并且总是比较每个字符(这是我以前的博客文章中的函数:

/**
 * A timing safe equals comparison
 *
 * @param string $safe The internal (safe) value to be checked
 * @param string $user The user submitted (unsafe) value
 *
 * @return boolean True if the two strings are identical.
 */function timingSafeEquals($safe, $user) {
    $safeLen = strlen($safe);
    $userLen = strlen($user);
    if ($userLen != $safeLen) {
        return false;
    }
    $result = 0;
    for ($i = 0; $i < $userLen; $i++) {
        $result |= (ord($safe[$i]) ^ ord($user[$i]));
    }
    // They are only identical strings if $result is exactly 0...
    return $result === 0;}
登录后复制

第二个是使用内置的PHP hash_equals() function。这是在5.6中添加的,与上面的代码做同样的事情。

注:一般情况下,它是不是能够防止长度泄漏。所以可以泄漏这个长度。重要的部分是它不会泄漏关于两个字符串的差异的信息。

其他类型的计时攻击 - 索引查找

那就是比较。这是相当好的覆盖。但是让我们来谈谈索引查找:

如果您有一个数组(或字符串),并且使用秘密信息作为索引(键),则可能会泄漏有关该键的信息。

为了理解为什么,我们需要了解一下CPU如何处理内存。

通常,CPU具有固定宽度的寄存器。把这些想象成小变量。在现代处理器上,这些寄存器可能是64位(8字节)宽。这意味着CPU可以在一次处理的最大变量是8个字节。(注意:这是不正确的,因为大多数处理器都有基于向量的操作,例如SIMD,它允许它与更多的数据交互。对于这个讨论来说,尽管这并不重要)。

那么当你想读一个长度为16字节的字符串时会发生什么呢?

那么,CPU需要加载它块。根据操作的不同,它可能一次加载8个字节的字符串,并且一次对它操作8个字节。或者更常见的是,它一次处理一个字节。

所以这意味着它需要从某处获取字符串的其余部分。这个“某处”是主存(RAM)。但记忆非常缓慢。像真的很慢。大约100ns。这是我们的15纳秒阈值。

而且由于主内存非常慢,所以CPU在CPU本身上只有很少的内存空间来充当缓存。实际上,它们通常有两种类型的缓存。它们具有特定于每个核心(每个核心都有自己的L1高速缓存)的L1高速缓存,也是特定于核心的L2高速缓存,以及经常在单个芯片上的所有核心之间共享的L3高速缓存。为什么3层?由于速度:

内存类型尺寸潜伏
L1缓存32KB0.5纳秒
L2高速缓存256KB2.5 ns
L3缓存4-16MB10-20纳秒
内存地段60 - 100纳秒

所以我们来看看在string[index]C字符串(char \*字符数组)上做了什么。想象一下你有这样的代码:

char character_at_offset(const char *string, size_t offset) {
    return string[offset]}
登录后复制

编译器会将其编译为:

character_at_offset:
    pushq   %rbp    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp    .cfi_def_cfa_register 6
    movq    %rdi, -8(%rbp)
    movq    %rsi, -16(%rbp)
    movq    -16(%rbp), %rax
    movq    -8(%rbp), %rdx
    addq    %rdx, %rax    movzbl  (%rax), %eax
    popq    %rbp    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
登录后复制

虽然有很多噪音。让我们把它缩小到一个非功能性但更合适的尺寸:

character_at_offset:
    addq    %rdx, %rax    movzbl  (%rax), %eax
    popq    %rbp
    ret
登录后复制

该函数有两个参数,其中一个是指针(字符串的第一个元素),第二个是整数偏移量。它将两者相加以获得我们想要的字符的内存地址。然后,movzbl从该地址移动一个字节并将其存储%eax(其余零也为零)。

那么,CPU如何知道在哪里可以找到那个内存地址呢?

那么,它会遍历高速缓存链,直到找到它。

因此,如果它在L1缓存中,整体操作大约需要0.5 ns。如果它在L2,2.5ns。等等。因此,通过仔细计算信息的时间,我们可以推断出该项目被缓存的位置(或者它是否被缓存)。

值得注意的是,CPU不会缓存单个字节。他们缓存称为线的内存块。现代处理器通常具有64字节宽的高速缓存行。这意味着缓存中的每个条目都是连续的64字节的内存块。

所以,当你进行内存提取时,CPU会将一个64字节的内存块写入缓存行。因此,如果您的movzbl调用需要打到主内存,整个块将被复制到较低的缓存行中。(请注意,这是一个非常简单的事情,但它是为了演示下一步会发生什么)。

现在,这里是真正有趣的地方。

假设我们正在处理一个大字符串。一个不适合二级缓存。所以1MB。

现在,让我们设想一下,我们从基于数字的秘密序列的字符串中提取字节。

通过观察获取字节需要多长时间,我们实际上可以确定关于秘密的信息!

让我们想象我们获取以下偏移量:

  • offset 10

  • offset 1

第一次提取会导致缓存未命中,将从主内存加载到缓存中。

但是第二个fetch(offset 1)将从L1缓存中获取,因为它可能与原来的缓存行(内存块)相同offset 10。所以它很可能是缓存命中。

如果我们然后提取offset 2048,它很可能不在缓存中。

因此,通过仔细观察延迟模式,您可以确定有关偏移序列关系的一些信息。通过多次使用正确的信息来做到这一点,你可以推断出这个秘密。

这被称为缓存时间攻击。

现在看起来真的很牵强,对吧?我的意思是,你有多频繁地获取完美的信息?这怎么可能是实际的。那么,这是100%的实际,并发生在现实世界中。

针对缓存时间攻击的防御:

只有一种防御这种风格攻击的实用方法:

  1. 不要通过秘密索引数组(或字符串)。

这真的很简单。

基于分支的计时攻击

你看过几次类似下面的代码?

$query = "SELECT * FROM users WHERE id = ?";$stmt = $pdo->prepare($query);$stmt->execute([$_POST[&#39;id&#39;]]);$user = $stmt->fetchObject();if ($user && password_verify($_POST[&#39;password&#39;], $user->password)) {
    return true;}return false;
登录后复制

当然,这是安全的?

那里有信息泄漏。

如果您尝试使用不同的用户名,则根据用户名是否存在需要不同的时间。如果password_verify需要0.1秒,您可以简单地测量该差异来确定用户名是否有效。平均而言,对使用用户名的请求将花费比可用用户名更长的时间。

现在,这是一个问题吗?我不知道,这取决于你的要求。许多网站希望保留用户名的秘密,并尽量不公开他们的信息(例如:不说用户名或密码在登录表单中是否无效)。

如果你想保持用户名的秘密,你就不会。

防御基于分支的计时攻击

做到这一点的唯一方法是不分支。但那里有问题。如果你不分支,你如何获得像上面那样的功能?

那么,一个想法是执行以下操作:

$query = "SELECT * FROM users WHERE id = ?";$stmt = $pdo->prepare($query);$stmt->execute([$_POST[&#39;id&#39;]]);$user = $stmt->fetchObject();if ($user) {
    return password_verify($_POST[&#39;password&#39;], $user->password);} else {
    password_verify("", DUMMY_HASH);}return false;
登录后复制

这意味着你password_verify在两种情况下运行。这削减了0.1第二个区别。

但核心计时攻击依然存在。原因在于数据库将返回查询的时间稍微有点不同,以查找找到该用户的查询,以及查找不到的查询。这是因为它在内部执行大量分支和条件逻辑,最终需要通过线路将数据传输回程序。

所以防御这种风格攻击的唯一方法就是不要将您的用户名视为秘密!

关于“随机延迟”的一个注记

许多人在听到定时攻击时,都会想:“呃,我只是随意添加一个延迟!这将工作!“。而事实并非如此。

要理解为什么,让我们来谈谈添加随机延迟时实际发生的情况:

整体执行时间是work + sleep(rand(1, 10))。如果兰德行为良好(这是随机的),那么随着时间的推移,我们可以将其平均。

让我们说这是rand(1, 10)。那么,这意味着当我们平均运行时,平均延迟约为5.相同的平均值加到所有情况下。所以我们需要做的就是每运行一次运行一次以平均噪音。我们运行的次数越多,随机值越倾向于平均。所以我们的信号仍然存在,它只需要稍微更多的数据来对抗噪声。

因此,如果我们需要运行49,000次测试以获得15ns的准确度,那么我们需要大概100,000或1,000,000次测试来获得相同的准确度和随机延迟。或者可能达到100,000,000。但数据仍然存在。

修复漏洞,不要仅仅在它周围增加噪音。

有效的实际延迟

随机延迟不起作用。但我们可以通过两种方式有效地使用延迟。第一个是更有效的,也是我唯一“依靠”的一个。

  1. 延迟取决于用户输入。

    因此,在这种情况下,您可以使用本地密钥对用户输入进行哈希处理,以确定要使用的延迟:

    function delay($input, $secret_key) {
        $hash = crc32(serialize($secret_key . $input . $secret_key));
        // make it take a maximum of 0.1 milliseconds
        time_nanosleep(0, abs($hash % 100000));}
    登录后复制

    然后只需将用户输入用于延迟功能。这样,随着用户改变他们的输入,延迟也会改变。但它会以同样的方式改变,使他们无法用统计技术来平均它。

    请注意,我使用过crc32()。这不需要是加密散列函数。由于我们只是派生一个整数,所以我们不需要担心碰撞。如果您希望更安全,您可以用SHA-2功能替换它,但我不确定这是否值得速度损失。

  2. 使操作花费最少时间(夹紧)

    因此,许多人浮现的想法是将操作“夹”到特定的运行时(或者更准确地说,使其至少需要一定的运行时间)。

    function clamp(callable $op, array $args, $time = 100) {
        $start = microtime(true);
        $return = call_user_func_array($op, $args);
        $end = microtime(true);
        // convert float seconds to integer nanoseconds
        $diff = floor((($end - $start) * 1000000000) % 1000000000);
        $sleep = $diff - $time;
        if ($sleep > 0) {
            time_nanosleep(0, $sleep);
        }
        return $return;}
    登录后复制

    所以你可以说比较必须花费最少的时间。因此,不要试图比较一直持续的时间,你只需要花时间。

    所以,你可以钳住等于100纳秒(clamp("strcmp", [$secret, $user], 100))。

    这样做,你保护了字符串的第一部分。如果前20个字符花费了100纳秒,那么通过钳位到100纳秒,可以防止那些泄漏的差异。

    但是有一些问题:

    • 它非常脆弱。如果你时间太短,你会失去所有的保护。如果时间过长,可能会在应用程序中增加不必要的延迟(如果不小心,可能会暴露DOS风险)。

    • 它实际上并没有保护任何东西。它只是掩盖了这个问题。我认为这是一种通过默默无闻的安全形式。这并不意味着它没有用或无效。这只是意味着风险。很难知道它是否确实有效地让你更安全,或者让你在夜晚更好地睡觉。当在图层中使用时,它可能是好的。

    • 它不能防止本地攻击者。如果攻击者可以在服务器上获得代码(甚至是未经授权的,在不同的用户帐户上,如共享服务器上),则他们可以查看CPU使用情况,从而可以看到过去的睡眠状况。这是一个延伸,在这种情况下可能会有更有效的攻击,但至少值得注意。

防御DOS攻击

所有这些技术都需要很多请求。它们基于依靠大量数据有效“平均”噪声的统计技术。

这意味着要获得足够的数据来实际执行攻击,攻击者可能需要制造数千,数十万甚至数百万的请求。

如果你正在练习好的DOS保护技术(基于IP的速率限制等),那么你将能够绕过很多这些风格的攻击。

但是DDOS保护难以防范。通过分配流量,防范难度更大。但对攻击者来说也更难,因为他们有更多的噪音需要处理(而不仅仅是本地网段)。所以这并不太实际。

但是就像安全的任何事情一样,纵深防御。即使我们认为这次攻击是不可能的,但如果我们原来的保护失败,仍然值得保护它。深度使用防御,我们可以让自己在各种规模的攻击中更具弹性。

回到点

目前有关PHP内部的一个关于是否使某些核心功能的时序安全与否的线索。正在讨论的具体功能是:

  • bin2hex

  • hex2bin

  • base64_encode

  • base64_decode

  • mcrypt_encrypt

  • mcrypt_decrypt

现在,为什么这些功能?井,bin2hex和base64_encode编码输出到浏览器(编码会话参数例如)当经常使用。然而,更重要的是hex2bin和base64_decode,因为它们可以用于解密秘密信息(就像在将密钥用于加密之前的密钥)。

到目前为止,大多数受访者的共识是,为了获得更多的安全,不值得让它们变得更慢。我同意这一点。

但是,我不同意的是,它会让它们“变慢”。更改比较(从)==到hash_equals较慢是因为它将函数的复杂性(最佳,平均,最差)从O(1, n/2, n)更改为O(n, n, n)。这意味着它将对平均情况下的性能产生重大影响。

但改变编码功能不会影响复杂性。他们将继续O(n)。所以问题是,速度差是多少?那么,我用PHP算法和一个时间安全的标准对bin2hex和hex2bin进行了基准测试,差异不是太显着。编码(bin2hex)大致相同(误差范围),并且解码的差异(hex2bin)大约为0.5μs。对于大约40个字符的字符串,这是5e-10秒多。

对我而言,这足够小,根本不用担心。平均应用程序调用其中一个受影响的函数多少次?也许每一次执行可能?但有什么潜在的好处?这可能是一个漏洞被阻止?

也许吧。我认为没有充足的理由去做这件事,一般而言,这些漏洞在用PHP编写的应用程序类型中将非常困难。但有了这个说法,如果实施过程足够快速(对我来说,0.5μs足够快),那么我认为没有一个重要的理由不去做这个改变。即使它有助于防止所有数百万PHP用户的单一攻击,这是否值得?是。它会阻止单一攻击吗?我不知道(可能不)。

但是,我认为有几项功能必须不断进行时间安全审计:

  • mcrypt_\*

  • hash_\*

  • password_\*

  • openssl_\*

  • md5()

  • sha1()

  • strlen()

  • substr()

基本上,我们所知道的任何东西都会与敏感信息一起使用,或者将在敏感操作中用作原语。

至于字符串函数的其余部分,或者没有必要让它们的时间安全(像lcfirst或strpos),或者它不可能(像trim)或已经完成(像strlen),或者它没有任何业务在PHP(如hebrev)...

跟进

因此,HackerNews和Reddit发布了这篇文章。评论有几个共同的主题,所以我会在这里跟进。我还编辑了帖子内联来解决这些问题。

不可能使代码保持恒定时间

那么,我应该澄清“不变”的含义。在绝对意义上,我并不是指不变的。我的意思是不变的相对于秘密。这意味着时间不会依赖于我们试图保护的数据而改变。因此总体而言,绝对时间可能由于许多原因而波动。但我们不希望我们试图保护的价值影响它。

这是区别:

for ($i = 0; $i < strlen($_GET[&#39;input&#39;]); $i++) {
    $input .= $_GET[&#39;input&#39;][$i];}
登录后复制

这是可变的时间,但泄漏什么是秘密,

$time = 0;for ($i = 0; $i < strlen($_GET[&#39;input&#39;]); $i++) {
    $time += abs(ord($_GET[&#39;input&#39;][$i]) - ord($secret[$i]));}sleep($time);
登录后复制

现在,这是一个荒谬的例子。但它表明,两者都会根据投入改变时间,但也会因我们试图保护的秘密而有所不同。这就是我们说“恒定时间”时的意思,而不是基于秘密的价值而变化。

怎样钳制一个特定的运行时间?

我已经在帖子的主体中解决了上述问题。

不保护DOS的工作?

是。我已经将其添加到防御列表中。但考虑到它对DDOS不起作用(虽然时间差异很难识别),但我不会因为这个原因而忽略它。

这不实用

那么,事实并非如此。有视频和文件和工具以及更多工具和更多论文以及更多视频。

所以如果攻击者一直在谈论这件事情,那肯定是有好处的。

但是,成功利用计时攻击需要很多工作。因此,攻击者通常会寻找更容易和更常见的攻击,例如SQLi,XSS,远程代码执行等,但这实际上取决于更多因素。如果您正在保护博客网站的会话标识符,那么您可能不必担心它。但是,如果您保护用于加密信用卡号码的加密密钥......

从实际的角度来看,我不会担心定时攻击,除非我确信其他潜在的媒介是安全的。就这样说,我认为这很有趣,值得了解。但是像安全和编程中的其他一切一样,这都是关于权衡的。

相关推荐:

对php一些服务器端特性配置,加强php的安全

php安全实例详解

整理了一些关于PHP安全性的知识

以上是PHP的安全实例分享的详细内容。更多信息请关注PHP中文网其他相关文章!

相关标签:
来源:php.cn
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板