AddressSanitizerを使って解放済みメモリアクセスを検知する

C++,Clang,LLVM,MSVC,Visual Studio

C++で開発をしていると、解放済みのメモリに対して操作してしまうバグは多いです。

人間なのでバグを埋め込む事はよくある事です。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);
}
assert(data2[0] =2); の失敗
NOTICE

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);
}
Address Sanitizer Error: Use of poisoned memory.

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の基本的な利用方法はこちらの記事で紹介しています。

Sample

samples/AddressSanitizer at master · meltybk/samples