C++はCと互換性がある。 つまり、どれだけC++が進化して安全な書き方ができるようになっても、Cのような書き方ができてしまうということだ。 自分は大学でC++を使ったプロジェクトに2つ関わっているが、その両方でそれが原因の問題が起きている。 大学のプロジェクトは人が定期的に入れ替わってしまう上に、メンバーのスキルもバラバラである。 しかしC++のモダンな書き方を教育する時間はない。 とりあえず手を動かさないと論文が書けないからだ。 というわけで、何年もかけて多くの人の手が加わったC++プロジェクトでは、Cの書き方とC++の書き方が混在してしまう。 この記事では最近見つけたuse-after-freeバグについて書く。 プログラマのスキルに依存せず仕組みで未然に防ぐ賢い方法を知っている人がいれば教えてほしい。
見つけたバグ
研究のために関わっているソフトウェアでは、ファイルのパスをコマンドライン引数で受け取って利用している。
自前のコマンドラインパーサがあり、それは受け取ったパスをstd::string
として返してくれる。
そのコマンドラインパーサを使う以下のようなコードがあった。
const char *filename = conf.get_str().c_str();
some_function(filename);
このコードは1年以上問題なく動作した。
開発環境をVSCode devcontainer化しようと、研究室の端末以外の環境で動かして初めてバグが顕在化したのだ。
some_function
に渡される値が空文字になったり、謎のごみデータになって文字化けのようになったりする。
C++わかるマンの皆様なら原因がわかると思う。
conf.get_str()
の返り値はstd::string
なのだが、これは文字列を格納するメモリを良い感じに管理してくれる。
RAIIによって、値のスコープが終わるときにメモリも破棄される。
ところで上のコードのstd::string
の値のスコープはどこからどこまでだろうか?
そう、const char *filename = conf.get_str().c_str();
の行までである。
つまり、その下のsome_function(filename)
が実行されるときにはconf->get_str()
の返り値の文字列を格納していたメモリは破棄されてしまっているのだ。
some_function
は破棄した後のメモリを操作することになるので、これはuse-after-freeである。
バグが顕在化しなかったのは、偶然メモリが書き換えられなかったからに過ぎない。
解決策
以下のように、std::string
を一度変数に格納すると解決する。
std::string filename = conf.get_str();
some_function(filename.c_str());
こうすると、conf.get_str()
の保持する文字列の生存期間はfilename
と同じになる。
さらに、Return Value Optimizationなるもので無駄なコンストラクタの呼び出しも発生しなくなるようだ。
実行前に防ぐ方法は無いのか?
このバグはAdressSanitizerを使うことで一発で見つけることができた。 しかし、できれば静的解析で発見したい。 そうすれば、将来別の人がこのソフトウェアを開発するときも未然にこのバグを防ぐことができるだろう。 しかし、自分はこのバグをコンパイル時に防ぐ方法を見つけられなかった。 良い方法を知っている人は、ぜひ教えてほしい。