如何确保编译器优化不会引入安全风险?

40

我需要编写一个Windows服务,其中涉及一些机密数据(例如PIN码、密码等)。这些信息仅在短时间内需要使用:通常它们会立即发送到智能卡读卡器。

让我们考虑以下代码:

{
  std::string password = getPassword(); // Get the password from the user

  writePasswordToSmartCard(password);

  // Okay, here we don't need password anymore.
  // We set it all to '\0' so it doesn't stay in memory.
  std::fill(password.begin(), password.end(), '\0');
}

现在我关心的是编译器优化。 在这里,编译器可能会检测到密码即将被删除,并且在此时更改其值是无用的,因此会删除该调用。

我不希望我的编译器关心未来未被引用的内存的值。

我的担忧是否合理?我如何确保这样的代码不会被优化删除?

5个回答

36

是的,您的担忧是合理的。您需要使用特别设计的函数,例如SecureZeroMemory(),以防止优化修改您的代码行为。

不要忘记,字符串类应该专门设计用于处理密码。例如,如果该类重新分配缓冲区以容纳更长的字符串,则必须在将其返回给内存分配器之前擦除缓冲区。我不确定,但很可能std::string不会这样做(至少默认情况下)。使用不适合的字符串处理类使所有担忧变得毫无价值-您甚至在意识到之前就会在程序内存中复制密码。


3
很简单,它的代码在编译程序时并未呈现给编译器,所以编译器无法看到它并决定它“没有用处”。例如,它可能已经被编译成 DLL,并且只能在动态链接时进行链接。 - sharptooth
@sharptooth:确实有道理。我会尽快接受这个答案的 ;) 你有关于编写安全密码处理类的链接/教程吗? - ereOn
1
std::string 就是 std::basic_string<char, std::char_traits<char>, std::alloc>。我认为分配器应该是“正确”的位置,并足以安全地处理即将释放的任何内存的清零。 - Christopher Creutzig
1
@sharptooth:当字符串对象被销毁时,内存将被重新分配或释放。分配器将被要求执行其中之一,并可以执行操作系统或运行时提供的任何安全零操作。是的,缩短字符串可能会导致某些数据比必要的时间更长地存在 - 但永远不会作为可能被重新使用的空闲内存。(哪个密码字符串会被缩短呢?) - Christopher Creutzig
@Roger:好的,抱歉打错字了。 - Christopher Creutzig
显示剩余5条评论

11

这个问题有些复杂,但原因不同。谁说std::string password = getPassword();没有在内存中留下另一份副本呢?(可能需要编写一个“安全”的分配器类,在“析构”或“释放”时将内存清零)

在您的代码片段中,您可以通过获取指向字符串数据的volatile指针(我不知道是否可以以标准方式完成),然后通过对其进行清零来避免优化。


好的,getPassword() 这个东西只是为了编写我的示例代码而虚构的。但你说得完全正确,这也是需要关注的。 - ereOn
@ereOn 无论您以何种方式生成密码,在这种情况下都必须非常小心。最简单的方法是使用自定义分配器,通过上述方法之一(volatile或SecureZeroMemory)将已释放的内存清零。请注意,std::string不会销毁其元素,只会释放内存。 - Yakov Galka

9
请勿使用std::string来存储密码,因为在重新分配或销毁内存时它不会将其清零 - 相反,请设计自己的ConfidentialString类。在设计该类时,您可能希望利用CryptProtectMemory...并且在需要使用解密版本时要非常小心,特别是在调用外部代码时。

我不知道那个函数。谢谢,它肯定会有所帮助。 - ereOn
你不必从头开始发明自己的字符串类,只需自己的分配器。 - Roger Pate
1
@Roger Pate:你可能还是自己编写比较好——这样可以更容易地控制哪些外部API可以(和不可以!)传递密码,让你保持字符串在内部加密,并避免意外复制构造。此外,有多少次需要对密码进行字符串操作呢? :) - snemarch

1
在这种特定情况下,如果编译器可以明显地具有副作用的方法调用进行优化,我会感到非常惊讶。或者std::fill是内联的,所以编译器可以看到实现?(我不是C++程序员)。
话虽如此,这种事情通常可能是一个问题。但是你需要考虑它被利用的难易程度。要读取另一个进程的内存,攻击者需要一定级别的管理员访问权限(如果没有,为什么要使用该操作系统)。如果机器已经被攻击到了那个级别,你已经输了。

我倾向于同意。不过在我们的系统中,当软件崩溃时,会将一个转储文件发送到特定的服务或开发人员本人。尽管这些人可以信任,但如果我能避免机密数据被存储在某个地方,那就更好了。 - ereOn
@ereOn:恐怕转储文件,如果其中包含进程的图像,本身就是一种安全风险。无论你采取什么措施来清除敏感数据,都无法保护你,因为开发人员可以访问代码并轻松破坏你的安全措施。 - JeremyP
开发人员无法更改在我们的生产环境下运行的代码。如果代码设计得良好且安全,了解其工作原理也无法帮助黑客窃取数据。而且,即使您成功从转储中删除了所有敏感数据,这些数据也无法起到任何作用。 - ereOn
2
如果std::fill()没有被内联,我会感到惊讶。实际上,我期望我的实现会回退到一些特定于平台的内在函数。编译器肯定可以优化这个过程。 - sbi
因为std::fill是一个模板,所以它可以被内联。即使在C++0x中删除了导出的模板,但仍然需要某些内容来生成正确的代码。 - Roger Pate

0

为什么不针对相关代码禁用优化呢?

#pragma optimize( "", off )

// Code, not to optimize goes here

#pragma optimize( "", on )

#pragma optimize 的这个示例是针对 MSVC 的,但其他编译器也支持它。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接