Une requête intéressante a été ouverte contre PHP pour faire bin2hex() depuis quelques temps. Cela a donné lieu à des discussions intéressantes sur la liste de diffusion (et m'a même amené à répondre : -X). La couverture par PHP des attaques de synchronisation à distance est très bonne, mais ils parlent de comparaisons de chaînes. Je veux parler d'autres types d'attaques chronométrées.
D'accord, supposons que vous ayez le code suivant :
function containsTheLetterC($string) { for ($i = 0; $i < strlen($string); $i++) { if ($string[$i] == "c") { return true; } sleep(1); } return false;}var_dump(containsTheLetterC($_GET['query']));
Il devrait être facile de dire ce qu'il fait. Il prend le paramètre de requête de l'URL, puis le parcourt textuellement, en vérifiant s'il s'agit d'un c minuscule. Si c'est le cas, il reviendra. Sinon, il dort une seconde.
Alors imaginons que nous ayons transmis la chaîne ?query=abcdef. Nous nous attendons à ce que la vérification prenne 2 secondes.
Maintenant, imaginons que nous ne sachions pas quelle lettre chercher. Imaginons que « c » soit une valeur différente que nous ne connaissons pas. Pouvez-vous comprendre comment déterminer ce qu'est cette lettre ?
C'est facile. Nous construisons une chaîne "abcdefghijklmnopqrstuvwxyzABCDEFGHIJ...." et la transmettons. Nous pouvons alors calculer combien de temps il faut pour revenir. Nous savons alors quel personnage est différent !
C'est la base des attaques chronométrées.
Mais nous n’avons pas de code qui ressemble à ça dans le monde réel. Regardons un exemple réel :
$secret = "thisismykey";if ($_GET['secret'] !== $secret) { die("Not Allowed!");}
Pour comprendre ce qui se passe, nous devons voir is_identical_function à partir du code source de PHP. Si vous regardez cette fonction, vous verrez que le résultat est défini par :
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;
La variable if si les deux variables sont identiques (car l'exigence principale est $secret === $secret) . Dans notre cas, cela n’est pas possible, il suffit donc de regarder le bloc else.
Z_STRLEN_P(op1) == Z_STRLEN_P(op2)
Donc, si les longueurs de cordes ne correspondent pas, nous revenons immédiatement.
Cela signifie que si les cordes ont la même longueur, plus de travail peut être fait !
Si nous variions le temps nécessaire à l'exécution, nous verrions quelque chose comme ceci :
长度 | 时间运行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中一步一步发生的事情。
首先,它查看每个字符串的第一个字符。
如果第一个字符不同,请立即返回。
接下来,看看每个字符串的第二个字符。
如果它们不同,立即返回。
等等。
因此"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缓存 | 32KB | 0.5纳秒 |
L2高速缓存 | 256KB | 2.5 ns |
L3缓存 | 4-16MB | 10-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%的实际,并发生在现实世界中。
只有一种防御这种风格攻击的实用方法:
不要通过秘密索引数组(或字符串)。
这真的很简单。
你看过几次类似下面的代码?
$query = "SELECT * FROM users WHERE id = ?";$stmt = $pdo->prepare($query);$stmt->execute([$_POST['id']]);$user = $stmt->fetchObject();if ($user && password_verify($_POST['password'], $user->password)) { return true;}return false;
当然,这是安全的?
那里有信息泄漏。
如果您尝试使用不同的用户名,则根据用户名是否存在需要不同的时间。如果password_verify需要0.1秒,您可以简单地测量该差异来确定用户名是否有效。平均而言,对使用用户名的请求将花费比可用用户名更长的时间。
现在,这是一个问题吗?我不知道,这取决于你的要求。许多网站希望保留用户名的秘密,并尽量不公开他们的信息(例如:不说用户名或密码在登录表单中是否无效)。
如果你想保持用户名的秘密,你就不会。
做到这一点的唯一方法是不分支。但那里有问题。如果你不分支,你如何获得像上面那样的功能?
那么,一个想法是执行以下操作:
$query = "SELECT * FROM users WHERE id = ?";$stmt = $pdo->prepare($query);$stmt->execute([$_POST['id']]);$user = $stmt->fetchObject();if ($user) { return password_verify($_POST['password'], $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。但数据仍然存在。
修复漏洞,不要仅仅在它周围增加噪音。
随机延迟不起作用。但我们可以通过两种方式有效地使用延迟。第一个是更有效的,也是我唯一“依靠”的一个。
延迟取决于用户输入。
因此,在这种情况下,您可以使用本地密钥对用户输入进行哈希处理,以确定要使用的延迟:
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功能替换它,但我不确定这是否值得速度损失。
使操作花费最少时间(夹紧)
因此,许多人浮现的想法是将操作“夹”到特定的运行时(或者更准确地说,使其至少需要一定的运行时间)。
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保护技术(基于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['input']); $i++) { $input .= $_GET['input'][$i];}
这是可变的时间,但泄漏什么是秘密,
和
$time = 0;for ($i = 0; $i < strlen($_GET['input']); $i++) { $time += abs(ord($_GET['input'][$i]) - ord($secret[$i]));}sleep($time);
现在,这是一个荒谬的例子。但它表明,两者都会根据投入改变时间,但也会因我们试图保护的秘密而有所不同。这就是我们说“恒定时间”时的意思,而不是基于秘密的价值而变化。
我已经在帖子的主体中解决了上述问题。
是。我已经将其添加到防御列表中。但考虑到它对DDOS不起作用(虽然时间差异很难识别),但我不会因为这个原因而忽略它。
那么,事实并非如此。有视频和文件和工具以及更多工具和更多论文以及更多视频。
所以如果攻击者一直在谈论这件事情,那肯定是有好处的。
但是,成功利用计时攻击需要很多工作。因此,攻击者通常会寻找更容易和更常见的攻击,例如SQLi,XSS,远程代码执行等,但这实际上取决于更多因素。如果您正在保护博客网站的会话标识符,那么您可能不必担心它。但是,如果您保护用于加密信用卡号码的加密密钥......
从实际的角度来看,我不会担心定时攻击,除非我确信其他潜在的媒介是安全的。就这样说,我认为这很有趣,值得了解。但是像安全和编程中的其他一切一样,这都是关于权衡的。
相关推荐:
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!