# SECCON 2023 SELFCET writeup ## 環境構築 まずは`docker`環境内でも,`gdb`を使えるようにしたい. `docker-compose.yml`を以下のように編集する. ```yaml= version: '3' services: dist_selfcet: privileged: true★これを追加. build: . ulimits: nproc: 65535 core: 0 ports: - "9999:9999" entrypoint: /etc/init.sh restart: unless-stopped ``` またDockerfile自体も修正した. ```dockerfile= FROM ubuntu:22.04 ENV DEBIAN_FRONTEND noninteractive RUN apt-get -y update RUN apt-get -y install xinetd gdb python3 wget★追加 RUN wget -q https://raw.githubusercontent.com/bata24/gef/dev/install.sh -O- | sh ★追加 RUN groupadd -r pwn && useradd -r -g pwn pwn RUN echo '#!/bin/bash\n\ service xinetd restart && /bin/sleep infinity' > /etc/init.sh RUN echo 'service pwn\n\ {\n\ type = UNLISTED\n\ disable = no\n\ socket_type = stream\n\ protocol = tcp\n\ wait = no\n\ user = pwn\n\ bind = 0.0.0.0\n\ port = 9999\n\ server = /home/pwn/xor\n\ }' > /etc/xinetd.d/pwn RUN chmod 500 /etc/init.sh RUN chmod 444 /etc/xinetd.d/pwn RUN chmod 1733 /tmp /var/tmp /dev/shm RUN echo "FAKECON{*** REDACTED ***}" > /flag.txt RUN chmod 444 /flag.txt RUN mv /flag.txt /flag-$(md5sum flag.txt | awk '{print $1}').txt WORKDIR /home/pwn ADD xor . RUN chmod 550 xor RUN chown -R root:pwn /home/pwn RUN service xinetd restart ``` あとはこれらのファイルが存在するディレクトリ上で, `docker-compose up`すれば`localhost:9999`でバイナリがホスティングされる. ## 初動解析 この問題はソースコードをつけてくれている. そんなに長くない. ```c= #include <err.h> #include <stdint.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <stddef.h> #define INSN_ENDBR64 (0xF30F1EFA) /* endbr64 */ // fの先頭が, 0xFA1E0FF3になってればtrapを発生させない. // ただしGOTのような間接ジャンプ系は不可能. #define CFI(f) \ ({ \ if (__builtin_bswap32(*(uint32_t*)(f)) != INSN_ENDBR64) \ __builtin_trap(); \ (f); \ }) #define KEY_SIZE 0x20 typedef struct { // +---------------------+ char key[KEY_SIZE]; // | - key: char[0x20] - | char buf[KEY_SIZE]; // | - buf: char[0x20] - | const char *error; // | *error | int status; // | padding | status | void (*throw)(int, const char*, ...); // | throw | } ctx_t; // +---------------------+ void read_member(ctx_t *ctx, off_t offset, size_t size) { // ctx.keyから0x58サイズ読み込む. -> 明らかにBOFできる. if (read(STDIN_FILENO, (void*)ctx + offset, size) <= 0) { ctx->status = EXIT_FAILURE; ctx->error = "I/O Error"; } // ここもbufを超えてNULLクリアできそう ctx->buf[strcspn(ctx->buf, "\n")] = '\0'; // status を潰せれば, CFIが呼ばれる. // throwの呼びさきが, 0xFA1E0FF3になってればOK if (ctx->status != 0) CFI(ctx->throw)(ctx->status, ctx->error); } void encrypt(ctx_t *ctx) { for (size_t i = 0; i < KEY_SIZE; i++) ctx->buf[i] ^= ctx->key[i]; } int main() { ctx_t ctx = { .error = NULL, .status = 0, .throw = err }; read_member(&ctx, offsetof(ctx_t, key), sizeof(ctx)/*88*/; read_member(&ctx, offsetof(ctx_t, buf), sizeof(ctx)/*88*/); encrypt(&ctx); write(STDOUT_FILENO, ctx.buf, KEY_SIZE); return 0; } ``` 処理としては単純明快で, 2つのデータ(`key`と`buf`)を読み込んで,各バイトを`xor`して標準出力に書き出すというもの. また, `checksec`は以下の通りだ. ```shell= gef> checksec ---------------------------------------------------- checksec - /home/pwn/xor ---------------------------------------------------- ------------------------------------------------------- Basic information ------------------------------------------------------- Canary : Enabled (value: 0x2ac1df305e573400) NX : Enabled PIE : Disabled RELRO : Full RELRO Fortify : Not found ----------------------------------------------------- Additional information ----------------------------------------------------- Static/Dynamic : Dynamic Stripped : No (The symbol remains) Intel CET endbr64/endbr32 : Not found Intel CET IBT : Disabled (kernel does not support) Intel CET SHSTK : Disabled (kernel does not support) RPATH : Not found RUNPATH : Not found System ASLR : Disabled (randomize_va_space: 0) GDB ASLR setting : Ignored (attached or remote process) gef> ``` 非PIEだが, `Full Relro`という点に注意. ## バグ解析 `main`関数にBOFバグがある.`read_member`関数内で, `read`するのは`0x58=sizeof(ctx_t)`だが`key`,`buf`メンバはそれぞれ`0x20`バイトしかないため, これらのメンバを超えて任意の値を書き込むことができる. このバイナリは`canary`が有効なので, BOFから直接リターンアドレスを書き換えることはできないが, スタックに配置される`ctx_t`は内部に関数ポインタ(`throw`)を持っており, `read_member`関数で`read`した後呼び出されるため, ここを書き換えるのが想定解だろう. ただし, 書き換えた関数ポインタを呼び出すには次の制約が存在する. 1. `throw`経由で関数ポインタを呼び出すには, `status`メンバが非ゼロでなければならない. 2. `throw`で呼び出すコードの先頭は`endbr64`命令でなければならない. ### 制約1 `read_member`内で`throw`メンバ経由で関数を呼び出すには,`status`メンバが非ゼロ. ```clike= if (ctx->status != 0) CFI(ctx->throw)(ctx->status, ctx->error); ``` ### 制約2 `CFI`マクロによって, 呼び出し先の先頭4バイトが`0xF30F1EFA`になっている場合にのみ`throw`を呼び出すことができるように制限されている. > ちなみに,`0xF30F1EFA`は`endbr64`命令でIntel CET有効なCPUでは`call`/`jmp`などの間接ジャンプ命令の有効な飛び先としてマークする命令. ```clike= #define INSN_ENDBR64 (0xF30F1EFA) /* endbr64 */ // fの先頭が, 0xFA1E0FF3になってればtrapを発生させない. // ただしGOTのような間接ジャンプ系は不可能. #define CFI(f) \ ({ \ if (__builtin_bswap32(*(uint32_t*)(f)) != INSN_ENDBR64) \ __builtin_trap(); \ (f); \ }) ~ 省略 ~ if (ctx->status != 0) CFI(ctx->throw)(ctx->status, ctx->error); ~ 省略 ~ ``` なお, 補足として ```clike= ctx->buf[strcspn(ctx->buf, "\n")] = '\0'; ``` の部分もバグがあるが非Exploitable? ## Exploit開発 ### libcのアドレスリーク まずはlibcのベースアドレスをリークさせることを考える. `main`関数内で, `ctx.throw`には`err`関数のアドレスが格納されているため, 上記のバグによって`status`を非ゼロにすると`err`関数が呼び出される. `err`関数は`errno`を解釈して, `error`を表示した後`exit`するため, 実行するとそのままプロセスが終了してしまう. `leak`させた後も正常に処理を`main`に戻したいので, `throw`を`err`関数から`warn`関数に書き換える. > `warn`関数もほぼ同一の処理だが, エラーメッセージを表示した後プロセスを終了しない. `ctx.error`に適当な関数の`got`のアドレスを入れて, `throw`の指し先がうまく`err`から`warn`の関数に書き換われば`got`の関数のアドレスから`libc`のアドレスをリークさせることができる. `err`関数と`warn`関数のオフセットは次のようになっている. ```shell= # nm -D /usr/lib/x86_64-linux-gnu/libc.so.6 | grep -E '[Tt] (err|warn)@' 00000000001211d0 T err@@GLIBC_2.2.5 0000000000121010 T warn@@GLIBC_2.2.5 # ``` Partial Overwriteで`err`のアドレスを`warn`に向けようとする場合, 最下位バイトと, 3ニブル目だけ書き換えれば良い. ただし, バイト単位でしか`throw`を書き換えることはできないため, 3ニブル目は確率的に`warn`のアドレスを当てる必要がある. 今回は, 非PIEなのでGOTのからアドレスをリークさせると良い. 使用できそうな`GOT`の関数の一覧は以下の通り. ``` gef> plt ------------------------------------ PLT / GOT - /home/pwn/xor ------------------------------------ Name | PLT | GOT | GOT value -------------------------------------------- .rela.dyn -------------------------------------------- __libc_start_main | Not found | 0x000000403fc8 | 0x7f6934b98dc0 <__libc_start_main> write | Not found | 0x000000403fd0 | 0x7f6934c83a20 <write> __stack_chk_fail | Not found | 0x000000403fd8 | 0x7f6934ca5700 <__stack_chk_fail> strcspn | Not found | 0x000000403fe0 | 0x7f6934d07750 <__strcspn_sse42> read | Not found | 0x000000403fe8 | 0x7f6934c83980 <read> __gmon_start__ | Not found | 0x000000403ff0 | 0x000000000000 err | Not found | 0x000000403ff8 | 0x7f6934c901d0 <err> gef> ``` 今回は`write`のアドレスを使用した. ### 任意コード実行からシェル起動 `read_member`が2回呼び出されるため, 1回目で上記の方法でlibcをリークさせた後は, `ret2libc`で`throw`をシェルが起動できるような関数に向ければよい. ただし前述した制約を考慮する必要があることと, `throw`の呼び出し方にもケアが必要だ. `throw`経由の呼び出しは, ```clike= (ctx->throw)(ctx->status, ctx->error); ``` となっており, 第一引数は`int`型の値が入る. つまりシェルを起動するには, ``` <shellを起動できる関数>(適当なint, "/bin/sh", ...) ``` の形で呼び出せる必要がある. 例えば, `system`関数などは, `"/bin/sh"`や`"sh"`のような文字列のアドレスが第一引数に必要だが, 今回の問題では32bitで表現できるアドレス帯域にそのような文字列は存在しなかった. また制約2から飛び先の先頭は必ず`endbr64`命令でなければならない(正しく関数に飛ばなければならない). そのような関数はあるだろうか? #### `posix_spawn`/`posix_spawnp`関数 最初に思いついたのが, `posix_spawn`系の関数を使うことだ. これは第一引数にポインタを取り, 第2引数に実行ファイルのパスを取る. ```clike= #include <spawn.h> int posix_spawn(pid_t *pid, const char *path, const posix_spawn_file_actions_t *file_actions, const posix_spawnattr_t *attrp, char *const argv[], char *const envp[]); int posix_spawnp(pid_t *pid, const char *file, const posix_spawn_file_actions_t *file_actions, const posix_spawnattr_t *attrp, char *const argv[], char *const envp[]); ``` 第一引数の`pid`は, writableなアドレスであればOKであるため, `posix_spawn(bss_base, "/bin/sh", ...)`とかで呼び出そうとしたが, うまく行かない. 正確には, `execve`まで実行できて入るものの、引数にうまく`/bin/sh`が入らない or 子でスタックが壊れており, 即座に`exit`してそうな感じ. {%gists 1u991yu24k1/0bf81bd5fad9e633d9e44950128f4de4 %} ``` [Term 1] root@Ubu2204x64:selfcet# ./x.py [*] local-> 127.0.0.1 9999 __page_offset=0x10 00000000: 78 6F 72 3A 20 20 3A EA F7 FF 7F 3A 20 53 75 63 xor: :....: Suc 00000010: 63 65 73 73 cess [*] addr_write [*] libc_base: 0x7ffff7d8f000 [*] addr_system: 0x7ffff7ddfd60 [*] addr_posix_spawn: 0x7ffff7f06ed0 [*] addr_posix_spawnp: 0x7ffff7f06ef0 [*] addr__libc_start_main: 0x7ffff7db8dc0 [*] addr___spawni: 0x7ffff7ea28e0 gdb?(after 1) *** Connection closed by remote host *** root@Ubu2204x64:selfcet# [Term 2] (gef)でattachした後の画面 gef> c [Attaching after Thread 0x7ffff7d8c740 (LWP 2407) vfork to child process 2418] [New inferior 2 (process 2418)] [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". [Detaching vfork parent process 2407 after child exit] [Inferior 1 (process 2407) detached] [Inferior 2 (process 2418) exited with code 0177] gef> ``` ### 次に思いついたのが`execveat`を使う方法 `int execveat(int dirfd, const char *pathname, char *const argv[], char *const envp[], int flags);` こちらも最初にディレクトリのfdを取るがこれを-100(`AT_FDCWD`)とかにして, `pathname`に`/bin/sh`を突っ込んであげればうまく動くと思ったのだが, ``` 0x7ffff7ea2c30 <execveat> ( $rdi = 0x00000000ffffff9c, AT_FWCWD $rsi = 0x00007ffff7f67698 -> 0x0068732f6e69622f ('/bin/sh'?), $rdx = 0x00007ffff7f67698 -> 0x0068732f6e69622f ('/bin/sh'?), $rcx = 0x00007ffff7ea2c30 <execveat> -> 0xb8ca8949fa1e0ff3, $r8 = 0x0000000000402000 <_IO_stdin_used> -> 0x204f2f4900020001, $r9 = 0x0000000000000000 ) ``` となり, `rdx`が強制的に`rsi`と同じ値になってしまうため`argv[]`がいい感じにはまらない. ## 完全敗北 上記の2パターンを色々値をいじりながら試してみたが結局大会中は成功には至らなかった. 以下によれば、`arch_prctl`システムコールを使って`fs`レジスタのベースアドレスを改変することで`canary`のチェックをバイパスできるとのこと. ![image](https://hackmd.io/_uploads/r1VjvZjpR.png) ### `arch_prctl`システムコール `arch_prctl`の`man`は以下の通り. ![image](https://hackmd.io/_uploads/H1-5P-oaA.png) 第一引数にコマンドをセットすることで以下の処理を行う事ができる. | 指定できるコマンドのマクロ | 処理内容 | | ----------------------- | ----- | | `ARCH_SET_FS` | `FS`レジスタのベースアドレスを第2引数にセットしたアドレスに指定する | | `ARCH_GET_FS` | `FS`レジスタのベースアドレスを第2引数のアドレスに格納する. | | `ARCH_SET_GS` | `GS`レジスタのベースアドレスを第2引数にセットしたアドレスに指定する | | `ARCH_GET_GS` | `GS`レジスタのベースアドレスを第2引数のアドレスに格納する. | `Stack Smash Protector`によってスタックに差し込まれる`canary`のマスター値は`fs:28`に存在する. `arch_prctl`システムコールを使って`fs`レジスタの指すアドレスを書き換えてやれば, 任意のアドレスからマスター`canary`を読み出せるようになるため, `fs_base = <BSS上の, 周辺の値の入っていないアドレス>`とかにしてやれば`canary`を常に`0`にできる. ## 最終的なExploit 最初の`err`→`warn`の部分は4bit範囲内なので, 確率としては$\frac{1}{16}$. 失敗したら何度か試すこと. 手で何回かやればシェルが取れる. {%gist 1u991yu24k1/ed739ad1a7b245afde8cb4cadac61751 %} CET is enabled by arch_prctlってマジ? https://lpc.events/event/7/contributions/729/attachments/496/903/CET-LPC-2020.pdf ## 補足(2023/09/21追記) 勉強会でアドバイスもらったのでメモ. `bata24/gef`では`fs_base`コマンドによって`fs`レジスタのベースアドレスを取得できる. `gef`のコードを追っていくと, `X86.gef_fs`関数で取得している. ```python=4835 class X86(Architecture): # ~ 省略 ~ def get_fs(self): # ~ 省略 ~ # fast path if not is_remote_debug() and not is_in_kernel(): PTRACE_ARCH_PRCTL = 30 ARCH_GET_FS = 0x1003 pid, lwpid, tid = gdb.selected_thread().ptid ppvoid = ctypes.POINTER(ctypes.c_void_p) value = ppvoid(ctypes.c_void_p()) value.contents.value = 0 libc = ctypes.CDLL("libc.so.6") ret = libc.ptrace(PTRACE_ARCH_PRCTL, lwpid, value, ARCH_GET_FS)# ★★ここでfsbaseを取得している★★ if ret == 0: # success return value.contents.value or 0 # ~ 省略 ~ ``` ここで, 現在アタッチしているスレッドに対して `ptrace(PTRACE_ARCH_PRCTL, lwpid, value, ARCH_GET_FS)`を行っている. [`glibc`のソースコード上にも定義されている](https://elixir.bootlin.com/glibc/glibc-2.35/source/sysdeps/unix/sysv/linux/x86/sys/ptrace.h#L119)が, カーネルのソースを追ってみると処理がよく分かるので記載しておく. 以下は`PTRACE_ARCH_PRCTL`を指定したときの, カーネル内の`ptrace`システムコールの呼び出し経路. ```clike= sys_ptrace() // https://elixir.bootlin.com/linux/v5.15.99/source/kernel/ptrace.c#L1293 → long arch_ptrace() // https://elixir.bootlin.com/linux/v5.15.99/source/arch/x86/kernel/ptrace.c#L711 → do_arch_prctl_64() // https://elixir.bootlin.com/linux/v5.15.99/source/arch/x86/kernel/process_64.c#L746 → x86_fsbase_read_task() // https://elixir.bootlin.com/linux/v5.15.99/source/arch/x86/kernel/process_64.c#L460 → x86_fsbase_read_cpu() // https://elixir.bootlin.com/linux/v5.15.99/source/arch/x86/include/asm/fsgsbase.h#L56 → rdfsbase() or rdmsrl() // rdfsbase命令 or rdmsr命令を呼ぶ. ``` 上記の呼び出し経路を見るとわかるが、`arch_ptrace`関数内で,`do_arch_prctl_64`を呼び出す. ```clike= long arch_ptrace(struct task_struct *child, long request, unsigned long addr, unsigned long data) { // ~ 省略 ~ #ifdef CONFIG_X86_64 // コメントに大体言いたいことが書いてある.      /* normal 64bit interface to access TLS data. Works just like arch_prctl, except that the arguments are reversed. */ case PTRACE_ARCH_PRCTL: ret = do_arch_prctl_64(child, data, addr); break; #endif // ~ 省略 ~ return ret; } ``` `do_arch_prctl_64`関数内でリクエストが`ARCH_GET_FS`/`ARCH_SET_FS`だった場合に, 処理を分岐している. ```clike= long do_arch_prctl_64(struct task_struct *task, int option, unsigned long arg2) { int ret = 0; switch (option) { case ARCH_SET_GS: { ~ 省略 ~ } case ARCH_SET_FS: { // fs_baseを新たにセットするための分岐. ~ 省略 ~ if (task == current) { loadseg(FS, 0); x86_fsbase_write_cpu(arg2); task->thread.fsbase = arg2; ~ 省略 ~ } case ARCH_GET_FS: { // fs_baseを取得するための分岐. unsigned long base = x86_fsbase_read_task(task); ret = put_user(base, (unsigned long __user *)arg2);// 取得した値をユーザランドに返す. break; } case ARCH_GET_GS: { ~ 省略 ~ } ``` 上記カーネル関数`do_arch_prctl_64`をelixirで検索すると, これは, `sys_arch_prctl`からも呼び出されている事がわかる. ```clike= // https://elixir.bootlin.com/linux/v5.15.99/source/arch/x86/kernel/process_64.c#L842 SYSCALL_DEFINE2(arch_prctl, int, option, unsigned long, arg2) { long ret; ret = do_arch_prctl_64(current, option, arg2); // ここでptraceの時同じ関数を呼び出している. if (ret == -EINVAL) ret = do_arch_prctl_common(current, option, arg2); return ret; } ``` ここから、`arch_prctl`/`ptrace`経由で`fs_base`を取得/設定できることがわかる.