作者: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 版本。
我为 gcc 和 clang 对代码做了一些修改,并添加了一些代码来打印清理前后分配的内存块内容。我打印的是内存被释放后指针所指向的块的内容,但在实际程序中你不应该这样做,因为你永远不知道应用程序会如何响应。不过,在这个实验中,我冒昧地使用了这种技术。
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
所以,gcc 和 clang 都决定优化我们的代码。由于内存在调用 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() 调用完好无损,却忘记了他们看到的仍然只是一个调试版本。