AddressSanitizerを使って解放済みメモリアクセスを検知する
C++でプログラムを開発するとメモリ破壊と呼ばれるバグに遭遇することがあります。
メモリ破壊には解放済みのメモリに対して操作しての操作や、確保したメモリの範囲外へのアクセスといったものがあります。そして、これらのバグは原因の究明が難しいのが特徴です。
人間なのでミスでバグを仕込んでしまうということは普通にありえることなので、バグを検出して素早く修正する為の仕組みが必要になります。
メモリ破壊系のバグの原因究明にはAddressSanitizerと呼ばれる機能を用いると便利です。
AddressSanitizerを使って解放済みのメモリアクセスを効率的に検知する方法をその紹介をします。
解放済みメモリアクセスとは?
解放済みメモリアクセスとは、free or deleteされたメモリに対するアクセスです。エイトでは、Use After Free.と言われています。
こちらはClang AddressSanitizerの紹介ページにあるサンプルコードです。
int main(int argc, char **argv) {
int *array = new int[100];
delete [] array;
return array[argc]; // BOOM
}
new して確保したメモリを delete で解放した後に参照しています。
このサンプルコードはあからさま過ぎて、私はこんなミスなんてしない。と、思うかもしれませんが、実際の開発ではそれなりに多いです。
特にクラスのメンバ変数としてポインタ変数を所持している場合、deleteした後にnullptr代入をせずに、そのまま操作してしまうケースが多いです。
再利用された後のメモリを書き換える
解放済みメモリアクセスが厄介なバグとして有名なのは、アロケーターが再利用したメモリ領域に対してアクセスするバグだからです。
当たり前の事かもしれませんが、解放されたメモリはメモリ確保要求が来た時に再利用されます。
その事を念頭に置きながら、こちらのコードを見ていきましょう。
Windows標準のアロケーターでは、直ぐにはメモリが再利用されなかったので、メモリが再利用されやすいdlmallocを使用しています。
#include "malloc.h"
int main() {
mspace msp = create_mspace(1024 * 1024 * 5, false);
int *data1 = (int *)mspace_malloc(msp, (sizeof(int) * 100));
mspace_free(msp, data1);
int *data2 = (int *)mspace_malloc(msp, (sizeof(int) * 100));
mspace_free(msp,data2);
}
data1 と data2 両方のポインタ変数が持っているアドレス値が同じです。
dlmallocがメモリを再利用している事が分かるかと思います。Windows標準のアロケーターは直ぐにはメモリを再利用しませんでしたが、メモリ領域は限られているのでいつかは再利用されます。
再利用されたメモリに対しての破壊
再利用されたメモリを経由しての解放済みメモリアクセスによるバグを起こしてみます。
通常であれば、 assert(data2[0] == 2);
の処理は通るはずです。しかし、data1[0] = 1;
のコードが解放済みメモリに対して不正な書き込みを行う事で、data2が参照しているメモリの中身を書き換えています。その結果、assert は失敗します。
#include <cassert>
#include "malloc.h"
int main() {
mspace msp = create_mspace(1024 * 1024 * 5, false);
int *data1 = (int *)mspace_malloc(msp, (sizeof(int) * 100));
mspace_free(msp, data1);
int *data2 = (int *)mspace_malloc(msp, (sizeof(int) * 100));
data2[0] = 2;
data1[0] = 1;
assert(data2[0] == 2);
mspace_free(msp,data2);
}
watch式の状態からdata2[0] が 1 に書き換えられているのが分かります。
問題はこの処理は assert が無いと何も問題がないかのように動いてしまう事です。
このプログラムからassertを外して実行すると正常に動作して終了します。
独自アロケーターの問題
AddressSanitizerは解放済みメモリアクセスを検知する為に、標準のアロケーターを置き換えてメモリの再利用を抑制する事で検知しやすくしています。
ですが、今回のサンプルのようにアロケーターを置き換えているとこの効果は望めません。
そんな時の為に、特定のメモリ領域を “poison" 化する機能があります。 poison 化された領域にアクセスするとエラーとなります。
AddressSanitizerManualPoisoning · google/sanitizers Wiki
単純にメモリフィルと同じ要領で free された時に領域を poison 化し、alloc された時に unpoison 化すればいいと思うかもしれませんが、dlmalloc のように再利用率の高いアロケーターでは検出率が低くなります。理由は、サンプルコードで分かる通り、解放されたメモリを poison 化しても、同じメモリを直ぐに再利用されて unpoison 化された後に破壊されるからです。
では、どうすればいいのでしょうか?
AddressSanitizerを使う時は、標準のアロケーターに戻してAddressSanitizerが置き換えるアロケーターに任せるか、mimalloc のような直ぐには再利用しないアロケーターに置き換えるか、free されたメモリが直ぐに再利用されないようにします。
直ぐにはメモリを再利用しないようにする
dlmalloc の時に簡単にメモリが再利用されなくなるようにカスタマイズします。とはいっても、dlmallocのコードは可読性が低いのと、解放したメモリ領域を使ってfree listを構成しているので poison 化しづらいので、外側から手を入れます。
#include "malloc.h"
#include <cassert>
#include <cstdint>
#include <sanitizer/asan_interface.h>
static mspace msp = nullptr;
static constexpr size_t kNumFreeList = 2;
static void* free_list[kNumFreeList];
static uint32_t free_list_index = 0;
extern void *malloc(size_t size) { return mspace_malloc(msp, size); }
extern void free(void *ptr) {
if (free_list_index >= kNumFreeList) {
free_list_index = 0;
for (uint32_t i = 0; i < kNumFreeList; ++i) {
mspace_free(msp, free_list[i]);
}
}
free_list[free_list_index] = ptr;
++free_list_index;
ASAN_POISON_MEMORY_REGION(ptr, mspace_usable_size(ptr));
}
int main() {
msp = create_mspace(1024 * 1024 * 5, false);
int *data1 = (int *)malloc(sizeof(int) * 100);
free(data1);
int *data2 = (int *)malloc(sizeof(int) * 100);
data2[0] = 2;
data1[0] = 1;
free(data2);
}
Poisoned by user: f7
という事で、f7 は ユーザーが任意に posion 化した領域です。 しっかり、不正メモリアクセスを検知出来ていますね。
SUMMARY: AddressSanitizer: use-after-poison D:\samples\AddressSanitizer\UseAfterFree.cpp:36 in main
Shadow bytes around the buggy address:
0x04261e814020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x04261e814030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x04261e814040: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x04261e814050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x04261e814060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x04261e814070: 00 00 00 00 00 00 00 00[f7]f7 f7 f7 f7 f7 f7 f7
0x04261e814080: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x04261e814090: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x04261e8140a0: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 00 00 00 00 00
0x04261e8140b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x04261e8140c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
MSVCの問題点
Visual Studio Community 2019 16.7.5で試しましたが、"sanitizer/asan_interface.h" に include path 標準で通っていません。
プロジェクトのプロパティのVC++ Directory > Include Directories に “$(VC_CRT_SourcePath)" を追加。
更には、#if __has_feature(address_sanitizer) || defined(__SANITIZE_ADDRESS__)
が false で “ASAN_POISON_MEMORY_REGION" マクロが使えないので、プロジェクトのマクロ定義に “SANITIZE_ADDRESS" を追加してやっとビルドできました。
試用段階なので不具合が残っていたようです。
AddressSanitizerなしでは特定するのが難しいバグ
サンプルではメモリが再利用されやすいように同じサイズのメモリを確保・解放していますし、メモリ操作の間には他のコードがないので、原因が分かりやすいです。
ですが、実際に開発するプログラムでは原因となるコードと実際にクラッシュする場所ソースコード的に全く関連がない場合が多く、特定する事が困難です。
解放済みメモリアクセスはAddressSanitizerが無かった頃には原因を特定するのが非常に難しいバグの1つでしたが、今ではAddressSanitizerを使う事で検知する事が容易になりました。
バグの原因は単純なミスが原因であっても、原因を特定するのは困難な事が多いです。
便利なツールは是非とも使っていきましょう。
Address Sanitizerの基本的な利用方法はこちらの記事で紹介しています。