『CTF形式で学ぶ Cプログラムの脆弱性』事前課題編

『CTF形式で学ぶ Cプログラムの脆弱性』の事前課題です。
www.security-camp.or.jp


※事前課題に取り組む前に、以下の記事に従って環境構築を行ってください。
haibara-works.hatenablog.com

【事前課題1】VMSSHで接続してみよう

  • 確認すること:VMのネットワークの設定

環境構築編の「演習用VMへのSSH接続」のときと同じですが、改めてVMSSH接続する方法を確認します。

VMIPアドレスの確認

まずはVirtualBoxVMを起動して、ログインします。ユーザー名は「alice」、パスワードは「password」です。
ログインができたら、SSH接続に必要なVMIPアドレスを確認します。ターミナルにip aと打ち込んで実行します。すると以下のような出力が表示されます(一部数値が異なる場合があります)。

alice@debian:~$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 08:00:27:d0:e6:4f brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.9/24 brd 192.168.1.255 scope global dynamic enp0s3
       valid_lft 14222sec preferred_lft 14222sec
    inet6 2404:7a83:a520:3400:a00:27ff:fed0:e64f/64 scope global dynamic mngtmpaddr
       valid_lft 2591992sec preferred_lft 604792sec
    inet6 fe80::a00:27ff:fed0:e64f/64 scope link
       valid_lft forever preferred_lft forever

ここでは詳しい説明は省きますが、2: enp0s3という項目のinet 192.168.1.9/24SSH接続に必要なIPアドレスです。SSHクライアントで下表のように指定することで、演習用VMSSH接続することができます。

IPアドレス 192.168.1.9
ユーザー名 alice
パスワード password

VMへのSSH接続

SSHクライアントとして、Windowsコマンドプロンプトを使う例を紹介します。LinuxMacをお使いの方は、それぞれのターミナルとして読み替えてください。
コマンドプロンプトを開いたら、次のコマンドを打ち込んで実行します。

ssh alice@<VMのIPアドレス>

VMIPアドレス」は先ほど調べたIPアドレスです。ここでのIPアドレスは192.168.1.9でしたので、ssh alice@192.168.1.9と入力します。出力は以下のようになります。【】で囲んだ文は補足コメントです。

【コマンドプロンプトでsshコマンドを打ち込む】
C:\Users\haibara>ssh alice@192.168.1.9
The authenticity of host '192.168.1.9 (192.168.1.9)' can't be established.
ECDSA key fingerprint is SHA256:iJ2rhaLbeTdl73AZqKI53LOvM1lPeCATex2mxSOzdAE.
【「yes」と入力】
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '192.168.1.9' (ECDSA) to the list of known hosts.
【パスワードを入力 (password)】
alice@192.168.1.9's password:
Linux debian 4.19.0-10-amd64 #1 SMP Debian 4.19.132-1 (2020-07-24) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Wed Oct 21 10:58:50 2020 from 192.168.1.4
【VMにログインできた!】
alice@debian:~$

【】で囲んだ補足コメントの通り、Are you sure you want to continue connecting (yes/no)?と聞かれたら「yes」を、alice@192.168.1.9's password: と表示されたら「password」(aliceのパスワード)をそれぞれ入力します。
alice@debian:~$ と出力されれば、VMにログインできています。ログインに成功すれば【事前課題1】は終了です!

【事前課題2】VM上でプログラミングしてみよう

  • 確認すること:ターミナルとエディタの使い方

【事前課題2】では、VM上のファイルを編集してプログラミングをしてみます。

ひな形ファイルの確認

演習用VMには、ひな形となるファイルが用意されています。~/sample/というディレクトリを開いてみましょう。
【事前課題1】に従って、VMSSH接続した後、次のようにコマンドを実行するとディレクトリ内のファイルを確認することができます。

alice@debian:~$ cd sample/ 【~/sampleディレクトリに移動】
alice@debian:~/sample$ ls 【このディレクトリ内にあるファイル・ディレクトリを表示】
Makefile  a.out  exploit.py  main.c  setup.sh

ディレクトリ内にいくつかのファイルがあることがわかります。以下にそれぞれのファイルの説明を示します。

ファイル名 説明
main.c C言語のソースファイル
Makefile main.cをコンパイルするためのmakefile
a.out Makefileによってmain.cから生成された実行ファイル
setup.sh この後の演習で使うシェルスクリプト
exploit.py この後の演習で使うPythonスクリプト

catコマンドを使って、main.cMakefileの内容を表示させてみましょう。

  • main.cの内容を表示
alice@debian:~/sample$ cat main.c 
#include <stdio.h>

int main() {
        printf("Hello, mc-do!\n");
}

main.cは、"Hello, mc-do!"という文字列を改行付きで出力するプログラムであることがわかります。

alice@debian:~/sample$ cat Makefile 
.PHONY: all
a.out: main.c
        gcc -m32 -fno-stack-protector main.c -o a.out

makefileは、makeコマンドへの指示を記述するファイルです。makefileのあるディレクトリでmakeコマンドを実行することで、makefileに記述された処理を実行することができます。また、makefilemakefileまたはMakefileというファイル名で保存することで、自動的にmakeコマンドがmakefileの内容を取得します。
makefileの文法については触れませんが、基本的には「あるファイルを、何かの処理によって、別のファイルに変換する」という処理を記述します。~/sample/ディレクトリにあるMakefileは、「main.cを、gcc -m32 -fno-stack-protector main.c -o a.outというコマンドによって、a.outに変換する」という意味を持ちます。

  • a.outの確認

a.outは、Makefileによってmain.cから生成されたファイルです。つまり、main.cコンパイルして出来た実行ファイルです。これはMakefilemain.cと違い、テキストファイルではなくバイナリファイルです。まずはa.outを実行してみましょう。以下のようにターミナルに打ち込みます。

alice@debian:~/sample$ ./a.out 
Hello, mc-do!

main.cの記述通り、"Hello, mc-do!"という文字列が出力されています。

main.cの編集

main.cの内容を書き換えてみましょう。VSCodeからSSH接続してファイルを開く方法もありますが、ここではVM上でCUIエディタを使う方法を紹介します。
CUIエディタにはnano・vimemacsなどがあります。別に使い慣れたエディタがあれば、それを使っても構いません。
ここではnanoを使う方法を紹介します。nanoはvimemacsに比べて、機能が少ない代わりに操作が簡単です。以下のコマンドによってmain.cをnanoで開きます。

alice@debian:~/sample$ nano main.c

コマンドを実行すると、次のような画面が表示されるはずです。

f:id:w_haibara:20201028022858p:plain
nanoでmain.cを開く

nanoでmain.cを開けていますね。
ここで、printfで出力する文字列を"FLAG{hello_ctf!}\n"に書き換えてみましょう。
まずは、printfの所までカーソルを移動させて、バックスペースで"Hello, mc-do!\n"を消します。
f:id:w_haibara:20201028023302p:plain

その後に、"FLAG{hello_ctf!}\n"をprintfのかっこの中にコピペします。
ブラウザでこの文字列をコピーして、(コマンドプロンプトの場合は) ターミナルのウィンドウを右クリックすることで、その時のカーソルの位置にペーストをすることができます。
f:id:w_haibara:20201028023825p:plainf:id:w_haibara:20201028024837p:plain

文字列をコピペすることができたら、この変更をファイルに上書き保存して、nanoを閉じます。キーボードのコントロールキーとxキーを同時に押すと、保存するか聞かれます。
f:id:w_haibara:20201028024837p:plain

ここでyキーを押すと、今度は保存する際のファイル名を聞かれます。今回は上書き保存をするため、main.cのままにします。エンターキーを押すと、nanoが終了し、main.cには先ほどの変更が上書きされています。
f:id:w_haibara:20201028025137p:plain

catコマンドで確認してみましょう。

alice@debian:~/sample$ cat main.c
#include <stdio.h>

int main() {
        printf("FLAG{hello_ctf!}\n");
}

確かに先ほどの変更が上書きされていることが確認できました。

makefileによるコンパイル

main.cを書き換えることができたら、コンパイルを行い、a.outを更新してみましょう。~/sample/ディレクトリで、makeコマンドを実行します。

alice@debian:~/sample$ make
gcc -m32 -fno-stack-protector main.c -o a.out

Makefileの内容に従って、main.cコンパイルされます。更新されたa.outを実行してみましょう。

alice@debian:~/sample$ ./a.out
FLAG{hello_ctf!}

a.outが更新されていることが分かります。このように、FLAG{hello_ctf!}と出力するa.outを作ることができれば【事前課題2】は完了です!

【事前課題3】Pwntoolsを使ってみよう

  • 確認すること:Pwntoolsの基本的な使い方

【事前課題3】では、PythonのライブラリであるPwntoolsを使ってみましょう。Pwntoolsは、この講義のテーマであるPwnを解く作業を自動化することができる便利なライブラリです。講義内の演習では、Pwntoolsを使ったエクスプロイトコード(脆弱性を攻撃するスクリプト)の作成にチャレンジします。その前に、PythonとPwntoolsの基本的な使い方を確認しましょう。

github.com

ひな形ファイルの確認

~/sample/ディレクトリにあるexploit.pyは、Pwntoolsを使った簡単なスクリプトです。catコマンドで内容を表示してみます。

alice@debian:~/sample$ cat exploit.py
from pwn import *

io = process("./a.out")
msg = io.recvline().decode()
print("msg:", msg)

次のコマンドで、このスクリプトを実行します。

alice@debian:~/sample$ python3 exploit.py
[+] Starting local process './a.out': pid 5756
msg: FLAG{hello_ctf!}

exploit.pyは、a.outの出力を取得するスクリプトです。exploit.pyの各行は以下のような意味を持ちます。

from pwn import * # Pwntoolsのインポート

io = process("./a.out") # a.out を開く
msg = io.recvline().decode() # a.outの出力を取得して、変数msgに代入
print("msg:", msg) # msgの値を出力
補足:Pythonのbytesとstring

recvline()メソッドはプログラムからの出力を返しますが、上のスクリプト内ではさらにdecode()メソッドを使っています。これはrecvline()の戻り値の型がbytesであるため、それをstringに変換するために必要です。
試しにdecode()を使わない以下のスクリプトを実行してみましょう。

from pwn import *

io = process("./a.out")
msg = io.recvline()
print("msg:", msg)

alice@debian:~/sample$ python3 exploit.py
[+] Starting local process './a.out': pid 7137
msg: b'FLAG{hello_ctf!}\n'

実際の改行ではなく、改行を表す\nそのものが出力されているのがわかります。

参考:https://docs.python.org/ja/3/library/stdtypes.html#bytes

プログラムとのやり取りを自動化する

Pwnでは、対話型のプログラム(ユーザーから入力を受け取り、それに応じて出力を行うプログラム)の脆弱性を攻撃します。つまり、エクスプロイトコードは、脆弱なプログラムへの入出力を自動化することになります。
exploit.pyでは単純にプログラムの出力を取得するだけでした。他にもPwntoolsによる入出力の扱いを見ていきましょう。
Pwntoolsの主な入出力を扱うメソッドを以下に示します。

メソッド名 説明 戻り値
recvline() 1行分(改行が来るまで)プログラムから読み取る。 読み取ったデータ
recvuntil(delims) delimsで指定したデータが来るまでプログラムから読み取る。 読み取ったデータ
sendline(data) dataを改行付きでプログラムへ出力する。 無し
sendlineafter(delim, data) recvuntil(delim)sendline(data)を共に実行する。つまり、delimで指定したデータが来るまでプログラムから読み取った後に、dataを改行付きでプログラムへ出力する。 読み取ったデータ

Pwntoolsには他にも多くの機能があります。詳細な情報は公式のドキュメント(pwntools — pwntools 4.3.1 documentation)を確認してみてください。
また、こちらの8ayacさんの記事はとても分かりやすく、Pwntoolsで何ができるのかを大まかに知ることができます。
qiita.com

さて、上表のメソッドを利用して、対話プログラムとのやり取りを自動化してみましょう。まずはmain.cを次のように書き換えてください。

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

void vuln() {
        char buf[8];
        printf(">> ");
        scanf("%s", buf);
        if (!strcmp(buf, "flag")) {
                printf("FLAG{hello_ctf!}\n");
        }
}

int main() {
        vuln();
        return 0;
}

【事前課題2】と同じように、ターミナルを右クリックしてエディタに張り付けましょう。
main.cを書き換えたら、makeコマンドでコンパイルします。

alice@debian:~/sample$ make
gcc -m32 -fno-stack-protector main.c -o a.out

コンパイルできたら更新されたa.outを実行して見ましょう。

alice@debian:~/sample$ ./a.out
Hello everyone!
>>

>>が表示された後に、入力待ちの状態になります。ここでflagと打ち込むと、FLAG{hello_ctf!}と出力されます。flag以外の文字列が入力された場合には、そのままプログラムが終了します。

alice@debian:~/sample$ ./a.out
Hello everyone!
>> flag
FLAG{hello_ctf!}

それでは、このa.outと自動でやり取りしてFLAG{hello_ctf!}を取得するように、~/sample/exploit.pyを書き換えてみましょう。このとき、以下のようにprocessメソッドに引数を追加してください。

io = process("./a.out", stdin=process.PTY)

processの引数に、stdin=process.PTYが追加されています。これは対象のプログラム(ここではa.out)と対話的な入出力をするためのオプションです。
(参考:https://docs.pwntools.com/en/stable/tubes/processes.html#module-pwnlib.tubes.process)

  • ヒント:FLAGを取得する手順
  1. a.outから>> が出力された後に、a.outflagと読ませる。
  2. 出力されたFLAGを読み込む。

https://docs.python.org/ja/3/library/stdtypes.html#bytes

exploit.pyでFLAGを取得できたら、【事前課題3】は終了です!

【事前課題4】gdb-pedaでプログラムを解析してみよう

  • 確認すること:gdb-pedaの基本的な使い方

gdb-pedaを使って実行ファイルの解析をしてみましょう。gdb-pedaは、GDBというデバッガにPEDAという拡張機能を追加したものです。
github.com

重要:解析の前に必ず~/sample/setup.shを実行してください。

alice@debian:~/sample$ ./setup.sh
[sudo] password for alice: 【password と入力】
kernel.randomize_va_space = 0

~/sample/ディレクトリで以下のコマンドを実行すると、gdb-pedaが起動します。

alice@debian:~/sample$ gdb ./a.out
f:id:w_haibara:20201029130053p:plain
gdb-pedaの起動

gdb-pedaが起動すると、ターミナルの表示がgdb-peda$という赤い文字に変わります。qを入力すれば、gdb-pedaを終了できます。それではgdb-pedaのいくつかの機能を見てみます。

r:プログラムの実行

rコマンドは「run」の略で、プログラムを実行することができます。

gdb-peda$ r
Starting program: /home/alice/sample/tmp/a.out
>> flag
FLAG{hello_ctf!}
[Inferior 1 (process 7717) exited normally]
Warning: not running

checksec:セキュリティ機能の確認

checksecコマンドを使うと、実行ファイルのセキュリティ機能について確認できます。gdb-pedaで実行してみます。

gdb-peda$ checksec
CANARY    : disabled
FORTIFY   : disabled
NX        : ENABLED
PIE       : ENABLED
RELRO     : Partial

「CANARY」や「NX」などはセキュリティ機能の名称です。ここでは触れませんが、実行ファイルがどのようなセキュリティ機能を持っていることが確認でき、攻撃の方針を立てる際に役立ちます。

disas:ディスアセンブル

disasコマンドは、指定した関数をディスアセンブルするコマンドです。ディスアセンブルとは、機械語(バイナリ)をアセンブリ(テキスト)に変換することです。Pwnではソースコードは公開されずに、実行ファイルだけが渡される問題もあります。このようにソースコードが無い場合には、ディスアセンブル結果が実行ファイルを読み解くヒントになります。

gdb-peda$ disas main
Dump of assembler code for function main:
   0x56556235 <+0>:     push   ebp
   0x56556236 <+1>:     mov    ebp,esp
   0x56556238 <+3>:     and    esp,0xfffffff0
   0x5655623b <+6>:     call   0x56556251 <__x86.get_pc_thunk.ax>
   0x56556240 <+11>:    add    eax,0x2dc0
   0x56556245 <+16>:    call   0x565561c9 <vuln>
   0x5655624a <+21>:    mov    eax,0x0
   0x5655624f <+26>:    leave
   0x56556250 <+27>:    ret
End of assembler dump.

※ 現時点でアセンブリを理解している必要はありません。

p:関数のアドレスなどを確認

pコマンドはprintの略で、レジスタの状態などあらゆるものを出力できます。ここでは関数のアドレスを確認する例を紹介します。
重要:関数のアドレスを確認する前に、一度rコマンドでプログラムを実行してください。

gdb-peda$ p main
$1 = {<text variable, no debug info>} 0x56556260 <main>

mainのアドレスは0x1235であることが分かります。

pattc・patto:バッファのオフセットを調査

pattcコマンドは、指定した長さのパターン文字列を生成します。

gdb-peda$ pattc 30
'AAA%AAsAABAA$AAnAACAA-AA(AADAA'

30文字分のパターンが生成されました。このパターン文字列をプログラムに読ませてみます。

gdb-peda$ run
Starting program: /home/alice/sample/a.out
>> AAA%AAsAABAA$AAnAACAA-AA(AADAA

Program received signal SIGSEGV, Segmentation fault.
[----------------------------------registers-----------------------------------]
EAX: 0xffffffff
EBX: 0x6e414124 ('$AAn')
ECX: 0x66 ('f')
EDX: 0xffffd5e8 ("AAA%AAsAABAA$AAnAACAA-AA(AADAA")
ESI: 0xf7fbf000 --> 0x1d9d6c
EDI: 0xf7fbf000 --> 0x1d9d6c
EBP: 0x41434141 ('AACA')
ESP: 0xffffd600 ("(AADAA")
EIP: 0x41412d41 ('A-AA')
EFLAGS: 0x10286 (carry PARITY adjust zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
Invalid $PC address: 0x41412d41
[------------------------------------stack-------------------------------------]
0000| 0xffffd600 ("(AADAA")
0004| 0xffffd604 --> 0xf7004141
0008| 0xffffd608 --> 0x0
0012| 0xffffd60c --> 0xf7dffb41 (<__libc_start_main+241>:       add    esp,0x10)
0016| 0xffffd610 --> 0x1
0020| 0xffffd614 --> 0xffffd6a4 --> 0xffffd7d2 ("/home/alice/sample/a.out")
0024| 0xffffd618 --> 0xffffd6ac --> 0xffffd7eb ("SHELL=/bin/bash")
0028| 0xffffd61c --> 0xffffd634 --> 0x0
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
0x41412d41 in ?? ()

何やらたくさん出力されました。焦らずに上から見ていくと、プログラムにパターン文字列を読ませた直後に「Program received signal SIGSEGV, Segmentation fault.」とあります。【事前課題3】で書き換えたmain.cを見れば分かるように、vuln関数内の配列bufのサイズは8です。それに対して30文字分の入力を与えたために、セグメンテーションフォルトが発生しています。
gdb-pedaでは、解析するプログラムがセグメンテーションフォルトを発生させると、その時のレジスタやスタックの状態を表示してくれます。ここで注目すべきは、EIPというレジスタの値です。上のgdb-pedaの出力のregistersの中からEIPの項目を見てみてください。EIP: 0x41412d41 ('A-AA')となっており、EIPの値が入力したパターン文字列の一部(A-AA)で上書きされていることがわかります。このままだと、ただプログラムの実行が失敗しただけのように見えますが、EIPが上書きできたということは一大事なのです。
なぜなら、EIPというレジスタは「次に実行する命令のアドレス」という意味を持つレジスタだからです。つまり、「EIPの上書きができる」ということは、「次に実行する命令を決めることができる」ことに等しいのです。
EIPの上書きが可能なことがわかったら、攻撃者が次にやりたいのは「EIPに任意の値を書き込む」ことです。この例では、プログラム内の配列のサイズより十分に大きいサイズ(ここでは30)の文字列を読ませることで、文字列のどこかはEIPに書き込まれています。ぴったりEIPのアドレスに値を書き込むためには何文字分の文字列を読ませればいいのかがわかれば、「EIPに任意の値を書き込む」ことができそうです。
そして、pattcコマンドとpattoコマンドを組み合わせることで、それが明らかになります。EIPに上書きされた文字列であるA-AApattoコマンドの引数として実行してみます。

gdb-peda$ patto A-AA
A-AA found at offset: 20

A-AAはオフセット 20で見つかりました」と表示されています。実はpattcコマンドで生成された文字列は、「どの連続した4文字を取ってきても重複せず、パターン文字列の何文字目かがわかる」という性質を持っています。そのため、

  1. pattcコマンドで十分に長いパターン文字列を生成する
  2. EIPを上書きした文字列を確かめる
  3. pattoコマンドでEIPを上書きした文字列が、パターン文字列の何文字目から始まるのかを確かめる

という手順で、EIPに特定の値を書き込むための入力文字数がわかるのです。
ここまでの文章に目を通したら、【事前課題4】は終了です!

【事前課題5】Pwntools・gdb-pedaを利用した攻撃

これが最後の事前課題です。これまでの課題に登場したテクニックを使って、実際に脆弱なプログラムを攻撃してみましょう。
まずは問題になる脆弱なプログラムを作成します。以下のCプログラムをmain.cに上書きしてください。

#include <stdio.h>

void printFlag() {
        printf("FLAG{great_hacking!}\n");
}

void vuln() {
        char buf[8];
        printf(">> ");
        scanf("%s", buf);
}

int main() {
        vuln();
        printf("There is no FLAG...\n");
        return 0;
}

main.cを上書きできたら、makeコマンドでコンパイルします。

alice@debian:~/sample$ make
gcc -m32 -fno-stack-protector main.c -o a.out

更新されたa.outを実行してみます。

alice@debian:~/sample$ ./a.out
>> foo 【適当にfooと入力】
There is no FLAG...

ソースコードを見るとわかる通り、(入力文字数が配列のサイズ以内であれば) FLAGを出力するprintFlag()関数は実行されません。
【演習問題5】は、a.outにFLAGを出力させるPythonスクリプトを作成できたら終了です!!!

  • ヒント
    • EIPをprintFlag()関数のアドレスで上書きすることで、printFlag()関数を実行することができます。
    • gdb-pedaについて
      • 攻撃に必要な情報は「入力の何文字目がEIPを上書きするか」・「printFlag()関数のアドレス」の2つです。
      • pコマンドで関数のアドレスを確認する前に、忘れずにrコマンドでプログラムを実行してください。
    • Python・Pwntoolsについて
      • Pythonでは、print(b"A"*n)とすることで、n文字分の文字列を出力できます。
      • アドレスを上書きする際、エンディアンに気を付ける必要があります。Pwntoolsには、エンディアンを直してくれるp32()というメソッドがあります。例えばアドレス0x0123456エンディアンを整えたいときは、p32(0x0123456)とすれば良いです。
      • Pythonでのバイト列の結合は、+で可能です。例えば、b"A"0x0123456を結合したいときは、b"A"+0x0123456と書けば良いです。

※ 悩んだら答えを見てやってみましょう!


お疲れ様でした!!講義当日を楽しみにしています!!!