メモリ内にパスワード残ってしまう話(続き)

前回、password バッファを SecureZeroMemory() でクリアしているにもかかわらず、ダンプファイル内にパスワードが残ってしまうという話をしました。なぜなのか、追っていきます。

動作確認環境

  • Windows 10 Home 21H2, 64bit
  • Visual Studio Community 2019
  • WinDbg

プログラム再掲

前回使ったプログラムを再掲します。

password バッファを SecureZeroMemory() でクリアしていますが、メモリ内にパスワードが残存し、その結果ダンプファイル内にもパスワードが残存してしまいます。

// 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);

    // パスワード漏洩防止のためバッファの内容をクリア
    SecureZeroMemory(password, sizeof(password));
    
    // 例外を発生させる。
    // 設定にもよるが、ダンプファイルが生成される。
    *((char *)NULL) = 0;

    return 0;
}

調査モードでプログラム実行

調査のため「/Zi」を付加して再コンパイルします。「/Zi」は「デバッグ情報を有効にする」の意味です。

>cl /O2 /Zi EnterPassword.c

また、ヒープの確保元を追うため、マイクロソフトの「gflags.exe」ツールを使って、本プログラムに対する[Create user mode stack trace database]を ON にします。

この状態でプログラムを実行し、パスワードとして「LeakedPassword」と入力します。

>EnterPassword.exe

C:\tp\EnterPassword>EnterPassword
password バッファのアドレス : 0043F980
パスワードを入力してください : LeakedPassword
パスワードは LeakedPassword です。
★ ここで NULL ポインタに書き込みをして例外発生。ダンプファイルが生成される。

ダンプファイルが生成されたので、WinDbg の [File]-[Open Crash Dump] で開き、「LeadedPassword」 を検索します。

0:000> s -a 0 L?-1 "LeakedPassword"
0043df79  4c 65 61 6b 65 64 50 61-73 73 77 6f 72 64 20 82  LeakedPassword .
030717f5  4c 65 61 6b 65 64 50 61-73 73 77 6f 72 64 20 82  LeakedPassword .
03072800  4c 65 61 6b 65 64 50 61-73 73 77 6f 72 64 0a 0a  LeakedPassword..

3 箇所にパスワードが残ってしまっています。

メモリの内容を調査

どこにパスワードが残るのでしょうか。

最初のアドレスの用途を調べます。

0:000> !address 0043df79
Usage: Stack

スタックです。プログラムの実行時にスタックに一時的にパスワードが積まれたと思われます。

2 番目のアドレスの用途を調べます。

0:000> !address 030717f5
Usage: Heap

ヒープです。
だれが確保したのか調べます。

0:000> !heap -p -a 30717f5
    address 030717f5 found in
    _HEAP @ 3060000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        030717d0 0203 0000  [00]   030717e8    01000 - (busy)
        77ca7934 ntdll!RtlpCallInterceptRoutine+0x00000026
        77c16e7c ntdll!RtlpAllocateHeapInternal+0x0000108c
        77c15dde ntdll!RtlAllocateHeap+0x0000003e
        4b52ce EnterPassword!_malloc_base+0x00000038
        4b8ef5 EnterPassword!__acrt_stdio_begin_temporary_buffering_nolock+0x0000006b
        48c499 EnterPassword!<lambda_0be4ab1c2a6918fda4e39227d83ea893>::operator()+0x00000023
        486311 EnterPassword!__crt_seh_guarded_call<int>::operator()<<lambda_c29ee0499b841886b80d843682cc403a>,<lambda_0be4ab1c2a6918fda4e39227d83ea893> &,<lambda_5a3ed3da325c8ea037a470278c0f2d16> >+0x00000027
        49b420 EnterPassword!__stdio_common_vfprintf+0x00000081
        477ed7 EnterPassword!printf+0x00000027
        7771fa29 kernel32!BaseThreadInitThunk+0x00000019
        77c37a9e ntdll!__RtlUserThreadStart+0x0000002f
        77c37a6e ntdll!_RtlUserThreadStart+0x0000001b

printf 関数が内部で確保したヒープにパスワードが格納されたことが分かります。

3 番目のアドレスの用途を調べます。

0:000> !address 03072800
Usage: Heap

こちらもヒープです。
だれが確保したのか調べます。

0:000> !heap -p -a 3072800
    address 03072800 found in
    _HEAP @ 3060000
      HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
        030727e8 0203 0000  [00]   03072800    01000 - (busy)
          unknown!printable
        77ca7934 ntdll!RtlpCallInterceptRoutine+0x00000026
        77c16e7c ntdll!RtlpAllocateHeapInternal+0x0000108c
        77c15dde ntdll!RtlAllocateHeap+0x0000003e
        4b31b6 EnterPassword!_calloc_base+0x00000047
        4cb17c EnterPassword!__acrt_stdio_allocate_buffer_nolock+0x0000001d
        4cacc2 EnterPassword!common_refill_and_read_nolock<char>+0x00000084
        4bb153 EnterPassword!_fgetc_nolock+0x0000002d
        4a27c5 EnterPassword!__crt_stdio_input::skip_whitespace<__crt_stdio_input::stream_input_adapter,char>+0x00000011
        4a9785 EnterPassword!__crt_stdio_input::input_processor<char,__crt_stdio_input::stream_input_adapter<char> >::process_whitespace+0x0000000f
        4a8ddf EnterPassword!__crt_stdio_input::input_processor<char,__crt_stdio_input::stream_input_adapter<char> >::process_conversion_specifier+0x00000016
        49b97b EnterPassword!__crt_seh_guarded_call<int>::operator()<<lambda_274ecf0a8038e561263518ab346655e8>,<lambda_21448eb78dd3c4a522ed7c65a98d88e6> &,<lambda_0ca1de2171e49cefb1e8dc85c06db622> >+0x00000027
        4ab8f1 EnterPassword!__stdio_common_vfscanf+0x00000081
        477f17 EnterPassword!scanf+0x00000027
        7771fa29 kernel32!BaseThreadInitThunk+0x00000019
        77c37a9e ntdll!__RtlUserThreadStart+0x0000002f
        77c37a6e ntdll!_RtlUserThreadStart+0x0000001b

scanf 関数が内部で確保したヒープにパスワードが格納されたことが分かります。

メモリからパスワードを消す

メモリに残存しているパスワードを消す方法を考えます。

最初のスタックについては、大きなローカル変数をもつダミーの関数を呼び出し、当該ローカル変数の内容をゼロクリアすることで消せそうです。プログラムとしては、次のような感じです。

void ClearStack()
{
    char pcBuf[30000];
    SecureZeroMemory(pcBuf, sizeof(pcBuf));
}

2 番目の、printf 関数が内部で作業用に確保したヒープについては、ダミーの「malloc() → ゼロクリア → free()」を繰り返しても消せそうにありません。というのも、当該ヒープの state が busy となっており、使用中だからです。

少し調べたところ、printf 関数を最初に呼び出したタイミングでヒープが確保され、後続の printf 関数ではそのヒープが使い回されるようでした。そこで、確実性には劣りますが、パスワードを printf() した直後にダミーの文字列を printf() し、ヒープの内容が上書きされるようにします。

    printf("パスワードは %s です。\n", password);
    printf("----------------------------------------------------\n");

3 番目の、scanf 関数が内部で作業用に確保したヒープについても、printf 関数と同様です。
scanf 関数を最初に呼び出したタイミングでヒープが確保され、後続の scanf 関数ではそのヒープが使い回されるようでしたので、ユーザーに不便をしいることになってしまいますが、パスワードの scanf() 直後にダミーの scanf() を依頼し、ヒープの内容が上書きされるようにします。

    printf("パスワードを入力してください    : ");
    scanf("%32s", password);
    printf("同じ長さの * を入力してください : ");
    scanf("%32s", dummy);

以上をまとめると、プログラムは次のようになります。

// EnterPassword.c
#include <stdio.h>
#include <conio.h>
#include <windows.h>

void ClearStack()
{
    char pcBuf[30000];
    SecureZeroMemory(pcBuf, sizeof(pcBuf));
}

int main()
{
    char password[32];
    char dummy[32];

    // バッファのアドレスを表示(調査用)
    printf("password バッファのアドレス : %p\n", password);

    // ユーザーが入力したパスワードをバッファに格納
    printf("パスワードを入力してください    : ");
    scanf("%32s", password);
    printf("同じ長さの * を入力してください : ");
    scanf("%32s", dummy);

    // バッファの内容を表示
    printf("パスワードは %s です。\n", password);
    printf("----------------------------------------------------\n");

    // パスワード漏洩防止のためバッファの内容をクリア
    SecureZeroMemory(password, sizeof(password));
    ClearStack(); // スタックもクリア

    // 例外を発生させる。
    // 設定にもよるが、ダンプファイルが生成される。
    *((char *)NULL) = 0;

    return 0;
}

パスワードが消えたことを確認

それでは修正したプログラムを再コンパイルして実行します。
今回はパスワードとして「HelloWorldJapanTokyo」と入力します。

>cl /O2 EnterPassword.c

>EnterPassword.exe
password バッファのアドレス : 00EFFDC8
パスワードを入力してください    : HelloWorldJapanTokyo
同じ長さの * を入力してください : ********************
パスワードは HelloWorldJapanTokyo です。
----------------------------------------------------

生成されたダンプファイルを WinDbg で開き、「HelloWorldJapanTokyo」を探します。

0:000> s -a 0 L?-1 "HelloWorldJapanTokyo"
0:000>

見つかりません。

「Hello」のみを探したり、「Tokyo」のみを探したり、Unicode で探したりしても見つかりません。

0:000> s -a 0 L?-1 "Hello"
0:000>
0:000> s -u 0 L?-1 "Hello"
0:000>
0:000> s -a 0 L?-1 "Tokyo"
0:000>
0:000> s -u 0 L?-1 "Tokyo"
0:000>

メモリからパスワードの痕跡が消えました。

ライブラリの内部構造に依存するため絶対にパスワードが残らないと断言はできませんが、当初のプログラムよりずいぶん安全性が高まったと思います。