我们经常需要在程序中存储隐私数据,例如密码、密钥和它们的派生物,我们通常需要在使用之后清理它们在内存中的痕迹,从而使潜在的入侵者无法获得这些数据。在这篇文章中我们讨论了为什么不能用函数memset()清理隐私数据。
您可能已经在其他文章中了解到在程序中使用 memset() 清理内存是存在漏洞的。 然而,这篇文章并没有完全考虑memset() 使用不当导致出现的所有情况。您遇到的问题不仅与清理堆栈分配的缓冲区相关,同样也与清理动态分配的缓冲区相关。
首先,让我们讨论上述提到的文章中的一个例子,该例是使用栈分配变量进行处理。
下面是一段处理密码的代码:
#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; }
这个例子是相当传统且完全合成的。
如果我们构建了一个调试版本的代码,并在调试器中运行它(我使用的是 Visual Studio2015),我们会看到改代码运行良好:密码和其计算的哈希值都在使用后被删除。
我们来看一下代码在Visual Studio调试器中的汇编版本:
.... 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() 函数,可以在使用私人数据后清理。
我们可以停在这里,但我们将继续尝试建立一个优化的版本。这时,我们在调试器中可以看到:
.... 000000013F7A1035 call std::operator>><char,std::char_traits<char> > (013F7A18B0h) 000000013F7A103A lea rcx,[rsp+20h] 000000013F7A103F call doSmth (013F7A1170h) return 0; 000000013F7A1044 xor eax,eax ....
所有调用 memset() 的相关指令已被删除。编译器认为没有必要调用函数清理数据,因为它们已经不再使用。这并不是一个错误,这是编译器的合法选择。从语言角度来看,不需要调用 memset() 是由于程序中缓冲区不再进一步使用,这样移除该调用并不会影响其行为。所以我们的隐私数据仍未清理,这是非常糟糕的。
现在让我们理解更深入些。看一下当我们在动态内存中使用 malloc 函数或 new 运算符分配数据时,数据会发生什么变化。
让我们修改之前的 malloc 代码:
#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; }
我们在发布版本中测试,由于调试版本含有所有的调用在我们希望它们调用的时候。在 Visual Studio 2015 编译后,我们得到以下汇编代码:
.... 000000013FBB1021 mov rcx, qword ptr [__imp_std::cin (013FBB30D8h)] 000000013FBB1028 mov rbx,rax 000000013FBB102B lea rdx,[rax+8] 000000013FBB102F call std::operator>><char,std::char_traits<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 ....
Visual Studio这次做得很好:它按预期清理了数据。但是其他的编译器呢?让我们尝试 gcc 版本5.2.1,和 clang ,版本3.7.0。
我修改一下代码来适应 gcc 和 clang ,添加一些代码打印输出清理之前和之后分配内存块的内容。在内存释放后,将指针指向内存块的内容输出,但在实际的程序中不能这样做,因为你永远不知道应用程序将如何响应。然而,在这个实验中,我可以自由地使用这种技术。
.... #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 生成的汇编代码:
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 中工作的:
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() 调用为了栈和应用程序的动态内存工作优化的缘故。最后,让我们看看在使用新的操作符分配内存时,编译器将如何响应。
再次修改代码:
#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; }
Visual Studio 中清理内存,正如预期一致:
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 编译器同样决定省去清理函数:
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,在这种情况下,它选择减掉“不必要”的函数来优化我们的代码:
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 函数也可以使用。此外,如果需要的话,你可以实现自己的安全功能,网上可以找到许多实例和指南。这里列举出一些。
解决方案 No. 1 。
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; } Solution No. 2. void secure_zero(void *s, size_t n) { volatile char *p = s; while (n--) *p++ = 0; }
一些程序员甚至进一步创建函数,填充数组中的伪随机值,并有不同的运行时间,以阻止基于实际测量的攻击。实现方法同样可以在网络中搜索到。
PVS Studio 静态分析器可以检测我们这里讨论的数据清理错误,并使用有关该问题的 诊断程序 V597。这篇文章作为一个扩展的解释,讲述为什么这个诊断是重要的。不幸的是,许多程序员倾向于认为分析器“选择”他们的代码,实际上没有什么可以担心的。嗯,那是因为他们在代码调试器中看到自己的 memset() 调用完整,而忘记了他们所看到的仍然只是一个调试版本。
*原文:[ Roman Fomichev blog] [1] ,小番番编译,转自须注明来自FreeBuf黑客与极客(FreeBuf.COM)