メモリ内にパスワードが残ってしまう話
パスワードを格納したバッファをクリアしたつもりが実際にはクリアされず、パスワードが漏洩してしまう可能性についてお話しします。
動作確認環境
- Windows 10 Home 21H2, 64bit
- Visual Studio Community 2019
- WinDbg
パスワードが漏洩するパターン
次のプログラムを準備します。
// EnterPassword.c
#include <stdio.h>
#include <conio.h>
#include <windows.h>
int main()
{
char password[32];
// バッファのアドレスを表示(調査用)
printf("password バッファのアドレス : %p\n", password);
// ユーザーが入力したパスワードをバッファに格納
printf("パスワードを入力してください : ");
scanf("%32s", password);
// バッファの内容を表示
printf("パスワードは %s です。\n", password);
// パスワード漏洩防止のためバッファの内容をクリア
memset(password, 0, sizeof(password));
// 例外を発生させる。
// 設定にもよるが、ダンプファイルが生成される。
*((char *)NULL) = 0;
return 0;
}
処理の概要は次の通りです。
- ユーザー入力したパスワードを password という名前のバッファに格納。
- password バッファの内容を表示。
- password バッファの内容を memset 関数を使ってクリア。
- 例外を発生させ、ダンプファイルを生成する。ここでは意図的に例外を発生させているが、プログラムのバグによって例外が発生したと想定。
このプログラムを「cl /O2」でコンパイルします。「/O2」は「最大限の最適化」の意味です
>cl /O2 EnterPassword.c
>EnterPassword.exe
password バッファのアドレス : 00A0FBC0
パスワードを入力してください : MySecretPassword
パスワードは MySecretPassword です。
★ ここで例外が発生してダンプファイルが生成される
>
生成されたダンプファイルを WinDbg で開き、password バッファの内容を見てみます。
0:000> da 00A0FBC0
00a0fbc0 "MySecretPassword"
バッファを memset 関数でゼロクリアしたはずなのに、「MySecretPassword」という文字列がそのまま残っています。故障解析などのためにダンプファイルが他者の手に渡ると、パスワードが漏洩することになります。
バッファがクリアされなかったのは、コンパイラの最適化が原因です。「memset 関数で password バッファをクリアしても、その後 password バッファが使われることはないのだから、クリアの処理は無駄である」との判断で、memset 関数の呼び出しが省略されてしまったわけです。
ZeroMemory ではどうか
C 言語の memset 関数ではなく、Windows API の ZeroMemory 関数でバッファをクリアするとどうなるでしょうか。
さきほどのプログラムの 1 行を書き換え、memset を ZeroMemory にします。
【変更前】
memset(password, 0, sizeof(password));
↓
【変更後】
ZeroMemory(password, sizeof(password));
コンパイルし、実行し、パスワードとして「Confidential」と入力します。
>cl /O2 EnterPassword.c
>EnterPassword.exe
password バッファのアドレス : 0133F8B4
パスワードを入力してください : Confidential
パスワードは Confidential です。
★ ここで例外が発生してダンプファイルが生成される
>
生成されたダンプファイルを開き、password バッファの内容を見てみます。
0:000> da 133f8b4
0133f8b4 "Confidential"
ダメです。バッファ内に「Confidential」がそのまま残っています。
SecureZeroMemory を使う
確実にバッファをクリアしたいというニーズに対応するため、Windows API には SecureZeroMemory という関数が用意されています。これを使ってみましょう。
最初のプログラムの 1 行を書き換え、memset を SecureZeroMemory にします。
【変更前】
memset(password, 0, sizeof(password));
↓
【変更後】
SecureZeroMemory(password, sizeof(password));
コンパイルし、実行し、パスワードとして「SpecialWord」と入力します。
>cl /O2 EnterPassword.c
>EnterPassword.exe
password バッファのアドレス : 00F9FA44
パスワードを入力してください : SpecialWord
パスワードは SpecialWord です。
★ ここで例外が発生してダンプファイルが生成される
>
ダンプファイルを開き、password バッファの内容を見てみましょう。
0:000> da f9fa44
00f9fa44 ""
0:000> db f9fa44 L20
00f9fa44 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
00f9fa54 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
バッファは無事にクリアされています。期待通りです。
SecureZeroMemory の実体は何か
なぜ SecureZeroMemory 関数を使うとバッファが確実にクリアされるのでしょうか。
Windows のヘッダファイルを追うと、SecureZeroMemory 関数の実体はおおよそ次のような関数であることが分かります。
__forceinline
void *MySecureZeroMemory(void *ptr, unsigned int cnt)
{
volatile char *vptr = (volatile char *)ptr;
while (cnt) {
*vptr = 0;
vptr++;
cnt--;
}
return ptr;
}
while ループを使ってバッファをクリアしていますが、重要なのは、通常のポインタを volatile 型のポインタに定義し直している点です。
volatile 型のポインタにすることで、「このメモリは揮発性なので(時々刻々値が変わるので)、勝手に処理を省略せず、書かれたとおりに動作するように」とコンパイラに指示していることになります。その結果、律儀にバッファがクリアされます。
試しに SecureZeroMemory 関数内の volatile を削除して通常のポインタに戻したところ、
【変更前】
volatile char *vptr = (volatile char *)ptr;
↓
【変更後】
char *vptr = ptr;
memset 関数や ZeroMemory 関数を呼び出したときと同様、ゼロクリアの処理が省略され、password バッファの内容が残存しました。
まとめ
バッファの内容を確実に消したいときは、memset 関数や ZeroMemory 関数ではなく、SecureZeroMemory 関数を使いましょう。
ちょっと待った
SecureZeroMemory 関数により、password バッファに格納されていたパスワードは確かにクリアされます。しかし実は、ダンプファイル内のほかの場所にパスワードが残存しています。ダンプファイル全体から「SpecialWord」を検索してみましょう。
0:000> s -a 0 L?7FFFFFFF "SpecialWord"
00f9e03d 53 70 65 63 69 61 6c 57-6f 72 64 20 82 c5 82 b7 SpecialWord ....
010a062d 53 70 65 63 69 61 6c 57-6f 72 64 20 82 c5 82 b7 SpecialWord ....
010a3f48 53 70 65 63 69 61 6c 57-6f 72 64 0a 0a 00 00 00 SpecialWord.....
3 箇所も残っています。これでは SecureZeroMemory 関数を使った意味がほとんどありません。(続く)