• 文章
  • 安全地清除私有数据
作者:
2016年4月6日(最后更新:2016年4月6日)

安全地清除私有数据

评分:4.6/5(143票)
*****

作者:Roman Fomichev

我们经常需要在程序中存储私有数据,例如密码、密钥及其派生数据。在使用完这些数据后,我们通常需要清除它们在内存中的痕迹,以防潜在的入侵者获取这些数据。本文将讨论为什么不能使用 memset() 函数来清除私有数据。

memset()

您可能已经读过这篇文章,其中讨论了程序中使用 memset() 清除内存时存在的漏洞。然而,那篇文章并未完全涵盖所有可能错误使用 memset() 的场景。您不仅在清除栈分配的缓冲区时会遇到问题,在清除动态分配的缓冲区时也同样会遇到问题。

栈 (The stack)

首先,让我们讨论一个来自上述文章的例子,该例子涉及使用一个在栈上分配的变量。

这是一个处理密码的代码片段


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <string>
#include <functional>
#include <iostream>

//Private data
struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

//Function performs some operations on password
void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

//Function for password entering and processing
int funcPswd()
{
  PrivateData data;
  std::cin >> data.m_pswd;

  doSmth(data);
  memset(&data, 0, sizeof(PrivateData));
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}</std::string></iostream></functional></string>

这个例子相当常规,并且完全是虚构的。

如果我们构建该代码的调试版本并在调试器中运行(我使用的是 Visual Studio 2015),我们会看到它工作得很好:密码及其计算出的哈希值在使用后都被清除了。

让我们在 Visual Studio 调试器中看一下我们代码的汇编版本


1
2
3
4
5
6
7
8
9
10
11
12
.... 
    doSmth(data);
000000013F3072BF  lea         rcx,[data]  
000000013F3072C3  call        doSmth (013F30153Ch)  
  memset(&data, 0, sizeof(PrivateData));
000000013F3072C8  mov         r8d,70h  
000000013F3072CE  xor         edx,edx  
000000013F3072D0  lea         rcx,[data]  
000000013F3072D4  call        memset (013F301352h)  
  return 1;
000000013F3072D9  mov         eax,1  
....

我们看到了对 memset() 函数的调用,它在使用后清除了私有数据。

我们本可以就此打住,但我们将继续尝试构建一个优化的发布版本。现在,这是我们在调试器中看到的内容


1
2
3
4
5
6
7
8
....
000000013F7A1035  call
        std::operator>><><char> > (013F7A18B0h)  
000000013F7A103A  lea         rcx,[rsp+20h]  
000000013F7A103F  call        doSmth (013F7A1170h)  
    return 0;
000000013F7A1044  xor         eax,eax   
.... </char>

所有与调用 memset() 函数相关的指令都被删除了。编译器认为没有必要调用一个清除数据的函数,因为这些数据已不再被使用。这不是一个错误,而是编译器的合法选择。从语言的角度来看,调用 memset() 是不必要的,因为该缓冲区在程序中后续不会再被使用,所以移除这个调用不会影响其行为。因此,我们的私有数据仍未被清除,这非常糟糕。

堆 (The heap)

现在让我们更深入地探讨。我们来看看当我们使用 malloc 函数或 new 运算符在动态内存中分配数据时会发生什么。

让我们修改之前的代码,使其使用 malloc


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <string>
#include <functional>
#include <iostream>

struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

int funcPswd()
{
  PrivateData* data = (PrivateData*)malloc(sizeof(PrivateData));
  std::cin >> data->m_pswd;
  doSmth(*data);
  memset(data, 0, sizeof(PrivateData));
  free(data);
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}</std::string></iostream></functional></string>

我们将测试一个发布版本,因为调试版本中所有的调用都在我们期望的位置。在 Visual Studio 2015 中编译后,我们得到以下汇编代码


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.... 
000000013FBB1021  mov         rcx,
        qword ptr [__imp_std::cin (013FBB30D8h)]  
000000013FBB1028  mov         rbx,rax  
000000013FBB102B  lea         rdx,[rax+8]  
000000013FBB102F  call
        std::operator>><><char> > (013FBB18B0h)  
000000013FBB1034  mov         rcx,rbx  
000000013FBB1037  call        doSmth (013FBB1170h)  
000000013FBB103C  xor         edx,edx  
000000013FBB103E  mov         rcx,rbx  
000000013FBB1041  lea         r8d,[rdx+70h]  
000000013FBB1045  call        memset (013FBB2A2Eh)  
000000013FBB104A  mov         rcx,rbx  
000000013FBB104D  call        qword ptr [__imp_free (013FBB3170h)]  
    return 0;
000000013FBB1053  xor         eax,eax  
.... </char>

这次 Visual Studio 做得很好:它按计划清除了数据。但其他编译器呢?让我们试试 gcc 5.2.1 版本和 clang 3.7.0 版本。

我为 gccclang 对代码做了一些修改,并添加了一些代码来打印清理前后分配的内存块内容。我打印的是内存被释放后指针所指向的块的内容,但在实际程序中你不应该这样做,因为你永远不知道应用程序会如何响应。不过,在这个实验中,我冒昧地使用了这种技术。


1
2
3
4
5
6
7
8
9
10
11
....
#include "string.h"
....
size_t len = strlen(data->m_pswd);
for (int i = 0; i < len;="" ++i)="" printf("%c",="" data-="">m_pswd[i]);
printf("| %zu \n", data->m_hash);
memset(data, 0, sizeof(PrivateData));
free(data);
for (int i = 0; i < len;="" ++i)="" printf("%c",="" data-="">m_pswd[i]);
printf("| %zu \n", data->m_hash);
.... 

现在,这是由 gcc 编译器生成的汇编代码片段


1
2
3
4
5
6
movq (%r12), %rsi
movl $.LC2, %edi
xorl %eax, %eax
call printf
movq %r12, %rdi
call free

打印函数(printf)之后是调用 free() 函数,而对 memset() 函数的调用不见了。如果我们运行代码并输入一个任意密码(例如 "MyTopSecret"),我们会在屏幕上看到以下信息

MyTopSecret| 7882334103340833743

MyTopSecret| 0

哈希值改变了。我猜这是内存管理器工作的副作用。至于我们的密码 "MyTopSecret",它完好无损地留在了内存中。

让我们看看 clang 的表现如何


1
2
3
4
5
6
movq (%r14), %rsi
movl $.L.str.1, %edi
xorl %eax, %eax
callq printf
movq %r14, %rdi
callq free

就像前一种情况一样,编译器决定移除对 memset() 函数的调用。这是打印出的输出

MyTopSecret| 7882334103340833743

MyTopSecret| 0

所以,gccclang 都决定优化我们的代码。由于内存在调用 memset() 函数后被释放,编译器认为这个调用是无关紧要的,并将其删除。

我们的实验表明,无论是处理栈内存还是应用程序的动态内存,编译器都倾向于为了优化而删除 memset() 的调用。

最后,让我们看看当使用 new 运算符分配内存时,编译器会如何响应。

再次修改代码


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <string>
#include <functional>
#include <iostream>
#include "string.h"

struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

int funcPswd()
{
  PrivateData* data = new PrivateData();
  std::cin >> data->m_pswd;
  doSmth(*data);
  memset(data, 0, sizeof(PrivateData));
  delete data;
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}</std::string></iostream></functional></string>

Visual Studio 按预期清除了内存


1
2
3
4
5
6
7
8
9
10
000000013FEB1044  call        doSmth (013FEB1180h)  
000000013FEB1049  xor         edx,edx  
000000013FEB104B  mov         rcx,rbx  
000000013FEB104E  lea         r8d,[rdx+70h]  
000000013FEB1052  call        memset (013FEB2A3Eh)  
000000013FEB1057  mov         edx,70h  
000000013FEB105C  mov         rcx,rbx  
000000013FEB105F  call        operator delete (013FEB1BA8h)  
    return 0;
000000013FEB1064  xor         eax,eax  

gcc 编译器也决定保留清除函数


1
2
3
4
5
6
7
8
9
10
11
12
13
call printf
movq %r13, %rdi
movq %rbp, %rcx
xorl %eax, %eax
andq $-8, %rdi
movq $0, 0(%rbp)
movq $0, 104(%rbp)
subq %rdi, %rcx
addl $112, %ecx
shrl $3, %ecx
rep stosq
movq %rbp, %rdi
call _ZdlPv

打印的输出也相应地改变了;我们输入的数据已经不在了

MyTopSecret| 7882334103340833743

| 0

但至于 clang,在这种情况下它也选择了优化我们的代码,并删掉了这个“不必要”的函数


1
2
3
4
5
6
movq (%r14), %rsi
movl $.L.str.1, %edi
xorl %eax, %eax
callq printf
movq %r14, %rdi
callq _ZdlPv

让我们打印内存的内容

MyTopSecret| 7882334103340833743

MyTopSecret| 0

密码仍然存在,等待被窃取。

让我们总结一下。我们发现,无论使用哪种类型的内存——栈内存还是动态内存,优化编译器都可能会移除对 memset() 函数的调用。尽管在我们的测试中,Visual Studio 在使用动态内存时没有移除 memset() 调用,但你不能指望它在实际代码中总是这样。在其他编译选项下,这种有害影响可能会显现出来。我们的小研究得出的结论是,不能依赖 memset() 函数来清除私有数据。

那么,用什么更好的方法来清除它们呢?

你应该使用专门的内存清除函数,这些函数在编译器优化代码时不会被删除。

例如,在 Visual Studio 中,你可以使用 RtlSecureZeroMemory。从 C11 开始,函数 memset_s 也可以使用。此外,如果需要,你还可以实现自己的安全函数;网上可以找到很多例子和指南。这里有一些例子。

解决方案1号


1
2
3
4
5
6
7
8
9
10
errno_t memset_s(void *v, rsize_t smax, int c, rsize_t n) {
  if (v == NULL) return EINVAL;
  if (smax > RSIZE_MAX) return EINVAL;
  if (n > smax) return EINVAL;
  volatile unsigned char *p = v;
  while (smax-- && n--) {
    *p++ = c;
  }
  return 0;
}

解决方案2号


1
2
3
4
5
void secure_zero(void *s, size_t n)
{
    volatile char *p = s;
    while (n--) *p++ = 0;
}

有些程序员甚至更进一步,创建用伪随机值填充数组且运行时间不同的函数,以阻碍基于时间测量的攻击。这些函数的实现也可以在网上找到。

结论

PVS-Studio 静态分析器可以检测我们在此讨论的数据清除错误,并使用 V597 诊断来提示这个问题。本文是为了更深入地解释为什么这个诊断很重要而写的。不幸的是,许多程序员倾向于认为分析器是在“挑剔”他们的代码,实际上没什么可担心的。嗯,这是因为他们在调试器中查看代码时看到他们的 memset() 调用完好无损,却忘记了他们看到的仍然只是一个调试版本。