はじめに
本稿は、以下の読者を対象に解説している。
- バッファオーバーフローがどういう原理で起きているか基礎を知りたい
- C言語を少しはわかる
C言語については、関数の使い方を理解していれば問題ない。
また、アセンブリの知識は全く必要ない。
バッファオーバーフロー概要
バッファオーバーフロー(別名バッファオーバーラン)は、プログラムのバグによって、想定の範囲を超えてメモリを上書きされることである。
プログラムにバッファオーバーフローの脆弱性が存在すると、以下のような操作が可能になる場合がある。
- プログラムを強制終了させる
- 任意のコマンドをプログラムの権限で実行する
これによって、DoS(サービス拒否)攻撃、サーバへの侵入、権限昇格を実行されるなどの被害が想定される。
バッファオーバーフローの種類
バッファオーバーフローには、大きく分けて次の二種類が存在する。
- スタック領域で発生するバッファオーバーフロー(スタックオーバーフロー)
- ヒープ領域で発生するバッファオーバーフロー(ヒープオーバーフロー)
今回は、2つの中でもより一般的なスタックオーバーフローについて解説する。
スタックオーバーフローの仕組み
スタックとは
そもそもスタックとはなんだろうか。
プログラムが実行されるときには、メモリが使用される。このメモリは、64bit だと16進数で 0x0 から 0xffffffffffffffff まであり、プログラムのコードやプログラム中で使用した変数の値などが保存される。
スタックはメモリ上の領域であり、主にプログラムが関数を呼び出した時に関数内で宣言された値を格納するために使用される。
また、関数内で宣言してもmallocなどの動的確保された値はスタック領域には保存されず、ヒープ領域に保存される。
これは、関数を呼び出すごとに積み重ねられ、関数が終了すると新しいものからポップされる。
実際のメモリ内では、スタックは高いアドレス (0xffffffffffffffff) の方から低いアドレスの方 (0x0) に伸びており、ヒープは低いアドレスから高いアドレスに伸びている。
IP (インストラクションポインタ)
スタックの詳しい説明に入る前に、IP という用語を理解しておきたい。
IP (インストラクションポインタ、またはプログラムカウンタ) は、次に実行する命令を格納するレジスタである。
レジスタが何かわからない人は、レジスタ = 変数 と考えて問題ない。
IPの動作をC言語を用いて説明する。
(実際にはアセンブリ単位で実行されるので、以下に示す動作と異なるが、簡略化のためにC言語で説明する)
1 #include <stdio.h>
2
3 int test() {
4 int n = 0;
5
6 return 0;
7 }
8
9 int main() {
10 int i = 0;
11
12 test();
13
14 return 0;
15 }
例えば今、プログラムが10行目を実行し終わったところだとする。
そうすると、IPには次に実行するアドレス(行番号)である12という値が格納される。(11行目は空行なので無視される)
10 int i = 0; // 現在ここ
11
12 test(); // IPにはこの行番号の12が入る
次に12行目が実行されるが、これは関数呼び出しである。関数呼び出しでは、次に実行すべき命令のアドレス(ここでは、14)をスタックに保存してからその関数を実行する。これは、関数が終了して戻ってきた際に次にどのアドレスにある命令を実行すればよいかを知るためである。
またこの時、IPには次に実行する関数内のアドレス4が格納される。
12 test(); // この関数を実行する前に
13
14 return 0; // この行番号の14をスタックに保存する
関数が呼び出されて4行目が実行され、次に6行目が実行される。
4 int n = 0;
5
6 return 0;
ここで、test関数は終了するのだが、関数呼び出しの時にスタックに保存したアドレス(14という値)をIPに設定することで、main関数に戻り14行目を実行する。
12 test(); // この関数の終了時に
13
14 return 0; // スタックに保存していたこの行番号を取り出しIPに設定する
IPはこのように、プログラムを実行するにおいて重要な役割を担っている。また、オーバーフローなどの脆弱性がある場合、攻撃者はこのIPの値を上書きすることでメモリ上の任意の場所にある命令を実行することができる。
スタックの動作
スタックは、LIFO(後入れ先出し)方式の構造で、プッシュで値を格納し、ポップで値を取り出す。
実際にプログラムでどう使用されるか簡単に説明する。
例えば、以下のようなプログラムを実行するとしよう。
void B() {
char buf[16];
memcpy(buf, "BBBBBBBBCCCCCCCC", 16);
}
void A() {
char buf[8];
memcpy(buf, "AAAAAAAA", 8);
B();
}
int main() {
A();
return 0;
}
上記は、main関数で関数Aを呼び出し、関数Aの中で関数Bを呼び出す単純なコードであるが、ここで各呼び出しと関数終了時のスタックの状態を見るとおおよそ次のようになる。
また、変数に格納される値は、高いアドレス(図で言うと下)方向に格納されていくので、もし、関数Bで16を越える文字列が配列 buf にコピーすると戻りアドレスが上書きされる。
(実際は、配列と戻りアドレスの間は少し空いているので、数バイト溢れただけでは戻りアドレスは上書きされない)
スタックオーバーフローを起こしてみる
オーバーフローを起こすコード
// vul.c
#include <stdio.h>
#include <string.h>
int main() {
char buf[8];
memcpy(buf, "AAAAAAAAAAAAAAAAAAAAAAAAA", 25);
return 0;
}
上記のコードでは、8文字分の配列に25文字を入れたことにより、バッファが溢れ、その影響でプログラムが強制終了するようになっている。
プログラムの実行
実際に実行してみる。
% uname -a
Linux cmp 4.4.0-93-generic #116-Ubuntu SMP Fri Aug 11 21:17:51 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux
% gcc vul.c -o vul -fno-stack-protector
% ./vul
zsh: segmentation fault (core dumped) ./vul
想定通りにプログラムがオーバーフローを起こし、強制終了したことがわかる。
これは、関数に戻るためのアドレスが上書きされたことにより、よくわからないアドレスにある命令を実行しようとしてエラーが発生し、プログラムが強制終了したからである。
また、上記で出力された segmentation fault とは、アクセスが許可されていないアドレスにアクセスすると起こるエラーである。
まとめ
本稿では、バッファオーバーフローの基本と仕組みを解説した。
また、実際にオーバーフローをおこすプログラムを実行し、戻りアドレスが上書きされることによって、プログラムが強制終了することを示した。
攻撃者はこれを利用し戻りアドレスを任意のアドレスに書き変えることにより、任意の命令を実行することが出来る。
バッファオーバーフローは時にクリティカルな脆弱性を生み出すこともあるので、開発では常にセキュアコーディングを心がけたい。
0件のコメント