コース目次 / 第4章

バッファオーバーフローを観察する(最終章)

gdbで、8バイトの箱に書き込まれたAAAA...がその境界を越えて隣接メモリに広がる様子を、生バイトで見ます。保護なし/ありでのクラッシュの違いから、スタックカナリアという多層防御を観察します。武器化はしません。

第4章 / 全6章目安 約14分この章のゴール: バッファオーバーフローの実際の挙動を、gdbで安全に観察できるようになる

前章で見た「箱の大きさと書き込む長さの不一致」が、実際に何を起こすのかを、自分の手元で・自分が作ったプログラムに対して観察します。このコースが行うのは観察までです。制御を乗っ取る・シェルコードを実行する、といった武器化は扱いません。

標的 — わざと小さな箱を使う関数

下を bof.c として保存します。8バイトの箱に、長い文字列を strcpy するだけの、最小の例です。

#include <stdio.h>
#include <string.h>

void vulnerable(char *input) {
    char buf[8];               // 8バイトの箱
    strcpy(buf, input);        // 箱の大きさを確認せずコピーする
    printf("buf の中身: %s\n", buf);
}

int main(void) {
    vulnerable("AAAAAAAAAAAAAAAAAAAAAAAA");  // 24文字 + 終端
    printf("ここまで到達すれば、関数は無事に戻ってきた\n");
    return 0;
}

まず、保護なしでビルドして、動かす

-fno-stack-protector を付けて、あえて保護を切ってビルドします(理由はあとで分かります)。

gcc -O0 -g -fno-stack-protector -o bof bof.c
./bof
echo "終了コード: $?"

実行すると、buf の中身: … は表示されず、Segmentation fault で異常終了します。$?(終了コード)を見ると 139——これは「シグナル11(SIGSEGV)で終了した」ことを示します。
何が起きたのか。strcpy が、8バイトの箱をはるかに超えて書き込み続け、関数から戻るためのアドレス(戻り先)まで上書きしてしまったため、vulnerable 関数が終わろうとしたとき、存在しない場所へジャンプしようとしてクラッシュしました。

gdb で、あふれる様子を、生バイトで見る

このコースが見せるのは、ここまでです。 どこにジャンプさせるかを操作したり、実行される命令を仕込んだりはしません。あくまで「箱からあふれている」という事実を、自分の目で確かめるだけです。

gdb -q -batch \
  -ex "break bof.c:5" \
  -ex "run" \
  -ex "next" \
  -ex "print buf" \
  -ex "x/32xb buf" \
  ./bof
Breakpoint 1, vulnerable (input=0x... 'A' <repeats 24 times>) at bof.c:5
5	    strcpy(buf, input);
6	    printf("buf の中身: %s\n", buf);
$1 = "AAAAAAAA"
0x7fffffffce98:	0x41	0x41	0x41	0x41	0x41	0x41	0x41	0x41
0x7fffffffcea0:	0x41	0x41	0x41	0x41	0x41	0x41	0x41	0x41
0x7fffffffcea8:	0x41	0x41	0x41	0x41	0x41	0x41	0x41	0x41
0x7fffffffceb0:	0x00	0xcf	0xff	0xff	0xff	0x7f	0x00	0x00

print buf は、buf を「8バイトの文字列」として律儀に “AAAAAAAA” と表示します——gdb は buf の宣言どおりのサイズしか知らないからです。
でも、その下の x/32xb buf(buf から32バイトを、生の16進バイトで表示)を見てください。0x41(文字 ‘A’)が、宣言された8バイトをはるかに超えて、3行(24バイト)ぶん続いています。これが、strcpy が実際にやったこと——「箱」という概念を無視して、渡されたバイト列をひたすら書き込み続けた証拠です。この先に、関数の戻り先アドレスなどが並んでいて、それも上書きされたために、さっきのクラッシュが起きました。

既定の保護(スタックカナリア)で、もう一度

先ほど -fno-stack-protector であえて切った保護を、今度は外さずに(gcc の既定のまま)ビルドします。

gcc -O0 -g -o bof_protected bof.c
./bof_protected
echo "終了コード: $?"
*** stack smashing detected ***: terminated
終了コード: 134

同じ穴のはずなのに、今度は違う壊れ方をしました。*** stack smashing detected ***——これはスタックカナリアという仕組みです。関数の入り口で、戻り先アドレスの手前に予測できない値(カナリア)を1つ置いておき、関数から戻る直前に「その値が変わっていないか」を確認します。strcpy の書き込みがカナリアの位置まで届いていたので、「何かが上書きされた」ことを、戻り先を使う前に検知して、安全に強制終了しました。

これが、多層防御の実例

sec-securecoding で学んだ多層防御を、いま目の前で見ました。
本命の対策——バッファの大きさを確認するコード(strncpy や、長さを渡すバージョンの関数)を書くこと——がここには無い。だから穴は空いたまま。
でも保険——スタックカナリア——が、その穴を「静かに悪用される」ものから「大きな音を立てて安全に落ちる」ものに変えました。
現代のコンパイラと OS には、この他にも ASLR(メモリ配置を毎回ランダムにずらし、狙った場所を当てにくくする)や NXビット(データ領域のメモリを、実行可能な命令として扱わせない)といった、何層もの保険が既定で効いています。あなたがこの章で見た「保護なし版」は、これらの保険が無かった時代に、実際に起きていたことです。

持ち帰る一言

箱の大きさを超えた書き込みは、隣接するメモリを、静かに侵食する。 gdbの生バイト表示が、それを目に見える証拠にしてくれました。現代の防御(スタックカナリア・ASLR・NX)は、この侵食を「気づかれない悪用」から「安全な強制終了」に変える、多層防御の実例です。次はまとめて、アセンブリの世界へ橋を渡します。

こうなっていればOK

卒業まであと1章です。

この章はまだ完了していません。