ホームページ  >  記事  >  バックエンド開発  >  PHPセキュリティサンプルの共有

PHPセキュリティサンプルの共有

小云云
小云云オリジナル
2018-03-13 09:45:151921ブラウズ

しばらくの間、bin2hex() を作成するという興味深いリクエストが PHP に対してオープンされました。これにより、メーリング リスト上でいくつかの興味深い議論が生まれました (そして、私は -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;]));

それが何をするかは簡単にわかるはずです。 URL からクエリ パラメータを取得し、それを逐語的に調べて、それが小文字の c であるかどうかを確認します。そうすれば戻ってきます。そうでない場合は、1 秒間スリープします。

それでは、文字列 ?query=abcdef を渡したとしましょう。チェックには 2 秒かかると予想されます。

さて、どの文字を探せばよいのかわからないと想像してみましょう。 「c」が私たちの知らない別の値であると想像してみましょう。その手紙が何であるかを調べる方法がわかりますか?

簡単です。文字列「abcdefghijklmnopqrstuvwxyzABCDEFGHIJ....」を作成して渡します。その後、戻るまでにかかる時間を計算できます。そうすれば、どのキャラクターが違うのかが分かります!

これが時限攻撃の基本です。

しかし、現実の世界にはそのようなコードはありません。実際の例を見てみましょう:

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

何が起こっているのかを理解するには、PHP のソース コードから is_identical_function を確認する必要があります。この関数を見ると、結果が次によって定義されていることがわかります:

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 平均時間
7 0.01241 0.01152 0.01191 0.01194
8 0.01151 0.01212 0.01189 0.01184
9 0.01114 0.01251 0.01175 0.01180
10 0.01212 0.01171 0.01120 0.01197
11 0.01210 0.01231 0.01216 0.01219
12 0.01121 0.01211 0.01194 0.01175
13 0.01142 0.01174 0.01251 0.01189
14 0.01251 0.01121 0.01141 0.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キャッシュ25 6KB 2.5 nsL3 キャッシュ 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 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。