An interesting request has been opened against PHP to make bin2hex() for a certain period of time. This led to some interesting discussions on the mailing list (and even got me to reply: -X). PHP's coverage of remote timing attacks is pretty good, but they talk about string comparisons. I want to talk about other types of timed attacks.
Okay, let's assume you have the following code:
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']));
It should be easy to tell what it does. It takes the query parameter from the URL and then goes through it verbatim, checking to see if it's a lowercase c. If so, it will return. If not, it sleeps for a second.
So let's imagine we passed the string ?query=abcdef. We expect the check to take 2 seconds.
Now, let's imagine that we don't know which letter to look for. Let's imagine that "c" is a different value that we don't know about. Can you figure out how to figure out what that letter is?
this is very simple. We construct a string "abcdefghijklmnopqrstuvwxyzABCDEFGHIJ...." and pass it in. We can then calculate how long it takes to return. Then we know which character is different!
This is the basis of timing attacks.
But we don't have code that looks like that in the real world. Let's look at a real example:
$secret = "thisismykey";if ($_GET['secret'] !== $secret) { die("Not Allowed!");}
In order to understand what is going on, we need to see is_identical_function from the source code of PHP. If you look at this function, you'll see that the result is defined by:
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;
The if variable is the same if both variables are the same (as the main requirement is $secret === $secret). In our case this is not possible so we just have to look at the else block.
Z_STRLEN_P(op1) == Z_STRLEN_P(op2)
So if the string lengths don't match, we return immediately.
This means that if the strings are the same length, more work can be done!
If we take how long to execute for different lengths, we'll see something like this:
Length | Time run 1 | Time run 2 | Time run 3 | Average time |
---|---|---|---|---|
0.01241 | 0.01152 | 0.01191 | 0.01194 | |
0.01151 | 0.01212 | 0.01189 | 0.01184 | |
0.01114 | 0.01251 | 0.01175 | 0.01180 | |
0.01212 | 0.01171 | 0.01120 | 0.01197 | |
0.01210 | 0.01231 | 0.01216 | 0.01219 | |
0.01121 | 0.01211 | 0.01194 | 0.01175 | |
0.01142 | 0.01174 | 0.01251 | 0.01189 | |
0.01251 | 0.01121 | 0.01141 | 0.01171 |
Memory Type | Size | Ludden |
---|---|---|
L1 Cache | 32KB | 0.5 nanoseconds |
L2 cache | 256KB | 2.5 ns |
L3 Cache | 4-16MB | 10-20 nanoseconds |
Memory | lot | 60 - 100 nanoseconds |
所以我们来看看在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,远程代码执行等,但这实际上取决于更多因素。如果您正在保护博客网站的会话标识符,那么您可能不必担心它。但是,如果您保护用于加密信用卡号码的加密密钥......
从实际的角度来看,我不会担心定时攻击,除非我确信其他潜在的媒介是安全的。就这样说,我认为这很有趣,值得了解。但是像安全和编程中的其他一切一样,这都是关于权衡的。
相关推荐:
The above is the detailed content of PHP security example sharing. For more information, please follow other related articles on the PHP Chinese website!