buffer overflowと、それに必要な前提知識1

昔、社内の勉強会用に作った資料のメモ。

以下の本が参考。

Hacking: 美しき策謀 第2版 ―脆弱性攻撃の理論と実際

Hacking: 美しき策謀 第2版 ―脆弱性攻撃の理論と実際

まず、C言語を触ったことがある人はわかるかと思うが、 コンパイルという作業がある。 これは、ソースコードを、機械が読める形(バイナリ)で吐き出したもので、人間には読むことはできない。

$ gcc -o firstprog firstprog.c
$ ./firstprog

# Hello World
# Hello World
# Hello World
# Hello World
# Hello World
# Hello World
# Hello World
# Hello World
# Hello World
# Hello World
#include <stdio.h>

int main(){
    int i;
    for( i = 0; i < 10; i++ ){
        printf("Hello World\n");
    }

    return 0;
}

// ↓
// こんな感じのものが、400行ほど続く

// cffa edfe 0700 0001 0300 0080 0200 0000
// 0f00 0000 b004 0000 8500 2000 0000 0000
// 1900 0000 4800 0000 5f5f 5041 4745 5a45
// 524f 0000 0000 0000 0000 0000 0000 0000
// 0000 0000 0100 0000 0000 0000 0000 0000
// 0000 0000 0000 0000 0000 0000 0000 0000
// 0000 0000 0000 0000 1900 0000 d801 0000
// 5f5f 5445 5854 0000 0000 0000 0000 0000
// 0000 0000 0100 0000 0010 0000 0000 0000
// 0000 0000 0000 0000 0010 0000 0000 0000
// 0700 0000 0500 0000 0500 0000 0000 0000
// 5f5f 7465 7874 0000 0000 0000 0000 0000
// 5f5f 5445 5854 0000 0000 0000 0000 0000
// 400f 0000 0100 0000 4700 0000 0000 0000
// 400f 0000 0400 0000 0000 0000 0000 0000
// 0004 0080 0000 0000 0000 0000 0000 0000
// 5f5f 7374 7562 7300 0000 0000 0000 0000
// 5f5f 5445 5854 0000 0000 0000 0000 0000
// 880f 0000 0100 0000 0600 0000 0000 0000
// 880f 0000 0100 0000 0000 0000 0000 0000
// 0804 0080 0000 0000 0600 0000 0000 0000
// 5f5f 7374 7562 5f68 656c 7065 7200 0000
// 5f5f 5445 5854 0000 0000 0000 0000 0000
// 900f 0000 0100 0000 1a00 0000 0000 0000

これらをデバッグするツールとして、gdbというものがある。 なんか、動かなくなった。

アセンブリ言語

今の時点でのイメージは、人間がギリギリ理解できる超低級言語。

アセンブリ言語の命令(Intel方式)

アセンブリ命令は、一般的に、

命令語 <操作の対象>, <参照元>

という形式になっている。 操作の対象と参照元には、それぞれ、レジスタ、メモリアドレス、即値のいずれかを指定する。

コマンド 意味
mov 移動
sub 減算
inc 加算
$ gdb -q ./firstprog
# Reading symbols from ./firstprog...Reading symbols from /Users/myname/Hacking/0x2/firstprog.dSYM/Contents/Resources/DWARF/firstprog...done.
# done.
(gdb) list
# 1
# 2   // これらのヘッダは、/usr/includeに格納されている
# 3   #include <stdio.h>
# 4
# 5   // mainという名前の関数から始まる
# 6   int main(){
# 7       int i;
# 8       for( i = 0; i < 10; i++ ){
# 9           printf("Hello World\n");
# 10      }
# (gdb) list
# 11
# 12      return 0;
# 13  }
(gdb) disassemble main
# Dump of assembler code for function main:
#    0x0000000100000f40 <+0>: push   rbp
#    0x0000000100000f41 <+1>: mov    rbp,rsp
#    0x0000000100000f44 <+4>: sub    rsp,0x10
#    0x0000000100000f48 <+8>: mov    DWORD PTR [rbp-0x4],0x0
#    0x0000000100000f4f <+15>:    mov    DWORD PTR [rbp-0x8],0x0
#    0x0000000100000f56 <+22>:    cmp    DWORD PTR [rbp-0x8],0xa
#    0x0000000100000f5a <+26>:    jge    0x100000f7f <main+63>
#    0x0000000100000f60 <+32>:    lea    rdi,[rip+0x43]        # 0x100000faa
#    0x0000000100000f67 <+39>:    mov    al,0x0
#    0x0000000100000f69 <+41>:    call   0x100000f88
#    0x0000000100000f6e <+46>:    mov    DWORD PTR [rbp-0xc],eax
#    0x0000000100000f71 <+49>:    mov    eax,DWORD PTR [rbp-0x8]
#    0x0000000100000f74 <+52>:    add    eax,0x1
#    0x0000000100000f77 <+55>:    mov    DWORD PTR [rbp-0x8],eax
#    0x0000000100000f7a <+58>:    jmp    0x100000f56 <main+22>
#    0x0000000100000f7f <+63>:    xor    eax,eax
#    0x0000000100000f81 <+65>:    add    rsp,0x10
#    0x0000000100000f85 <+69>:    pop    rbp
#    0x0000000100000f86 <+70>:    ret
# End of assembler dump.
(gdb) quit

この例では最初に、ソースコードを表示させた後、main()関数の逆アセンブル結果を表示している。

その後、ブレークポイントmain()の先頭に設定し、プログラムを実行している。

その他にも言えることは、プログラムの実行の大半は、プロセッサとメモリセグメントにおける状態の変化で構成される。

よって、起こっていることを見極めるには、まずメモリを調査することになる。

examineのオプション一覧

コマンド 意味
o 8進表記
x 16進表記
u 符号なし10進表記
t 2進表記

gdbというものを使って、でデバッグしていく。 これは、逆アセンブルをした結果が、これ。

この左端の、意味不明な文字列は、メモリアドレスで、ある。 これはCのポインタという概念が必要になる。

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

int main(){
    char str_a[20];// 20個の要素を持つ文字の配列
    char *pointer;// 文字の配列をさすポインタ
    char *pointer2;

    strcpy(str_a, "Hello,World!\n");
    pointer = str_a;// ひとつ目のポインタが、配列の先頭を指すように設定する。
    printf(pointer);// ひとつ目のポインタが指している文字列を表示する。

    pointer2 = pointer + 2;// ポインタ1の二つ先を参照するようにする。
    printf(pointer2);// それが指している文字列を表示する
    strcpy(pointer2, "y you guys!\n");// そのばしょに他の文字列をコピーする
    printf(pointer);

}

結果

$ ./pointer

# Hello,World!
# llo,World!
# Hey you guys!

では、実際に、ポインタをのぞいてみる。

#include <stdio.h>

int main(){
    int i;

    char char_array[5] = {'a','b','c','d','e'};
    int int_array[5] = {1,2,3,4,5};

    char *char_pointer;
    int *int_pointer;

    char_pointer = (char *) int_array;
    int_pointer  = (int *) char_pointer;

    for ( i=0; i < 5; i++ ){
        printf("[整数へのポインタ]は、%pをさしており、その内容は、'%c'です。\n",int_pointer,*int_pointer);
        int_pointer = (int *) ( (int *) char_pointer + 1 );
    }

}

これを実行すると、

$ gcc -o pointer_types3 pointer_types3.c
$ ./pointer_types3
# [整数へのポインタ]は、0x7fff50107950をさしており、その内容は、''です。
# [整数へのポインタ]は、0x7fff50107954をさしており、その内容は、''です。
# [整数へのポインタ]は、0x7fff50107954をさしており、その内容は、''です。
# [整数へのポインタ]は、0x7fff50107954をさしており、その内容は、''です。
# [整数へのポインタ]は、0x7fff50107954をさしており、その内容は、''です。

次に、コマンドライン引数

#include <stdio.h>

void usage(char *programname){
    printf("使用方法: %s <メッセージ> <繰り返し回数>\n", programname);
    exit(1);
}

int main( int argc, char *argv[] ){
    int i,count;

    if( argc < 3 ){
        usage(argv[0]);
    }

    count = atoi( argv[2] );
    printf( "%d回繰り返します。" );

    for( i = 0; i < count; i++ ){
        printf("%3d  - %s\n", i, argv[1]);
    }
}

これを実行すると、

$ ./convert
# 使用方法: ./convert <メッセージ> <繰り返し回数>
$ ./convert test 20
0回繰り返します。  0  - test
 #  1  - test
 #  2  - test
 #  3  - test
 #  4  - test
 #  5  - test
 #  6  - test
 #  7  - test
 #  8  - test
 #  9  - test
 # 10  - test
 # 11  - test
 # 12  - test
 # 13  - test
 # 14  - test
 # 15  - test
 # 16  - test
 # 17  - test
 # 18  - test
 # 19  - test

メモリのセグメント化

メモリ空間は、 コンパイルされたプログラムを実行する際に、テキスト、データ、bss、ヒープ、スタック、という5つのセグメントに分かれる。

テキストセグメントには、別名コードセグメントともいい、 プログラムのマシン語が格納される領域。

プログラム実行時には、テキストセグメントの先頭の命令を指すように、eipが設定される。

1:eipが指しているメモリから命令を読み込む 2:該当命令のバイト長をeipに加算する 3:手順1で読み込んだ命令を実行する 4:手順1に戻る

この繰り返し

テキストセグメント

テキストセグメントには、コードのみが格納され、変数の割り当ては行われない

よって、書き込みは禁止されている

データセグメントと、bssセグメント

このセグメントには、プログラムが使用する、大域変数や、静的変数を格納される。

データセグメントには、初期化されたものが入るのにたいして、 bssには、初期化されていないものが入る。 書き込みは可能だが、サイズは固定。永続的

ヒープセグメント

ここはプログラマが直接制御できる。どのような目的にも使用でき、その割り当ては動的。

メモリ空間を図解した場合、これは、下に向かって、つまり、上位のメモリアドレスに向かって伸びて行く。

スタックセグメント

関数の局所変数や、関数呼び出し中のコンテキストなど、一時的なメモ帳として利用される

関数呼び出し時に、eipとコンテキストが変更されるため、スタックセグメントを利用して、 引数の受け渡し、関数の実行が終了した際に復元するeipの値の保存、該当関数が使用するすべての局所変数の割り当てが行われる これらをまとめて、スタックフレームと呼ばれるまとまりにされ、スタック上に格納される。

スタック

スタックは、先入れ後出し(数珠みたいなもの) スタックに入れることを、プッシュするといい、 出すことを、ポップする、という。

void test_function(int a, int b, int c, int d){
    int flag;
    char buffer[10];

    flag = 31337;
    buffer[0] = 'A';
}

int main(){
    test_function(1,2,3,4);
}

$ gdb -q ./stack_example
# Reading symbols from ./stack_example...Reading symbols from /Users/myname/Hacking/0x2/stack_example.dSYM/Contents/Resources/DWARF/stack_example...done.
# done.
(gdb) list
# warning: Source file is more recent than executable.
# 1
# 2   void test_function(int a, int b, int c, int d){
# 3       int flag;
# 4       char buffer[10];
# 5
# 6       flag = 31337;
# 7       buffer[0] = 'A';
# 8   }
# 9
# 10  int main(){
(gdb) list
# 11      test_function(1,2,3,4);
# 12  }
(gdb) disass main
# Dump of assembler code for function main:
#    0x0000000100000f70 <+0>: push   rbp
#    0x0000000100000f71 <+1>: mov    rbp,rsp
#    0x0000000100000f74 <+4>: mov    edi,0x1
#    0x0000000100000f79 <+9>: mov    esi,0x2
#    0x0000000100000f7e <+14>:    mov    edx,0x3
#    0x0000000100000f83 <+19>:    mov    ecx,0x4
#    0x0000000100000f88 <+24>:    call   0x100000f20 <test_function>
#    0x0000000100000f8d <+29>:    xor    eax,eax
#    0x0000000100000f8f <+31>:    pop    rbp
#    0x0000000100000f90 <+32>:    ret
# End of assembler dump.
(gdb) disass test_function
# Dump of assembler code for function test_function:
#    0x0000000100000f20 <+0>: push   rbp
#    0x0000000100000f21 <+1>: mov    rbp,rsp
#    0x0000000100000f24 <+4>: sub    rsp,0x30
#    0x0000000100000f28 <+8>: mov    rax,QWORD PTR [rip+0xe1]        # 0x100001010
#    0x0000000100000f2f <+15>:    mov    r8,QWORD PTR [rax]
#    0x0000000100000f32 <+18>:    mov    QWORD PTR [rbp-0x8],r8
#    0x0000000100000f36 <+22>:    mov    DWORD PTR [rbp-0x18],edi
#    0x0000000100000f39 <+25>:    mov    DWORD PTR [rbp-0x1c],esi
#    0x0000000100000f3c <+28>:    mov    DWORD PTR [rbp-0x20],edx
#    0x0000000100000f3f <+31>:    mov    DWORD PTR [rbp-0x24],ecx
#    0x0000000100000f42 <+34>:    mov    DWORD PTR [rbp-0x28],0x7a69
#    0x0000000100000f49 <+41>:    mov    BYTE PTR [rbp-0x12],0x41
#    0x0000000100000f4d <+45>:    mov    rax,QWORD PTR [rax]
#    0x0000000100000f50 <+48>:    cmp    rax,QWORD PTR [rbp-0x8]
#    0x0000000100000f54 <+52>:    jne    0x100000f60 <test_function+64>
#    0x0000000100000f5a <+58>:    add    rsp,0x30
#    0x0000000100000f5e <+62>:    pop    rbp
#    0x0000000100000f5f <+63>:    ret
#    0x0000000100000f60 <+64>:    call   0x100000f92
# End of assembler dump.
(gdb) quit

本来、4321の順で引数を渡すはず。

0x100000f20これは、test_functionの、アドレスである。

こんな感じで、C言語の基本的な部分をやった。

次に、プログラムの脆弱性について。

プログラムの脆弱性

バッファオーバーフロー

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

int main(int argc, char *argv[]){
    int value = 5;
    char buffer_one[8], buffer_two[8];

    strcpy(buffer_one, "one");
    strcpy(buffer_two, "two");

    printf("[前] buffer_twoは、%pにあり、その値は、\'%s\'です\n", buffer_two, buffer_two);
    printf("[前] buffer_oneは、%pにあり、その値は、\'%s\'です\n", buffer_one, buffer_one);
    printf("[前] valueは、%pにあり、その値は、%d(0x%08x)です。\n", &value, value, value);

    printf("\n[strcpy] %dバイトを、buffer_twoにコピーします。\n\n", strlen(argv[1]));
    strcpy(buffer_two, argv[1]);

    printf("[後]buffer_twoは %p にあり、その値は、\'%s\'です。\n", buffer_two, buffer_two);
    printf("[後]buffer_oneは %p にあり、その値は、\'%s\'です。\n", buffer_one, buffer_one);
    printf("[後] valueは、%pにあり、その値は、%d(0x%08x)です。\n", &value, value, value);
}
$ ./overflow_example 4
# [前] buffer_twoは、0x7fff51cc6948にあり、その値は、'two'です
# [前] buffer_oneは、0x7fff51cc6950にあり、その値は、'one'です
# [前] valueは、0x7fff51cc6934にあり、その値は、5(0x00000005)です。

# [strcpy] 1バイトを、buffer_twoにコピーします。

# [後]buffer_twoは 0x7fff51cc6948 にあり、その値は、'4'です。
# [後]buffer_oneは 0x7fff51cc6950 にあり、その値は、'one'です。
# [後] valueは、0x7fff51cc6934にあり、その値は、5(0x00000005)です。

$ ./overflow_example 4000000000000
# [前] buffer_twoは、0x7fff5f138938にあり、その値は、'two'です
# [前] buffer_oneは、0x7fff5f138940にあり、その値は、'one'です
# [前] valueは、0x7fff5f138924にあり、その値は、5(0x00000005)です。

# [strcpy] 13バイトを、buffer_twoにコピーします。

# Abort trap: 6

本来割り当てられていたメモリをオーバーすることで、制御を奪取する。

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

int check_authentication(char *password){
    int auth_flag = 0;
    char password_buffer[16];

    strcpy(password_buffer, password);

    if(strcmp(password_buffer, "brillig") == 0){
        auth_flag = 1;
    }

    if(strcmp(password_buffer, "outgrabe") == 0){
        auth_flag = 1;
    }

    return auth_flag;
}

int main(int argc, char *argv[]){
    if(argc < 2){
        printf("使用方法: %s <password>\n", argv[0]);
    }

    if(check_authentication(argv[1])){
        printf("------------\n");
        printf("-----ok-----\n");
        printf("------------\n");
    }else{
        printf("\ndeny!\n");
    }
}
$ ./auth_overflow aaa

# deny!

$ ./auth_overflow brillig
# ------------
# -----ok-----
# ------------

もしここで、

$ ./auth_overflow aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

このようにやったら、破られてしまう。

これは、メモリが勝手に、違うとこまで埋めてしまう。そこで、0以外を残す。これで、破られてしまう。