プライベートデータを安全に消去する



多くの場合、プログラムは個人データを保存する必要があります。 例:パスワード、キー、およびそれらの派生物。 非常に頻繁にこのデータを使用した後、攻撃者がそれらにアクセスできないように、トレースからRAMをクリアする必要があります。 この記事では、これらの目的でmemset()関数を使用できない理由について説明します。



memset()



memset()を使用してメモリを上書きするプログラムの脆弱性について説明した記事をすでに読んでいる場合があります。 しかし、 memset()の誤用のすべての可能なケースを完全に開示しているわけではありません。 スタックで作成されたバッファのフラッシュだけでなく、動的メモリに割り当てられたバッファでも問題が発生します。



スタック



まず、スタックで作成された変数を使用して、上記の記事のケースを検討します。



パスワードで動作するコードを書きましょう:

#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; std::cin >> data.m_pswd; doSmth(data); memset(&data, 0, sizeof(PrivateData)); return 1; } int main() { funcPswd(); return 0; }
      
      





この例は非常にarbitrary意的であり、完全に合成的です。



デバッグバージョンをビルドし、デバッガーでそのようなコードを実行すると(Visual Studio 2015を使用しました)、すべてが正常に動作していることがわかります。 パスワードと計算されたハッシュは、使用後に消去されます。



Visual Studioデバッガーの下のアセンブラーコードを見てみましょう。

 .... 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()の呼び出しを観察します。この関数は、使用後にプライベートデータをクリアします。



これは完了したように見えますが、いや、コード最適化を備えたリリースバージョンをビルドしてみましょう。 デバッガーで取得したものを見てみましょう。

 .... 000000013F7A1035 call std::operator>><char,std::char_traits<char> > (013F7A18B0h) 000000013F7A103A lea rcx,[rsp+20h] 000000013F7A103F call doSmth (013F7A1170h) return 0; 000000013F7A1044 xor eax,eax ....
      
      





ご覧のとおり、 memset()関数呼び出しに対応するすべての命令が削除されます。 コンパイラーは、データが使用されなくなったため、データを消去する関数を呼び出すことは意味がないと考えました。 これは間違いではなく、コンパイラの正当な動作です。 言語の観点からは、バッファはそれ以上使用されないため、 memset()呼び出しは不要です。 その場合、 memset()呼び出しを削除してもプログラムの動作には影響しません。 したがって、私たちの個人データはメモリから削除されません。これは非常に悪いことです。





さあ、もっと深く潜りましょう。 malloc関数またはnew演算子を使用して、動的メモリに割り当てられるデータに何が起こるかを確認します。



mallocで動作するようにコードを変更します。

 #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; }
      
      





デバッグではすべての呼び出しがその場所にあるため、リリースバージョンを確認します。 Visual Studio 2015でコンパイルした後、アセンブラーコードを見てみましょう。

 .... 000000013FBB1021 mov rcx, qword ptr [__imp_std::cin (013FBB30D8h)] 000000013FBB1028 mov rbx,rax 000000013FBB102B lea rdx,[rax+8] 000000013FBB102F call std::operator>><char,std::char_traits<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 ....
      
      





ご覧のとおり、この場合、Visual Studioを使用するとすべて問題なく動作し、データクリーニングが機能します。 しかし、他のコンパイラが何をするのか見てみましょう。 gccバージョン5.2.1とclangバージョン3.7.0を使用してみましょう。



gccclangの場合 、ソースコードをわずかに変更し、割り当てられたメモリの内容のリストをメモリのクリーニング前とクリア後に追加しました。 メモリーを解放した後、ポインターに従って内容を印刷しました。 実際のプログラムでは、この場合にプログラムがどのように動作するかは完全に不明であるため、これは実行できません。 しかし、実験のために、私は自分にそのような自由を許しました。

 .... #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コンパイラーによって作成されたアセンブラーコードフラグメント:

 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を確認します。

 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を使用してメモリを割り当てるときのコンパイラの動作を確認しましょう。



コードを再度変更します。

 #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; }
      
      





Visual Studioはメモリを綿密にクリーンアップします。

 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コンパイラは、メモリをクリアするためにコードを残すことも決めました。

 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はコードを再度最適化し、「不要な」機能を削除することにしました。

 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関数があります。 必要に応じて、独自の安全な関数を作成できます。 インターネットには、その作成方法の例が数多くあります。 以下にいくつかのオプションを示します。



オプションN1

 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; }
      
      





オプションN2

 void secure_zero(void *s, size_t n) { volatile char *p = s; while (n--) *p++ = 0; }
      
      





さらに進んで、擬似乱数値を配列に入力する関数を作成し、同時に異なる時間に動作して、時間測定に関連する攻撃を複雑にします。 それらの実装はインターネットでも見つけることができます。



おわりに



PVS-Studio静的アナライザーは、このようなエラーを検出できます。 V597診断を使用して問題の状況を通知します。 この記事は、この診断が重要である理由の詳細な説明として書かれたものです。 残念ながら、多くのプログラマーは、アナライザーがコードで「障害を検出」し、実際には問題はないと考えています。 結局のところ、プログラマーはデバッガーでmemset()関数呼び出しを見て、これがデバッグバージョンであることを忘れています。





この記事を英語圏の聴衆と共有したい場合は、翻訳へのリンクを使用してください:Roman Fomichev。 個人データの安全な消去



All Articles