安全清除内存并重新分配

Posted

技术标签:

【中文标题】安全清除内存并重新分配【英文标题】:Clearing memory securely and reallocations 【发布时间】:2012-05-27 21:01:47 【问题描述】:

在here 的讨论之后,如果你想有一个安全类用于在内存中存储敏感信息(例如密码),你必须:

memset/clear the memory before free it 重新分配也必须遵循相同的规则 - 而不是使用 realloc,而是使用 malloc 创建一个新的内存区域,将旧内存复制到新内存,然后在最终释放之前 memset/clear 旧内存

所以这听起来不错,我创建了一个测试类来看看它是否有效。所以我做了一个简单的测试用例,我不断添加单词“LOL”和“WUT”,然后在这个安全缓冲区类中添加一个数字大约一千次,销毁该对象,然后最终执行导致核心转储的操作。

由于该类应该在销毁之前安全地清除内存,因此我不应该能够在 coredump 上找到“LOLWUT”。但是,我还是设法找到了它们,并想知道我的实现是否只是错误。但是,我使用 CryptoPP 库的 SecByteBlock 尝试了同样的事情:

#include <cryptopp/osrng.h>
#include <cryptopp/dh.h>
#include <cryptopp/sha.h>
#include <cryptopp/aes.h>
#include <cryptopp/modes.h>
#include <cryptopp/filters.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
using namespace std;

int main()
   
      CryptoPP::SecByteBlock moo;

      int i;
      for(i = 0; i < 234; i++)
         moo += (CryptoPP::SecByteBlock((byte*)"LOL", 3));
         moo += (CryptoPP::SecByteBlock((byte*)"WUT", 3));

         char buffer[33];
         sprintf(buffer, "%d", i);
         string thenumber (buffer);

         moo += (CryptoPP::SecByteBlock((byte*)thenumber.c_str(), thenumber.size()));
      

      moo.CleanNew(0);

   

   sleep(1);

   *((int*)NULL) = 1;

   return 0;

然后编译使用:

g++ clearer.cpp -lcryptopp -O0

然后启用核心转储

ulimit -c 99999999

然后,启用核心转储并运行它

./a.out ; grep LOLWUT core ; echo hello

给出以下输出

Segmentation fault (core dumped)
Binary file core matches
hello

这是什么原因造成的?由于 SecByteBlock 的 append 引起的重新分配,应用程序的整个内存区域是否重新分配?

另外,This is SecByteBlock's Documentation

edit:使用 vim 检查核心转储后,我得到了这个: http://imgur.com/owkaw

edit2:更新了代码,使其更易于编译,以及编译说明

final edit3:看起来 memcpy 是罪魁祸首。请参阅 Rasmus 的mymemcpy 实现,了解他的回答。

【问题讨论】:

我认为这不是您所看到的原因,但您确实知道在某些内存上调用 memset 并不能阻止某些交换文件中仍然存在它的副本某处?通常,memset 的结果不需要穿透所有缓存层,但我提到交换文件,因为它是最持久的,因此对于敏感数据来说是最危险的地方。 @SteveJessop 嗯,这可能就是为什么 windows 有 SecureZeroMemory 或类似的东西。我想知道 linux/posix 的等价物是什么……如果有的话。 一个足够积极的优化器可能会将memset 删除到不再被读取的块中。 SecureZeroMemory 是有原因的! 那么我的王国需要一个 posix/linux/ios 版本的securezeromemory! @kamziro:我很确定 Linux 和 iOS 都会在让另一个进程看到之前为您清除内存。如果不是,那是因为你在编译内核时关闭了某些东西:-)最终你要争夺的是攻击者可以通过引发和读取核心转储来查看内存内容的窗口的持续时间,直接检查 RAM 等。无论您做什么,该窗口要么存在,要么不存在,因为攻击者通常没有理由在之后你清除它而不是之前这样做。 【参考方案1】:

字符串文字将存储在内存中,不由 SecByteBlock 类管理。

this other SO question很好地解释了它: Is a string literal in c++ created in static memory?

您可以通过查看获得的匹配数来尝试确认 grep 匹配是否可以由字符串文字解释。您还可以打印出 SecByteBlock 缓冲区的内存位置,并尝试查看它们是否与核心转储中与您的标记匹配的位置相对应。

【讨论】:

核心转储存储器匹配看起来像这样:“@ ^ @ ^ @ ^ @ ^ @ ^ @ ^ @ ^ @ ^ @ ^ @ ^ @ ^ @ ^ @ 0LOLWUT231LOLWUTOLWUT229LOLWUT23216LOLWUT217LOLWUT218LOLWUT219LOLWUT220LOLWUT221LOLWUT222LOLWUT223LOLWUT224LOLWUT225LOLWUT226LOL ^ @ ^ @ ^ @^@^@^@^@^@^" (来自 vim),所以它们肯定来自 secbyteblock。将循环设置为零元素不会给出匹配项。设置为零也是通过使用用户输入完成的,因此绝对不是“编译器优化”【参考方案2】:

这是另一个更直接地重现问题的程序:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

inline void SecureWipeBuffer(char* buf, size_t n)
  volatile char* p = buf;
  asm volatile("rep stosb" : "+c"(n), "+D"(p) : "a"(0) : "memory");


void mymemcpy(char* b, const char* a, size_t n)
  char* s1 = b;
  const char* s2= a;
  for(; 0<n; --n) *s1++ = *s2++;


int main()
  const size_t size1 = 200;
  const size_t size2 = 400;

  char* b = new char[size1];
  for(int j=0;j<size1-10;j+=10)
    memcpy(b+j, "LOL", 3);
    memcpy(b+j+3, "WUT", 3);
    sprintf((char*) (b+j+6), "%d", j);
  
  char* nb = new char[size2];
  memcpy(nb, b, size1);
  //mymemcpy(nb, b, size1);
  SecureWipeBuffer(b,size1);
  SecureWipeBuffer(nb,size2);

  *((int*)NULL) = 1;

  return 0;    

如果您将 memcpy 替换为 mymemcpy 或使用更小的尺寸,问题就会消失,所以我最好的猜测是内置 memcpy 做了一些事情,将部分复制的数据留在内存中。

我想这只是表明从内存中清除敏感数据实际上是不可能的,除非它是从头开始设计到整个系统中的。

【讨论】:

诅咒,真不幸。我想这排除了使用“防止 RAM 取证!”营销我的应用程序的可能性! 其实你的mymemcpy好像已经解决了问题。看来标准真的是导致记忆分散到野外的东西! @kamziro。但奇怪的是,使用 -fno-builtin-memcpy 不起作用。【参考方案3】:

如果不检查memcpy_s 的详细信息,我怀疑您看到的是memcpy_s 用于复制小内存缓冲区的临时堆栈缓冲区。您可以通过在调试器中运行并查看查看堆栈内存时是否出现LOLWUT 来验证这一点。

[Crypto++ 中reallocate 的实现在调整内存分配大小时使用memcpy_s,这就是为什么您可以在内存中找到一些LOLWUT 字符串的原因。此外,许多不同的LOLWUT 字符串在该转储中重叠的事实表明它是一个正在被重用的临时缓冲区。]

memcpy 的自定义版本只是一个简单的循环,不需要计数器之外的临时存储,因此这肯定比实现memcpy_s 的方式更安全。

【讨论】:

+1。 memset_s 是唯一符合规范的安全清除缓冲区的方法。这也意味着在 C++11 之前,没有办法安全地清除缓冲区(即在符合规范的情况下)。【参考方案4】:

我建议这样做的方法是加密内存中的数据。这样,无论数据是否仍在内存中,数据始终是安全的。当然,缺点是每次访问数据时都需要对数据进行加密/解密。

【讨论】:

编译器足够“聪明”,可以优化您即将使用的内存上的memset free,也许还可以删除最后的加密。或者下一个编译器版本可能。 我建议您只加密/解密您当前正在阅读/写入的特定部分。【参考方案5】:

尽管出现在核心转储中,但密码实际上并不在内存中 清除缓冲区后不再。问题是memcpying a 足够长的字符串会将密码泄漏到 SSE 寄存器中,那些 是核心转储中显示的内容。

memcpysize 参数大于某个特定值时 阈值—80 bytes on the mac—然后使用 SSE 指令执行 内存复制。这些指令更快,因为它们可以复制 16 一次并行处理字节,而不是逐个字符, 一个字节一个字节,或者一个字一个字。这是源代码的关键部分 Libc on the mac:

LAlignedLoop:               // loop over 64-byte chunks
    movdqa  (%rsi,%rcx),%xmm0
    movdqa  16(%rsi,%rcx),%xmm1
    movdqa  32(%rsi,%rcx),%xmm2
    movdqa  48(%rsi,%rcx),%xmm3

    movdqa  %xmm0,(%rdi,%rcx)
    movdqa  %xmm1,16(%rdi,%rcx)
    movdqa  %xmm2,32(%rdi,%rcx)
    movdqa  %xmm3,48(%rdi,%rcx)

    addq    $64,%rcx
    jnz     LAlignedLoop

    jmp     LShort                  // copy remaining 0..63 bytes and done

%rcx是循环索引寄存器,%rsis源地址寄存器, %rdidestination 地址寄存器。每次绕圈跑, 64 字节从源缓冲区复制到 4 个 16 字节 SSE 寄存器 xmm0,1,2,3;然后将这些寄存器中的值复制到 目标缓冲区。

该源文件中还有很多东西可以确保出现副本 仅在对齐的地址上,以填写剩余的副本部分 在完成 64 字节块之后,并处理源和 目的地重叠。

但是——SSE 寄存器在使用后不会被清除!这意味着 64 字节 被复制的缓冲区仍然存在于xmm0,1,2,3 寄存器中。

这是对 Rasmus 程序的修改,显示了这一点:

#include <ctype.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <emmintrin.h>

inline void SecureWipeBuffer(char* buf, size_t n)
  volatile char* p = buf;
  asm volatile("rep stosb" : "+c"(n), "+D"(p) : "a"(0) : "memory");


int main()
  const size_t size1 = 200;
  const size_t size2 = 400;

  char* b = new char[size1];
  for(int j=0;j<size1-10;j+=10)
    memcpy(b+j, "LOL", 3);
    memcpy(b+j+3, "WUT", 3);
    sprintf((char*) (b+j+6), "%d", j);
  
  char* nb = new char[size2];
  memcpy(nb, b, size1);
  SecureWipeBuffer(b,size1);
  SecureWipeBuffer(nb,size2);

  /* Password is now in SSE registers used by memcpy() */
  union 
    __m128i a[4];
    char c;
  ;
  asm ("MOVDQA %%xmm0, %0": "=x"(a[0]));
  asm ("MOVDQA %%xmm1, %0": "=x"(a[1]));
  asm ("MOVDQA %%xmm2, %0": "=x"(a[2]));
  asm ("MOVDQA %%xmm3, %0": "=x"(a[3]));
  for (int i = 0; i < 64; i++) 
      char p = *(&c + i);
      if (isprint(p)) 
        putchar(p);
       else 
          printf("\\%x", p);
      
  
  putchar('\n');

  return 0;

在我的 Mac 上,打印出来的是:

0\0LOLWUT130\0LOLWUT140\0LOLWUT150\0LOLWUT160\0LOLWUT170\0LOLWUT180\0\0\0

现在,检查核心转储,密码只出现一次, 和确切的0\0LOLWUT130\0...180\0\0\0 字符串一样。核心转储必须 包含所有寄存器的副本,这就是该字符串存在的原因——它是 xmm0,1,2,4 寄存器的值。

所以 调用后密码实际上不再在 RAM 中了 SecureWipeBuffer,它似乎只是,因为它实际上在某些 仅出现在核心转储中的寄存器。如果你担心 memcpy 存在可被 RAM 冻结利用的漏洞, 不用担心了。如果在寄存器中保存密码副本让您感到困扰, 使用修改后的memcpy,不使用 SSE2 寄存器,或清除它们 完成后。如果您真的对此感到偏执,请继续测试您的 coredumps 以确保编译器不会优化您的 密码清除代码。

【讨论】:

我在处理安全/可信环境时遇到了类似的问题。我知道某些优化可以确实消除了 memset。值得一提的是,这是英特尔 SGX SDK 的另一个解决方案,它使用指向自定义 memset 函数的 volatile 指针:github.com/intel/linux-sgx/blob/…

以上是关于安全清除内存并重新分配的主要内容,如果未能解决你的问题,请参考以下文章

如何在 C++ 中为 char *array 安全地重新分配内存(用于 CustomString 类)

Swift 对象的安全内存

重新分配内存并在C中重新分配的内存空间添加一个字符串

当向量需要更多内存并重新分配内存时,指针会发生啥?

出现错误:无法清除或重新分配库 DATA1,因为它仍在 SAS 中使用

Swift,SpriteKit:释放游戏场景并重新分配新场景