• 文章
  • 安全地清除私有資料
作者:
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() 呼叫完好無損,卻忘記了他們看到的仍然只是一個除錯版本。