プログラマーの理想と現実

第6回 メモリ破壊バグ

2015.01.07

この記事は、『UNIX Magazine』2004年2月号(2004年1月17日発売)に掲載された同名記事の初稿(著者から編集部に提出したもの)を元に、Web掲載用に一部を修正したものです。10年以上前に執筆したものなので、現在のUNIXを取り巻く環境とは色々と異なることがありますが、プログラミングに対する心構えとしては現在でも通用するものと思い、再掲してみることにしました。

・・・・・・・・・・

今回は、一般的なC言語プログラムに入り込むバグのうち、かなり厄介な部類に入る メモリ破壊バグ について考えてみよう。

メモリ破壊バグとは?

メモリ破壊バグとは、配列に対する指標値の誤りや、ポインタ変数の誤りなどにより、意図しないメモリ領域を書き換えてしまい、以降のプログラム動作がおかしくなる、というものである。

頻繁に目にするのは、malloc()でメモリブロックを確保する際にサイズの計算を誤り、確保した領域をはみ出してデータを書き込んでしまう、というケースである。文字列のコピーを作成する際、終端のヌル文字用にサイズを+1しておくのを忘れたりすると、簡単に起きる。あるいは、自動変数として固定サイズのバッファを取ったものの、データを書き込む時にはみ出してしまった、というケースも良く見受けられる。

これらの問題が何故”厄介な部類”に入るのかと言うと、誤りの結果としての動作が決定論的でなく、因果が時間的・空間的に離れてしまって直接的な関連を見い出しにくいからである。

たとえば、malloc()で確保した領域を踏み外して書き込むと、かなりの確率で以降のどこかのmalloc()またはfree()でセグメント違反が起きる。セグメント違反が起きる箇所は、踏み外しをおこなった箇所とソースコードのスコープ的には何の関連もない。場合によっては、標準ライブラリの内部で落ちたりする。また、セグメント違反で落ちるまでの時間もずいぶんかかる。

UNIXでは、C言語プログラムでセグメント違反が起きるとcoreファイルが生成され、デバッガを使って違反の内容をチェックできる。ヌルポインタへの書き込みなどの単純な誤りであれば、ソースレベルデバッガで変数などの内容を確認すれば、誤りを発見できる。しかし、メモリ破壊バグでは、セグメント違反が起きる時間も場所も、原因である領域踏み外し点とは関連がないため、デバッガで状況を見ても簡単に原因を特定することはできない。ライブラリ内で落ちていたりすると、ソースコードも無いし、お手上げである。

また、自動変数として確保した領域を踏み外して書き込むと、関数から戻る時にセグメント違反が起きるか、関数の呼び出し元に戻ってからセグメント違反が起きる確率が高い。このケースでは、ソースコードのスコープ的にはかなり近いところで落ちることになるが、デバッガで状況を調べようとしても、実行位置も判断できず、変数の内容も参照できないことが多く、結局お手上げに近い状態になってしまう。

どうやって判断するのか

厄介なメモリ破壊バグを仕込んでしまうと、初級者には現象把握も原因究明もほとんど無理である。だからこそ、配列アクセスに指標チェック機能を付けたり、ポインタ変数を無くしてメモリを直接アクセスすることを禁止するなど、初級者でも確実にプログラムが開発できるように、言語処理系が改良・開発されているわけである。[1]

初級者から、中級者・上級者とステップアップしてゆくには、厄介なメモリ破壊バグにも適切に対応できる必要がある。そのためには、まず状況を観察して「これはメモリ破壊バグの可能性が高い」と判断できるようになるのが第一歩だ。「では、ここで門外不出の秘伝を!」と言えれば良いのだが、残念ながら私には持ち合わせがない。少なくとも、先に示した2つの状況

  1. malloc()で確保した領域を踏み外した場合、以降のどこかのmalloc()または free()でセグメント違反が起きる確率が非常に高い。
  2. 自動変数の領域を踏み外した場合、スタックが壊れるのでデバッガでスタックトレースが取れなくなる確率が非常に高い。

が観測された場合は、メモリ破壊バグを仕込んでしまったと判断しても良いだろう。もちろん、これらの現象は単に確率が高いだけであって、他にも色々と不可解な現象が起りうる。それらについては、多くのケースを実体験して感覚的なものを養うしかないだろう。

なぜそうなるのか

メモリ破壊バグの可能性が高いと判断したら、次は実際に踏み外して書き込んでいる箇所を突き止めなければならない。しかし、元々セグメント違反などの現象が起きる箇所と、踏み外している箇所は関連がないので、突き止めるのもそう簡単ではない。ソースコードをしらみ潰しに当たるという手法も最終兵器としては大変に有効であるが、まずはもう少し効率の良い方法を模索する必要がある。そのためには、なぜセグメント違反が起きるのか、つまり踏み外して書き込んだ後、一体何がどうなってセグメント違反が起きたのか、を把握することが肝要である。

malloc()で確保した領域のケース

malloc()は、複数の独立した任意のサイズのメモリブロックを確保し、プログラムに提供する機能を有するライブラリである。確保したメモリブロックは、後にfree()で解放することで再利用することもできる。この機能を実現するため、mallocライブラリは内部でメモリブロックのサイズや位置情報を何らかの形で管理している。古いUNIXで実装されていた、非常に簡単な方法は次の通りである(多少単純化してある)。

  • プログラムに提供するメモリブロックの直前に、当該メモリブロックの情報を保存するヘッダ構造が置かれる。
  • ヘッダ構造には、当該メモリブロックのサイズ、空きブロックかどうかを示すフラグ、および次のブロックのヘッダ構造へのポインタが含まれる。
  • malloc()が呼び出されると、ブロックの先頭を示す広域変数からブロックのヘッダ構造を順次アクセスし、要求サイズが収まる最初の空きブロックを見つける。当該ブロックを要求サイズと余りに分割し、余り部分は新たにヘッダ構造を作り単独のブロックとする。当該ブロックのヘッダ情報を書き換え、ヘッダ構造の直後のアドレスを返す。
  • free()が呼び出されると、引数として渡されたアドレスの直前にヘッダ構造があるものとし、ヘッダのポインタから次のブロックの位置を知り、次のブロックも空きブロックである場合、二つのブロックを合体して一つのブロックにする。

ポイントは、malloc()で確保するメモリ領域の前後には、管理用のヘッダ構造があり、その中に”次のブロックへのポインタ”が含まれている点である。確保したブロックの後ろに踏み外して書き潰すと、次のブロックのヘッダ構造が、前に踏み外して書き潰すと、当該ブロックのヘッダ構造が壊されてしまう。その後、malloc()またはfree()が呼び出されると、ヘッダ構造のポインタを手繰る処理が行われる。そこで、ポインタが書き潰されていると、予期せぬアドレスにアクセスしてしまい、セグメント違反が起きる。

ただし、踏み外して書き潰したあと最初に呼び出されるmalloc()やfree()でセグメント違反が起きるのかというと、そういうわけではなく、malloc()では書き潰してヘッダが壊れたブロックより前に十分な空きブロックが見つかればセグメント違反は起きず、またfree()では書き潰してヘッダが壊れたブロックを解放する時に限ってセグメント違反になる。これが、書き潰してからセグメント違反が起きるまでに時間がかかる理由である。

なお、最近のUNIXではmallocライブラリにも多くのバリエーションがあり、より高度な管理方法を実装しているものも多い。そういうmallocライブラリを使っている場合、最終的にセグメント違反で落ちるかどうか、あるいはその確率なども当然異なってくる。

自動変数で確保した領域のケース

ごく普通のC言語処理系とプロセッサの組み合わせでは、自動変数はスタック上に確保される。スタックは、C言語のような関数型再入可能手続言語では、次のような情報を保持するために使用される。

  1. 関数への引数
  2. 関数呼び出し時の戻り番地
  3. 呼び出し前のフレームポインタの待避
  4. 関数内で使用するレジスタの待避
  5. 関数内で確保される自動変数の領域

これらの情報は、関数を局所的に実行するために必要十分な情報であり、まとめて スタックフレーム と呼ばれる。関数への引数も、関数内で確保する変数も、スタックフレーム内に確保されるため、同じ関数を何度も再帰的に呼び出すことが可能になるわけである。

スタックフレームには、上記の情報がそのままの順序で格納される。[2] 典型的な関数呼び出しのシーケンスは、図1から図2のようになる(ニーモニックは概念的なものである) [3]

関数内では、フレームポインタからのインデックス付き間接参照を用いて、引数と自動変数にアクセスする。フレームポインタは、ちょうど自動変数が開始する位置を指しており、ここから上が自動変数領域、ここから下が引数領域になる。ただし、引数領域と自動変数領域の間には、戻り番地と、呼び出し側関数でのフレームポインタを待避したもの(場合によるとレジスタ内容の待避も)が挟まっている。

自動変数の領域を踏み外して書き潰してしまうと、戻り番地または待避したフレームポインタが壊される可能性がある。戻り番地が書き潰されてしまうと、関数から戻る時点で予期せぬアドレスにジャンプすることになり、セグメント違反になる。運良くアドレスとしては有効な値に書き潰した場合は、セグメント違反にはならないが、無効命令トラップやその他の不都合が起きる。待避したフレームポインタを書き潰した場合は、呼び出し側関数に戻った後、引数や変数へのアクセスでセグメント違反が起きる可能性が高い。

いずれの場合も、スタックフレームの内容が壊されているため、デバッガで正しい関数呼び出し履歴が表示される可能性はかなり低い。戻り番地を書き潰した場合は、プログラムカウンタもおかしな値になっているため、どこを実行していたのかもわからなくなってしまう。

というところで、今回は紙幅が尽きた。続きはまた次回に。

[1]だから、C言語ではなくもっと安全な高級言語を使って開発を行うべきだ、という議論はここでは避けておく。少なくとも、職業プログラマである私に使用言語の選択権があることは希であるし、また、どのような環境・条件でも正しいプログラムを作成するのが職業プログラマとして義務であるとの想いもある。
[2]人によってはスタック成長方向が逆と感じるかもしれないが。
[3]ただし、煩雑になるのでレジスタの待避と復元は示していない。

注釈

  • 初稿に由来する表現の差異、ならびにWeb掲載に伴う修正のため、雑誌掲載の内容とは一部文章等が異なることがあります。
  • 転載許諾を頂いた株式会社KADOKAWAアスキー・メディアワークスブランドカンパニーならびに旧『UNIX Magazine』編集部の皆様に深く御礼を申し上げます。

著者プロフィール

tom

当社設立直後に入社して約30 年、UNIX の移植、日本語化、デバイスドライバ開発、周辺機器ファームウェア開発などに継続的に携わり、現在も現役でUNIX 系OS の移植、改造などの開発業務を行う。社内でもっともプログラムを書いている人の一人。代表取締役社長。

記事一覧Index