exploit入門

Ghidraを用いたバイナリ解析・pwntoolsを用いたPythonでのExploitScriptの入門

本ページでは、とても簡単な問題を通してバイナリ解析やexploitの入門を行います。 本ページの内容は以下のとおりです:

  • Ghidraを用いたバイナリ解析
  • GDBを用いたデバッグ
  • pwntoolsを使ったExploit
  • リモートサーバへの攻撃とflagの奪取

Challengeのダウンロード

このリンクからChallengeをダウンロードします: DOWNLOAD.

ダウンロード後、以下のコマンドでファイルを展開してください:

1
tar xvf ./simplest-<SHA256>.tar.gz

また、リモートサーバに繋ぐことができるかどうかも以下のコマンドで確認してください:

1
nc sc.skb.pw 30009

Ghidraを用いたバイナリの解析

Ghidraに解析させる

本講義で扱うchallngeは全てソースコードを添付するため、バイナリ解析自体はあまりしません。 でも折角なのでここで少しGhidraに触ってみましょう。

まずはGhidraを開いて、File > Import Fileからダウンロードしたバイナリsimplestをインポートします。 Ghidra

趣味の悪いドラゴンのアイコンをクリックすると、プロジェクトが開きます。バイナリを解析しますか?的なプロンプトが表示されるため、Analyzeを押してください。 Ghidra

画面左のSymbol Treesからmainを選択すると、main関数の逆アセンブリが表示されます。 Ghidra

main関数の解読

1つずつmain関数を読んでいきましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setvbuf(stdout,(char *)0x0,2,0);
  setvbuf(stdin,(char *)0x0,2,0);
  setvbuf(stderr,(char *)0x0,2,0);

  local_158 = fopen("/dev/urandom","r");
  if (local_158 == (FILE *)0x0) {
    perror("fopen");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  sVar2 = fread(&local_164,4,1,local_158);
  if (sVar2 != 1) {
    perror("fread");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  srand(local_164);
  local_15c = rand();

最初のsetvbufはpwn問題のおまじないみたいなもので、入出力のバッファリングを無効化してくれます。

続くfopenでは/dev/urandomをRモードで開いて、失敗したらperror > exitしていますね。テンプレです。 その後、freadlocal_164に対して4byteだけ/dev/urandomから読んでいますね。

urandomから読み込んだ値を使ってsrandをして、rand()の返り値をlocal_15cに代入しています。これはrand()で乱数を取得する際の常套手段です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  long local_148 [6];

  printf("Input `AAAAAAAA` > ");
  __isoc99_scanf(&DAT_00102037,local_148);
  iVar1 = strcmp((char *)local_148,"AAAAAAAA");
  if (iVar1 != 0) {
    puts("Wrong input!");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }

次に、scanflocal_148に対して文字列を読み込んでいます。DAT_00102037%30s文字列です。

30s

その後、strcmpで入力文字列がAAAAAAAAかどうかをチェックしています。異なる場合にはexitしていますね。

1
2
3
4
5
6
7
  printf("Input integer: 0x%X without prefix > ",(ulong)local_15c);
  __isoc99_scanf(&DAT_0010207e,&local_160);
  if (local_15c != local_160) {
    puts("Wrong input!");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }

次も同じような感じです。先程生成した乱数local_15cを表示して、ユーザの入力が乱数と一致するかどうかを確認しています。

1
2
3
4
5
6
7
8
  memset(local_148,0,0x30);
  printf("Input `0x12345678UL` in little-endian > ");
  __isoc99_scanf(&DAT_00102037,local_148);
  if (local_148[0] != 0x12345678) {
    puts("Wrong input!");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }

最後のプロンプトです。最初にバッファとして使っていたlocal_148をクリアしています。そのあと、0x12345678ULをリトルエンディアンで入力するように促し、入力が正しいかをチェックしています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  }
  local_150 = fopen("flag","r");
  if (local_150 == (FILE *)0x0) {
    perror("fopen");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  pcVar3 = fgets(local_118,0x100,local_150);
  if (pcVar3 == (char *)0x0) {
    perror("fgets");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }

最後に、flagをRモードで読み込んで出力しています。ここまでプログラムの実行が到達するとフラグが得られるみたいですね!

1
2
3
4
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }

こいつは関数から返る時のお決まりみたいなもので、スタック上のカナリアを見てしてスタックが壊れていないかどうかをチェックしています。

pwntoolsを用いたExpoloit

ローカルサーバを建てる

さて、ここまででバイナリの解析は終わりました。入門プログラムなので、簡単な入力を3回するだけでフラグが貰えるようです。 ここからはpwntoolsを用いてローカルでExploitを書いていきます。

まずはローカルサーバでchallengeを動かしましょう。以下のスクリプトをsocatという名前で保存し、実行します。

1
socat -v tcp-listen:12300,fork,reuseaddr exec:./simplest

これを実行するとローカルホストのポート12300でchallengeサーバが起動するため、ncで接続してみましょう。

1
nc localhost 12300

無事に接続できたらOKです。

pwntools template

pwntoolsを用いたexploitのテンプレートは以下のようになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/usr/bin/python3
#encoding: utf-8;

from pwn import *
import sys

################################################
FILENAME = "simplest" # EDIT HERE
LIBCNAME = "" # EDIT HERE
hosts = ("", "localhost", "localhost") # EDIT HERE
ports = (0, 12300, 23947) # EDIT HERE
################################################

rhp1 = {'host': hosts[0], 'port': ports[0]}  # for actual server
rhp2 = {'host': hosts[1], 'port': ports[1]}  # for localhost
rhp3 = {'host': hosts[2], 'port': ports[2]}  # for localhost running on docker
context(os='linux', arch='amd64')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME != "" else None

def exploit():
    global c

## main ##############################################
if __name__ == "__main__":
    global c

    if len(sys.argv) > 1:
        if sys.argv[1][0] == "d":
            cmd = """
          set follow-fork-mode parent
        """
            c = gdb.debug(FILENAME, cmd)
        elif sys.argv[1][0] == "r":
            c = remote(rhp1["host"], rhp1["port"])
            #s = ssh('<USER>', '<HOST>', password='<PASSOWRD>')
            #c = s.process(executable='<BIN>')
        elif sys.argv[1][0] == "v":
            c = remote(rhp3["host"], rhp3["port"])
    else:
        c = remote(rhp2['host'], rhp2['port'])
    exploit()
    c.interactive()

以下、exploitコードはexploit()関数の中身だけを抜粋して書いていきます。

ひとまず本コードをexploit.pyという名前で保存してください。 その後、EDIT HEREと書いてある部分を適宜修正します。 chmod +x ./exploit.py && ./exploit.pyで実行してみましょう。以下のような画面が出力されればサーバとの接続に成功し、exploitの準備ができています。

conn

プロンプト1: 出力を待って文字列を入力

最初のプロンプトはInput 'AAAAAAAA' > でした。先程バイナリを読んだ通り、この文字列を入力すれば次のプロンプトに進めます。

pwntoolsでは、recvuntil()メソッドで特定の文字列が出力されるまで出力を消費することができます。 また、sendline()メソッドで任意バイトを改行付きで出力することができます。

なお、sendlineメソッドはbyte型を受け付けますが、Python3では文字列をバイトにするためにbを付ける必要があることに注意してください。ptrlibを使うと、そのへんをいい感じにラップしてくれるらしいです。

1
2
    c.recvuntil("> ")
    c.sendline(b"AAAAAAAA")

プロンプト2: 出力をパースして入力

続くプロンプトは最初に生成した乱数を0x%Xフォーマットで出力し、10進文字列として入力させるものでした。

まずはrecvuntil()メソッドで乱数の手前まで出力を消費します。その後、recvuntil(" ")で乱数を読みます。" "は、乱数の終わりを識別するためのトークンで、[:-1]とすることで消去します。読み込んだ出力はあくまでも文字列なので、int()メソッドで文字列に変換します。最後に、sendline()で10進文字列として入力を与えます。先ほどと同様、sendline()の引数はbyte型である必要があることに注意しましょう。

1
2
3
4
5
    c.recvuntil("Input integer: ")
    rand = int(c.recvuntil(" ")[:-1], 16)
    c.recvuntil("> ")
    print("rand: ", hex(rand))
    c.sendline(str(rand).encode())

プロンプト3: エンディアンに注意

最後のプロンプトは、0x12345678という整数値をリトルエンディアンで出力するというものでした。 pwntoolsでは、context(os='linux', arch='amd64')とすることでアーキテクチャを指定できます。 amd64を指定した場合には各種メソッドが整数値をリトルエンディアンとしてエンコードしてくれるようになります。 この設定の後に、p64メソッドを使うことで整数を64bit整数型としてエンコードすることができます。

1
2
    c.recvuntil("> ")
    c.sendline(p64(0x12345678))

Local Exploit

ここまででexploitが作成できました。実際にローカルで動かしてみましょう。

1
./exploit.py

ローカルにflagファイルを置いていない場合には、socatを動かしているターミナルにcan't open fileのような表示が出るはずです。これが出力されればOKです!

GDBでデバッグ

この問題では特にGDBを使うまでもありませんが、せっかくなので使っておきましょう。 今回は3つ目のプロンプトに対してexploitで入力を与えた直後をデバッグしたいと想定します。

まず、exploit側のデバッグしたい箇所にinput()を入れて実行を一時停止します:

1
2
3
    c.recvuntil("> ")
    input("WAITING")
    c.sendline(p64(0x12345678))

exploitを行うと、WAITINGと表示された後に実行が止まります。これが表示されたら、psコマンドを用いてsimplestプロセスを探します:

1
2
$ ps -ax | grep simplest
 129883 pts/20   S+     0:00 ./simplest

続いて、他のターミナルを開いてPID 129883 のプロセスにアタッチします。

1
2
gef ./simplest
gef> att 129883

pwndbgやgefを利用している場合には以下のようなイカつい🦑画面が出ます:

gef

スタックトレース(btコマンド)から分かるように、現在はscanfの途中で実行を止めています。まずはmain関数に戻りたいため、finコマンドを実行します。finは現在の関数から抜けるまで実行を進めてくれます。finの実行後、GDBが以下の表示をして止まります:

fin

これは先程input()でexploitの実行を止めたためです。exploitをしているターミナルでEnterを押して実行を再開させましょう。mainまで戻ってくると、続く命令は以下のようになります:

    0x000056380a2b751a e8b1fcffff         <main+0x231>   call   0x56380a2b71d0 <__isoc99_scanf@plt>
    0x000056380a2b751f 488d85c0feffff     <main+0x236>   lea    rax, [rbp - 0x140]
 -> 0x000056380a2b7526 488b00             <main+0x23d>   mov    rax, QWORD PTR [rax]

Ghidraで見た感じだと、scanfの次は入力を0x12345678と比べるはずです。そのため、lea rax, [rbp - 0x140]rbp - 0x140こそがユーザ入力が格納されるアドレスであることが分かります。このアドレスに何が入っているかを見てみましょう。 GDBではx/<num><format>コマンドでメモリをダンプすることができます。

param説明
<num>何個分表示するか
<format>表示フォーマット。b/h/w/gでバイト/ハーフワード/ワード/ダブルワード。sで文字列。iで命令。xで16進数。

まずはx/16bx $rbp - 0x140で、rbp - 0x140から16bytes分を1byteずつ表示しましょう:

gef> x/16bx $rbp - 0x140
0x7fff2ceff940: 0x78    0x56    0x34    0x12    0x00    0x00    0x00    0x00
0x7fff2ceff948: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00

入力には0x12345678を入れたはずですが、上の表示では0x78 0x56...と逆順で入っています。 各CPUアーキテクチャはそれぞれエンディアンというメモリ上のデータ配置に関する決まりを持っています。 x64はリトルエンディアンのため、8byte整数値は上記のように逆順で入れられることになります。

なお、GDBのxコマンドはエンディアンをいい感じに解釈して出力してくれます。例として、x/2gxで2QWORD文を表示してみると以下のように0x12345678が表示されることが分かります。

gef> x/2gx $rbp - 0x140
0x7fff2ceff940: 0x0000000012345678      0x0000000000000000

リモートサーバへの攻撃

さて、ローカルでexploitが動いたので実際にリモートサーバに攻撃してみましょう。

上記のテンプレートを利用している場合には、hosts / portsをリモートサーバのものに変更したあと、以下のようにrを引数に渡して実行するとリモートサーバに繋げることができます:

1
2
hosts = ("sc.skb.pw", "localhost", "localhost") # EDIT HERE
ports = (30009, 12300, 23947) # EDIT HERE
1
./exploit.py r
Last modified November 15, 2023: add warning about challenge server (55ad5da)