第7章 データ漏えい対策
メモリクリア失敗対策

メモリ上の秘密データを消去する目的で memset() 等のメモリクリア用ライブラリ関数を使用する場合、使用するコンパイラによっては最適化によってメモリ消去処理が削除されてしまうことがある。その代表的な例として、Microsoft の Visual C++ が挙げられる。

Visual C++ の最適化オプション

最適化オプションの内容によっては、Visual C++ はコード生成において、参照されない領域に対する memset() 呼び出しを削除する。

Visual Studio 2005 の最適化は、主に下記のような指定が可能である。

このうち、実行速度(/O2)あるいは最大限の最適化(/Ox)を指定すると、参照されない領域に対する memset() 呼び出しのコードが除去される。

最適化により memset() 呼び出しが除去されてしまう問題

Windows Visual Studio 2005 の環境にて、コンパイラの最適化により処理が除去される例を次に示す。

main () {
   char secret[256];

   if (get_secret (secret,  sizeof (secret))) {
      // secretに関する何からの処理...
   }

   // 作業領域として使用したメモリのクリア
   memset(secret, 0, sizeof (secret));
}

get_secret (char *buff, int size)
{
   strncpy (buff, "SECRET_DATA", size);
}

このソースコードは、最適化コンパイルを行うと次のようなアセンブラコードとなり、実行される。(行頭が 004010xx となっている行が、アセンブラコードである。)

   main () {
00401000  sub   esp,104h 
00401006  mov   eax,dword ptr [___security_cookie (403000h)] 
0040100B  xor   eax,esp 
0040100D  mov   dword ptr [esp+100h],eax 
      char secret[256];

      if (get_secret (secret,  sizeof (secret))) {
00401014  push  100h 
00401019  lea   eax,[esp+4] 
0040101D  push  offset string "SECRET_DATA" (4020E4h) 
00401022  push  eax  
00401023  call  strncpy (401052h)    ← get_secret() のインライン処理が
                                        展開されている
         // secretに関する何からの処理...
      }

      // 作業領域として使用したメモリのクリア
      memset(secret, 0, sizeof (secret));
                                     ← ※1) memset に相当する処理が
                                             生成されない
   }
00401028  mov   ecx,dword ptr [esp+10Ch] 
0040102F  add   esp,0Ch 
00401032  xor   ecx,esp 
00401034  xor   eax,eax 
00401036  call  __security_check_cookie (401042h) 
0040103B  add   esp,104h 
00401041  ret

上記は、ソースコードとアセンブラコードを対応させたリストである。この中の※1)の部分にはソースコードに対応するアセンブラコードが無いことがわかる。
本来ここでメモリクリア処理を行う予定なのだが、この部分が削除されてしまっている。そのため、最後の ret 命令の時点でも秘密のデータ secret は、クリアされずに残ってしまっている。

このコンパイルしてできたアセンブラコードで、※1)にあるはずの処理が削除されてしまったのは、関数内の変数 secret を memset() 呼び出しで 0 クリアしようとしているが、この変数がその後使われないためである。このためコンパイラは、この 0 クリア処理は省略できるものとして除去してしまうのである。

メモリクリア失敗
図7-6: メモリクリア失敗

最適化によってメモリクリア処理が除去されないための対策

(1) 最適化の抑止

メモリクリアのためのmemset()等が削除されてしまうのは、コンパイル時に最適化が行われてしまうためである。よって、対策としては、この最適化自体を行わない、あるいは抑止することが考えられる。この最適化の抑止をするには、コンパイルオプションを指定することで可能である。
しかし、コンパイルオプションに指定してしまうとソースファイル全体に対する最適化がすべて抑止されてしまう。よって、最適化で除去されてしまう部分に対して最適化を抑止できることが望ましい。このように一部に対して最適化を抑止するには、次のような方法がある。

1) SecureZeroMemory を使用する

Visula C++ に含まれる SecureZeroMemory() は、WinBase.h に定義されており、最適化しても除去されないように、volatile を使用して実現されている。

PVOID SecureZeroMemory(
   PVOID ptr,      // 0クリアするメモリの先頭アドレス
   SIZE_T cnt      // 0クリアするメモリのサイズ
);

次の例のようにすると、最適化のオプションが働いてもメモリ secret はクリアされる。

#include <windows.h>
#include <winbase.h>

void test2 () {
   char secret[256];

   if (get_secret (secret,  sizeof (secret))) {
         // secretに関する何からの処理...
   }

   // 作業領域として使用したメモリのクリア
   SecureZeroMemory (secret, sizeof (secret));
}

2) volatile を使用する

volatile 宣言されたポインタは、ポインタのアドレスに対する書き込みは、最適化されずに、実際のメモリに対して書き込むようにするという指定である。
SecureZeroMemoryでもvolatile を使用しており、自前でvolatileを使用した処理を記述してもSecureZeroMemoryと同じことができる。

例 volatile を使用して最適化を抑止する
test3 () {
   char secret[256];
   volatile char *p;      ←volatileの宣言する
   int i;

   p = (volatile char *)secret;
   if (get_secret(p,  sizeof (secret))) {
         // secretに関する何からの処理...
   }

   // 作業領域として使用したメモリのクリア
   for (i = 0; i < sizeof (secret); i++) {
      *p++ = 0;           ←最適化しても除去されない
   }
}

3) "#pragma optimize" による最適化抑止

C のソースコード中の "#pragma" の記述は、コンパイラへの指示となり、パラメータ optimize で最適化の on, off を指定することができる。
つまり、SecureZeroMemoryやvolatileによるメモリクリア処理を行っていない場合でも、optimizeのon, offで指定された範囲を最適化対象から外すことがきて、メモリクリア処理が削除されずに済む。

次の例では、 #pragma optimize により、コンパイラに対して関数 test1() は、最適化をしないように指示している。
もし最適化されると、リターンの直前の memset() が除去され、secret の内容がメモリ(スタック上)に残ってしまう。

例 #pragma optimize により最適化を抑止する
#pragma optimize("", off)      // ここから最適化をすべて抑止する
void test1 () {
   char secret[256];

   if (get_secret (secret,  sizeof (secret))) {
         // secretに関する何からの処理...
   }

   // 作業領域として使用したメモリのクリア
   memset(secret, 0, sizeof (secret));  ←《最適化すると除去されるコード》
}
#pragma optimize("", on)       // ここまで(最適化をデフォルトに戻す)

(2) 後続に変数参照があれば memset() は除去されない

もし、最適化の抑止の対策が行われていなくても、memset() 呼び出しの後、クリア対象となったポインタ変数が別の関数呼び出しに使用されているような場合、memset() 呼び出しは除去されない。

例えば、malloc() と free() の呼び出しに挟まれている memset() 呼び出しは除去されない。

例 malloc() により確保したエリアを、開放前にクリアする
#define MALLOC_SIZE	16

void func1 () {
   char *p;

   p = (void *)malloc(MALLOC_SIZE);
   if (p == (char *)NULL) {
      // エラー処理(省略)
      return;
   }
   if (get_secret(p, MALLOC_SIZE)) {
         // pに関する何からの処理...
   }

   // 作業領域として使用したメモリのクリア
   memset(p, 0, MALLOC_SIZE);   // 最適化されずにクリア処理される (*1)
   free (p);
}

 (*1) Windowsの場合、malloc したサイズを _msize() により取得することが
      可能。下記のようにすることもできる。
   memset(p, 0, _msize(p));

参考:gcc での最適化

C/C++コンパイラとして Microsoft の Visual C++ のほかに Unix系およびGNU/Linux系で広く利用されているひとつに gcc がある。
この gcc を利用する場合は、コンパイル時に最適化を行っても memset() は除去されずに処理される。

まとめ

Microsoft の Visual C++ においてmemset() 等を用いてメモリクリアを行う際には、最適化によってメモリクリア処理が削除されないために、次の対策を施す。

その他の処理系においても同様に、最適化によってメモリクリア処理が削除される懸念がある。