作者: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() 呼叫完好無損,卻忘記了他們看到的仍然只是一個除錯版本。