C言語の最新事情を知る(2)
C11の仕様-脆弱性対応に関連する機能強化点
C言語の最新仕様の情報にキャッチアップしよう。C11の仕様で強化された機能のうち、主に脆弱性対応に関連するものを紹介する。
前回は、C99の仕様(ISO/IEC 9899:1999)について主だったところを紹介した。今回はC11の仕様(ISO/IEC 9899:2011)の中から主に脆弱性対応に関連するところを紹介したい(次回はそれ以外のC11の強化点を説明する)。
なお、C11の仕様書はANSIのeStandards storeからも購入できる。また、committee draftであれば、www.open-std.orgで閲覧可能だ。現在はこのリンクからダウンロード可能(ただし、詳細は確定した仕様とは異なる場合があるので注意されたい)。
概要
C11の仕様は2011年に規格化された。C11の特徴の1つとして、脆弱(ぜいじゃく)性対応に力が注がれている点が挙げられる。C99でもsnprintf関数の導入など、バッファーオーバーランへの対策は入っていたが、C11ではgets関数の削除や、printf関数における「%n」書式の廃止、fopen関数への排他モードの追加など、さまざまな脆弱性対応が導入されている。C11はまだ規格化されて間もないため処理系によるサポートはこれから進んでいくだろうが、C99のときとは異なり言語仕様そのものへの変更よりは、ライブラリの変更が主なので、対応にそれほど時間は要さないものと思われる(なお、今回紹介する機能の多くはC11仕様のAnnex Kで定められているものであり、これらは実装がオプション扱いとなっているので、まだ実装が進んでいない)。前回同様、各項目には仕様書の章、節番号を付記しているので、詳細に興味を持たれた方は仕様書を参照してほしい。
rsize_t型とRSIZE_MAXマクロ(§K3.3、§K3.4)
Cではメモリ領域の大きさを表現する型として、size_t型が用いられてきた。これは符号無しの整数型で非常に大きな値までカバーしている。しかし、一般にはあまりに大きな値は何らかのエラーに起因していると考えられる(例えば符号付きの型でサイズの計算を行い、そのときに計算を間違えて負の値を渡してしまった場合、符号無しの型として見るとこれは非常に大きな値となる)。stddef.hヘッダーファイルで定義されるrsize_t型は型としてはsize_t型と同一である(§K.3.3)、異なるのはrsize_t型のオブジェクトに格納される値は、stdint.hヘッダーファイルで定義されるRSIZE_MAXマクロの値以下であると規定されている点である(§K.3.4)。このため処理系でRSIZE_MAXを現実的な値に制限しておくことで(例えば、SIZE_MAXの半分)、間違ったサイズの指定を実行時エラーで検出することが可能となる(「RSIZE」の‘R’は「restricted」の意)。
例えばsprintf_s関数は引数nがrsize_t型になっており(リスト1)、RSIZE_MAXを超える値を渡された場合は「0」を返すことでエラーを示すことになっている(§K.3.5.3.6 なお、restrictというキーワードは、引数で指定されているメモリ領域がオーバーラップしていないことを前提として、この関数が実装されていることを示している)。
#define __STDC_WANT_LIB_EXT1__
#include <stdio.h>
int sprintf_s(char * restrict s, rsize_t n,
const char * restrict format, ...);
|
[コラム]__STDC_WANT_LIB_EXT1__マクロ(§K.3.1.1)
上で述べた「_s」付きの関数やrsize_t型、RSIZE_MAXマクロなど、C11仕様の§K.3で定義されている型、関数、マクロを利用するには、該当ヘッダーファイルをincludeする前に、__STDC_WANT_LIB_EXT1__マクロに「1」を設定する必要がある。これは既存の処理系への配慮だろう。具体的には、「_s」付きの関数は過去にマイクロソフトのVisual C++のC/C++拡張で導入されており、そのままC11で同名関数を追加すると衝突してしまうためだ。
C11仕様ではこのマクロによる処理系の挙動は以下のように定義されている。
- __STDC_WANT_LIB_EXT1__マクロが0と定義されている場合:
§K.3で解説された関数、マクロ、型は宣言、定義されない。 - __STDC_WANT_LIB_EXT1__マクロが1と定義されている場合:
§K.3で解説された関数、マクロ、型が宣言、定義される。 - __STDC_WANT_LIB_EXT1__マクロが定義されていない場合:
§K.3で解説された関数、マクロ、型が宣言、定義されるかどうかは実装依存。 - あるコンパイル単位内において、__STDC_WANT_LIB_EXT1__マクロの定義は一貫して同一でなければならない
gets関数の廃止(§K.3.5.4.1)
gets関数は標準入力から1行分の文字列を取り出してメモリに格納する関数だが、格納先メモリの長さを指定できないため、外部からの入力をこの関数を使って扱うプログラムは、潜在的なバッファーオーバーラン脆弱性を持っている。C11ではこの関数は削除され、fgets関数かgets_s関数を用いることが推奨されている(リスト2)。
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
char *gets_s(char *s, rsize_t n);
|
gets_s関数は、入力文字列の格納先と共に、格納先のサイズも指定できるようになっており、最大でn-1文字が標準入力から読み込まれ、その後にnull文字が書き込まれる。なお、既に述べた通り処理系によるgets_s関数の実装はオプション扱いであるため、移植性を考慮し可能であればfgets関数を使用した方がよいだろう。
getenv_s関数(§K.3.6.2.1)
Cで環境変数を取得する関数はgetenv関数である(リスト3)。
char *getenv(const char *name);
|
しかしgetenv関数にはいくつか問題がある。getenvは指定された名前を持つ環境変数を探し出し、対応する値を指すポインターを返す。このような値の受け渡し方は最も効率のよい方法の1つではあるが、このポインター値はプログラムのライフサイクルの中でずっと有効なわけではない。他のコードが環境変数を更新すると、内部で環境変数の格納領域の並べ替えなどが起きる可能性がある。特にマルチスレッドアプリケーションでは、あるスレッドがgetenv関数を呼んで環境変数へのポインターを取得した直後に別のスレッドが環境変数を変更するかもしれず、このような場合は競争状態となる可能性がある。また、環境変数の最大長さはプラットフォーム依存なので、ここで得られたポインターを使ってstrcpy関数などで内容をコピーするのは危険である。
C11ではgetenv関数を改良した、getenv_s関数が導入されている(リスト4)。
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdlib.h>
errno_t getenv_s(size_t * restrict len,
char * restrict value,
rsize_t maxsize,
const char * restrict name);
|
getenv_s関数は、環境変数の値をコピーするようになっており、また最大コピー長も指定できるようになっている。
memset_s関数(§5.1.2.3、§K.3.7.4.1)
パスワードなどセキュアなデータを扱う場合、処理後にはこうしたデータをメモリ上から消し込んでおく必要がある。このような用途でmemset関数を用いた場合、memset関数呼び出し以降のコードがmemset関数により書き込んだメモリの内容に対して一切アクセスしていないと、コンパイラがmemset関数の実行自体を不要と判断し、最適化によってmemset関数の処理そのものを削除してしまう可能性がある。これに対してmemset_s関数では、メモリへの書き込みが行われることが保証される(リスト5)。
#define __STDC_WANT_LIB_EXT1__ 1
#include <string.h>
errno_t memset_s(void *s, rsize_t smax, int c, rsize_t n);
|
smaxは最大サイズで、nにはsmaxを超える値を設定することはできない。これを利用し、smaxには対象バッファーの領域全体を定数値(外部入力に影響されなければ定数でなくても良い)で与え、nには今回設定したい領域を設定することで、nの計算を万が一間違えた場合や、攻撃によりnに領域外を指定された場合にもmemset_s関数を失敗させて領域外データの破壊を防ぐことができる。
なお、memset関数を使って同一バイトのパターンで消し込みを行うと、そのパターンを手掛かりとして逆にセキュアなデータの配置場所を推測される可能性があるため、一般には乱数を用いるべきである。
fopen関数の排他モード(§7.21.5.3)
リスト6のコードはファイルを読み出してみて、もしも存在しなければ作成する例である。
#include <string.h>
#include <errno.h>
#include <stdio.h>
int main(int argc, char **argv) {
FILE *fp = fopen("test", "r"); // (1)
if (!fp) { // (2)
fputs("ファイル'test'が存在しません。作成します...\n", stderr);
fp = fopen("test", "w"); // (3)
if (!fp) {
fprintf(stderr, "ファイルに書き込めません。%s\n", strerror(errno));
return -1;
}
else {
// ファイルが作成できたときの処理
}
}
fclose(fp);
return 0;
}
|
しかし、このコードにはTOCTOU(Time of check-Time of use)と呼ばれる脆弱性がある。図1のような状況を考えてみよう。
rootユーザーが実行しているコードは最初に「test」ファイルがあるか確認し、無ければ作成しようとする。攻撃者は、プログラムがtestファイルの書き込みオープンを行う直前を狙って/etc/sshd_configファイルへのハードリンクを作成する(ハードリンクの作成にはroot権限は不要なことに注意)。mode引数に「"w"」を指定してfopen関数を呼び出したときにすでにファイルが存在していた場合の挙動はtruncate、つまり内容の削除となるため、ハードリンクの作成に成功していると/etc/sshd_configファイルの内容が破壊されてしまうのだ*1。
- *1 この動作を確認したければ、リスト6のfputs関数呼び出しの直後(書き込みモードでのfopen関数呼び出しの直前)に「getchar();」などを追加したものをコンパイル/実行し、文字入力待ちになったところで、testファイルを作成してみるとよいだろう。catコマンドなどで適当にファイルを作成するだけで動作は確認できるので、このときにくれぐれも「ln /etc/sshd_config test」コマンドなどを実行してシステムファイルへのハードリンクを作成しないように注意されたい。
もちろん攻撃者は最初のfopen関数呼び出し(1)と次のfopen関数呼び出し(3)の間隙のタイミングである(2)をうまく狙う必要があるが、相手がデーモンやcronジョブのように定期実行されるプログラムであれば、その時間帯を狙って何度か試行することで成功する確率も上昇する。
C11からはmode引数に「"x"」を付加することで、対象のファイルを排他オープンできるようになった。この場合、ファイルがすでに存在するとfopen関数の呼び出しはエラーで失敗する。このため、書き込み用のfopen関数の呼び出しをリスト7に示すように変更すれば、すでにファイルが存在する場合には失敗するため攻撃を防ぐことができる。
fp = fopen("test", "wx"); // (3')
|
tmpnam_s関数(§K.3.5.1.2)
従来から存在する一時ファイル用の名前を生成するtmpnam関数は本質的に危険である(リスト8)。
#include <stdio.h>
char *tmpnam(char *s);
|
生成された一時ファイルの名前は引数で渡したポインターが指す位置に格納されるが、用意した領域のサイズを指定する方法がない。このため、この関数を呼び出す際には十分なサイズの領域を用意する必要があるが、どのくらい用意すれば十分なのかは使用する処理系やコードを実行するプラットフォーム、環境に依存し、常に領域あふれの危険が伴う。
またtmpnam関数は引数としてnullポインターも受け付け、この場合はライブラリ側で用意された領域を使用するようになっている。これは一見便利なようだが、当然、この関数を複数回にわたって呼び出していくうちに内容は上書きされてしまう。多くのtmpnam関数の実装はマルチスレッドで利用した場合に、スレッドごとに別のバッファーを用意するようにはなっていないため、競争状態が生じる。
tmpnam_s関数はC11で導入された関数である(リスト9)。
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
errno_t tmpnam_s(char *s, rsize_t maxsize);
|
tmpnam_s関数は、引数sに指定された位置に生成した一時ファイル名を格納する。このとき、引数maxsizeを使って呼び出し側で用意した領域のサイズを指定できる。tmpnam関数とは異なり、引数sに対してnullポインターを渡すことは許されず、引数に問題があった場合や、一時ファイル名を生成できなかった場合は「0」以外の値が返る。
tmpfile_s関数(§K.3.5.1.1)
前節で見たように、tmpnam_s関数を用いることで、安全に一時ファイル名を生成できる。しかし一時ファイル名を生成した後、実際にその名前を使ってファイルを作成するまでの間に、その名前が他のプログラムによって使用されてしまう可能性は残る。これは単にファイル名が衝突してプログラムが異常動作する可能性があるというだけでなく、上で見たTOCTOUによる攻撃を受ける危険もある。このためファイルのオープンの際には排他モードを使用しなければならない。しかし単に一時ファイルを作成することが目的であれば、tmpfile_s関数を用いた方がよい(リスト10)。
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
errno_t tmpfile_s(FILE * restrict * restrict streamptr);
|
tmpfile_s関数は一時ファイルをモード「wb+」(更新用バイナリファイル)で作成して、引数で指定された場所にFILEポインターを格納する。作成されたファイルは、プログラムの終了時に自動的に削除される。tmpfile_s関数は処理に成功すれば「0」を、それ以外の場合は非0を返す。なお、tmpfile_s関数は、従来から存在するtmpfile関数と比較して特段セキュアであるというわけではないが、他の「_s」付きの関数と同様にerrnoをグローバル変数ではなく戻り値として返す規約となっている点が特徴といえるだろう。
このように、一時ファイルを作成する場合はtmpnam_s関数よりもtmpfile_s関数を利用した方がよいが、Cの標準ライブラリには一時ディレクトリを作成する関数は存在しない。このため一時ディレクトリを作成する必要がある場合にはtmpnam_s関数で名前を生成する必要がある。
%n変換指定子が削除された関数(§K.3.5.3.1、§K.3.5.3.3)
printf関数およびその派生関数では、変換指定子(conversion specifier)を使用することで多種多様な書式化を行うことが可能だ。その変換指定子の1つに「%n」がある。%nを指定すると書式化を指定するのではなく、これまでに出力された文字数を受け取ることができる。リスト11に例を示す。
int main(int args, char **argv) {
int nchar;
printf("Hello, World%n\n", &nchar);
printf("%d characters.\n", nchar);
return 0;
}
結果:
Hello, World
12 characters.
|
リスト11は%n変換指定子を使って、%nが現れるまでに出力した文字数を受け取っている。「Hello, World」は12文字あるため、nchar変数には「12」が格納されていることが分かる。
printf関数の変換指定子は強力なので安易に使用すると思わぬ脆弱(ぜいじゃく)性を招くことがある。その中でも%nを悪用した攻撃は、任意のアドレスに外から値を書き込むことが可能なため大変危険である。%nを利用した攻撃手法は複雑で、ここで詳細を解説すると、それだけで紙面が尽きてしまうので、詳細は参考文献*2を参照されたい。ここではその恐ろしさの片りんをお見せするに留めることとする。
- *2 参考文献:Robert C. Seacord, 2006, 『C/C++セキュアコーディング』, ISBN 978-4756148230, アスキー, 368ページ。また、Web上ではIPA(独立行政法人 情報処理推進機構)の「セキュア・プログラミング講座 C/C++言語編」の第10章「著名な脆弱性対策」にある「フォーマット文字列攻撃対策」などを参考にしてほしい。
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int isInvalidCommand(const char *p) {
// ……
}
int main(int argc, char **argv) {
char buf[64];
const char *pCommand;
if (argc != 2) {
fputs("Specify one command.\n", stderr);
exit(1);
}
pCommand = argv[1];
if (isInvalidCommand(pCommand)) {
sprintf(buf, "Err:%.50s\n", pCommand); // (1)
fprintf(stderr, buf); // (2)
exit(1);
}
// ……
}
実行例(toolはこのプログラムの名前):
$ tool abc
Err:abc
|
リスト12はコマンドライン引数でコマンドを受け取り、そのコマンドが間違っていたらエラーを表示して終了するプログラムである。(1)では「%.50s」と指定している。つまり入力文字列が長くても50文字で切り捨てられるようにバッファーオーバーラン対策が入っており、一見特に問題無さそうである。しかし実はここに恐ろしい脆弱性が隠れている。ここではintのサイズが32bit、関数呼び出しにおいては引数がスタックに積まれるリトルエンディアンの処理系を使用していると仮定しよう(今回は、32bit版Cygwinのgccを使用)。
リスト12を注意して見ると、引数に指定した文字列が結果的に、(2)に渡ってしまっていることが分かる。このためリスト13のように変換指定子を引数に指定すると、それが(2)で実行されてしまう。
$ tool "0123%x_%x_%x_%x_%x_%x_%x"
Err:012320041760_611306e0_22ac2c_22d404_61276e00_3a727245_33323130
|
リスト12の(2)では変換指定子に対応する引数を指定していない。ここで表示されている結果は何を意味しているのだろうか?
図2はfprintf関数がcall命令で呼び出される直前のスタックの状態を示したものである。引数は右から順に積まれており(最後に積まれたものが一番下位のアドレスになる)、一番下位のアドレスにstderr、その4バイト先にbufのアドレスが積まれている(リトルエンディアンなので上位、下位が逆転している)。プログラム内で指定した引数はこの2つなので変換指定子%xで使われる値は、そのときにたまたまスタック上に存在していた値ということになる。
ここで注目すべきはスタック上にはローカル変数も存在しているという点であり、今回のケースでは28バイト先にはbufが存在している。このため6つ目の%xではbuf先頭の「Err:」の部分が、7つ目の%xでは今回引数に指定した「0123」(結果的にbufの4バイト目以降に格納されている)が見えている(それぞれのASCII値は16進表記で「E」=45、「r」=72、「:」=3a、「0」~「3」=30~33)。
このように外部から与えた引数によって、スタックの内容が見えてしまう点だけでも十分に脅威であるが、ここで引数に指定した7つ目の%xを%nに変更した場合の動作を想像してみてほしい。この場合、%nに到達するまでに(リスト13の出力結果の「Err:」から末尾にある「33323130」の直前の「_3a727245_」までの)58文字が出力されているため、58が%nによって書き込まれる値に、その書き込み先は0x33323130番地となる。つまり外部から指定した「0123」に相当する0x33323130番地に値58が書き込まれることになる。書き込まれる値は出力文字数なので、%xに幅指定を行うことで変更できる。
printf_s関数(§K.3.5.3.3)は変換指定子のうち、%nを受け付けないように改良された関数である。これ以外にも引数のチェックが追加されており、%sに相当する引数にnullポインターを渡すことは許されない(リスト14)。
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
int printf_s(const char * restrict format, ...);
|
通常、%n変換指示子が必要になるケースはまれであろう。必要ない場合はprintf_s関数を代わりに使用しておけば、万が一外部から変換指示子を指定できるようなプログラミングミスがあっても、%n変換指示子に起因する脆弱性を避けることができる。とはいえ、%nによる脆弱性は、変換指示子を悪用した脆弱性の1つにすぎない。基本は、外部入力をそのままprintf系の関数の変換指示子として使用するのを避けることなのはいうまでもない。
まとめ
今回はC11の仕様で強化された機能のうち、主に脆弱性対応に関連するものを紹介した。
- rsize_t型は型としては、size_t型と型としては同じだが、そこに格納される値がRSIZE_MAXマクロの値以下であることが規定される。このためライブラリの引数型にrsize_t型を使用しておくことで、RSIZE_MAXを超える値を引数エラーと判定できる
- バッファーオーバーラン脆弱性の原因となることがあるgets関数は削除され、gets_s関数が導入された
- getenv関数で返されたポインターは環境変数の変更などにより配置替えが起きて無効となることがある。これを改善したgetenv_s関数が導入された
- memset関数は最適化により処理が削除される可能性があった。C11では最適化により削除されることはないことが仕様で保証されたmemset_s関数が追加された
- fopen関数のモードに排他モード("x")が追加された
- tmpnam関数の欠点を改善したtmpnam_s関数が追加された。同時にtmpfile関数を一般的な「_s」付き関数の規約に合わせたtmpfile_s関数が追加された
- printf系列の関数の変換指定子から、危険な%n変換指定子を削除したprintf_s系列の関数が追加された
次回は今回取り挙げた分以外の強化点について見ていこう。