# Google CTF 2019 Finals - sbox Write-up ###### tags: `ctf` `pwn` `sandbox` `pivot_root` # 概要 この記事は,[CTF Advent Calendar 2019](https://adventar.org/calendars/4241) の4日目の記事です. 3日目は私の「[Google CTF 2019 Quals - sandstone Write-up](https://hackmd.io/@bata24/SkkrkDlaB)」でした. # はじめに 今回はGoogle CTF 2019 Finalsで出題された,sboxという問題について解説します. この問題はsandbox問ですが,(理解すれば)それほど難しい話ではないのにも関わらず,大会中はどのチームにも解かれなかった問題でした.大会終了後に運営に答えを聞き実装してみたところ,確かに動作したので公開しようと思った次第です. # 問題文 ``` Sbox: a flexible sandbox with outside helper. (runs on a single core machine) nc sbox.ctfcompetition.com 1337 ``` ファイルは[こちら](https://storage.googleapis.com/gctf-2019-attachments/2d16f08c0fe543944cb4292cb6acf08d7fd9408816b98b134e952eea065a4dbb)からDLできます. # 準備 main.cとhelper.cがありますね.逆にバイナリはありません. それほど長くはありませんから,まずはソースを一通り読んでおきましょう. また,この問題ではnsjailを使っています.過去にインストールしたことがない方は,入れておきましょう. ```shell= cd /tmp apt-get install -y --no-install-recommends git ca-certificates build-essential bison flex pkg-config libprotobuf-dev libnl-route-3-dev protobuf-compiler git clone --recurse-submodules https://github.com/google/nsjail cd nsjail make && make install ``` # 初動解析 念のためコードを貼っておきます.まずは`main.c`です. ```C= #define _GNU_SOURCE #include <sys/mman.h> #include <unistd.h> #include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/socket.h> #include <sys/wait.h> #include <signal.h> #include <limits.h> #define MAX_ELF_SIZE 1024*1024*30 #define MIN(a, b) (a < b ? a : b) char line[256]; void read_line() { int size; for (size = 0; size < sizeof(line)-1; ++size) { char c; int r = read(0, &c, 1); if (r <= 0) { break; } if (c == '\n') { break; } line[size] = c; } line[size] = '\0'; } int load_elf(void) { read_line(); size_t size = atoi(line); if (size > MAX_ELF_SIZE) { puts("too big"); return -1; } int exe = memfd_create("solution_exe", MFD_ALLOW_SEALING | MFD_CLOEXEC); if (exe < 0) { puts("internal error"); return -1; } char buf[2048]; while (size) { ssize_t read_sz = read(0, buf, MIN(sizeof(buf), size)); if (read_sz <= 0) { puts("error on read"); return -1; } if (write(exe, buf, read_sz) != read_sz) { puts("internal error"); return -1; } size -= read_sz; } if (fcntl(exe, F_ADD_SEALS, F_SEAL_WRITE | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_SEAL)) { puts("internal error"); return -1; } return exe; } int execute_helper() { int sv[2]; if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) { puts("internal error"); return -1; } pid_t pid = fork(); if (pid < 0) { puts("internal error"); return -1; } if (pid == 0) { close(sv[1]); dup2(sv[0], STDIN_FILENO); dup2(sv[0], STDOUT_FILENO); close(sv[0]); execl("/root/helper", "/root/helper", NULL); abort(); } close(sv[0]); return sv[1]; } pid_t execute_in_jail(int exe, int comms) { char path[PATH_MAX]; sprintf(path, "/proc/%d/fd/%d", getpid(), exe); pid_t pid = fork(); if (pid == 0) { if (chown("/tmp/sboxchroot", 1339, 1339) != 0) { perror("chown"); abort(); } dup2(comms, 37); char* args[] = { "/root/nsjail", "-Q", "--pass_fd", "37", "-u", "1000:1339", "-g", "1000:1339", "--rw", "--chroot", "/tmp/sboxchroot", "--proc_rw", "--execute_fd", "--disable_rlimits", "--", path, NULL, }; execv("/root/nsjail", args); abort(); } close(comms); return pid; } int main(int argc, char* argv[]) { int exe = load_elf(); if (exe < 0) { return -1; } puts("ok"); int comms = execute_helper(); if (comms < 0) { return -1; } pid_t child = execute_in_jail(exe, comms); if (child < 0) { return -1; } puts("started"); int status; if (waitpid(child, &status, WUNTRACED) != child) { puts("internal error"); return -1; } if (WIFEXITED(status)) { puts("exited"); if (WEXITSTATUS(status) != 0) { return -1; } } else { kill(child, SIGKILL); puts("killed"); } return 0; } ``` 続いて`helper.c`です. ```C= #include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/stat.h> #include <sys/types.h> #include <sys/prctl.h> #include <sys/time.h> #include <fcntl.h> #include <errno.h> #include <time.h> #include <limits.h> char g_flag[35]; char g_buf[256]; char line[1024]; #define CHECK(q) \ do { \ if (!(q)) { \ write_error(); \ return; \ } \ } while(0) void write_error() { int saved_errno = errno; puts("error"); puts(strerror(saved_errno)); } void timez() { struct timeval tv; CHECK(gettimeofday(&tv, NULL) == 0); char* time = ctime(&tv.tv_sec); CHECK(time != NULL); printf("%s", time); } void read_line() { if (fgets(line, sizeof(line), stdin) == NULL) { line[0] = '\0'; return; } size_t len = strlen(line); if (line[len-1] == '\n') { line[len-1] = '\0'; } } void save() { read_line(); size_t len = strlen(line); if (len > sizeof(g_buf)-1) { puts("error"); puts("too long"); } memcpy(g_buf, line, len); g_buf[len] = '\0'; puts("ok"); } void sanitize_path(char* path) { int dots = 0; char* out = path; for (char* in = path; *in != '\0'; ++in) { switch(*in) { case '.': ++dots; break; case '/': while (dots--) { if (out != path) { --out; } while (out != path && *out != '/') --out; } default: dots = 0; break; } *out++ = *in; } while (dots--) { if (out != path) { --out; } while (out != path && *out != '/') --out; } *out = '\0'; } void join_path(char* out, char* a, char* b) { out[0] = '\0'; size_t len_a = strlen(a); size_t len_b = strlen(b); if (len_a >= PATH_MAX-1 || len_b >= PATH_MAX-len_a-1) { return; } memcpy(out, a, len_a); out[len_a] = '/'; memcpy(out+len_a+1, b, len_b); out[len_a+len_b+1] = '\0'; } void give_flag() { read_line(); sanitize_path(line); char path[PATH_MAX]; join_path(path, "/tmp/sboxchroot", line); CHECK(chdir(path) == 0); CHECK(chown(".", 0, 0) == 0); CHECK(chmod(".", 0700) == 0); int key_fd = open("key", O_RDONLY); CHECK(key_fd != -1); char key[sizeof(g_flag)]; CHECK(read(key_fd, key, sizeof(key)) == sizeof(key)); char flag[sizeof(g_flag)]; for (int i = 0; i < sizeof(g_flag); ++i) { flag[i] = g_flag[i] ^ key[i]; } int fd = open("flag", O_CREAT|O_EXCL|O_WRONLY, 0640); CHECK(fd != -1); CHECK(write(fd, flag, sizeof(flag)-1) == sizeof(flag)-1); close(fd); puts("ok"); } int main(int argc, char* argv[]) { umask(0); if (prctl(PR_SET_DUMPABLE, 0)) { perror("prctl"); return -1; } int fd = open("/root/flag", O_RDONLY); if (fd == -1) { perror("open"); return -1; } if (read(fd, &g_flag, sizeof(g_flag)-1) != sizeof(g_flag)-1) { perror("read"); return -1; } g_flag[sizeof(g_flag)-1] = '\0'; close(fd); for (;;) { read_line(); if (strcmp(line, "gimme flag") == 0) { give_flag(); } else if (strcmp(line, "timez") == 0) { timez(); } else if (strcmp(line, "save") == 0) { save(); } else if (strcmp(line, "load") == 0) { puts(g_buf); } else if (strcmp(line, "") == 0) { puts("kbye"); break; } else { puts("error"); puts("unknown command"); } fflush(stdout); } return 0; } ``` 処理自体はそれほど難しくないのですが,色々な要素が絡み合っているため結構複雑です. 絵に起こすとわかりやすいと思います.以下は当時書いた絵です.「怪しいところは太字」と記載がありますがミスリードなので気にしないでください. ![](https://i.imgur.com/HHAzRko.png) 絵にすると分かりますが,ちょっと特殊なサンドボックス問です.サンドボックスを脱出する必要はなく,パーミッション的に読めないフラグを読めば良いということが分かります(図だと右下の緑色のボックスが読み取るべきフラグ). もう少し具体的に書くと,ゴールは`root:root, 0700`なディレクトリの配下にある`root:root, 0640`なflagを読むことです.普通に考えたら無理ですよね,これ. ```shell= root@Ubuntu1804-64:/tmp# ls -ld /tmp/aa drwx------ 2 root root 4096 Dec 4 00:29 /tmp/aa/ root@Ubuntu1804-64:/tmp# ls -l /tmp/aa total 0 -rw-r----- 1 root root 0 Dec 4 00:29 flag root@Ubuntu1804-64:/tmp# sudo -u nobody cat /tmp/aa/flag cat: /tmp/aa/flag: Permission denied root@Ubuntu1804-64:/tmp# ``` # 問題点の分析 当時は全く攻略の糸口さえもつかめませんでしたが,解き終わってからだとヒントのようなものが存在したことが分かります.論理の飛躍があるのは重々承知ですが(一応)論理的に解説してみましょう. まず,一番着目すべきは`give_flag()`でしょう. ![](https://i.imgur.com/5i85hMI.png) 今回,子プロセスからは4つの操作が可能ですが,`timez()`,`save()`,`load()`のいずれも何もできないと考えられるからです.必然的に,何かを仕込めるとしたら`give_flag()`一択となります. ## 着眼点1 1つ目に着目すべきは,子プロセスから送受信できるデータです. 107行目の`read_line()`によって,ファイルシステム上のパスを子プロセスから送信することができます. パスは受信後にサニタイズされるため,ディレクトリトラバーサルは不可能ですが,この問題はローカルexploitなので,シンボリックリンク攻撃が可能です. ## 着眼点2 2つ目に着目すべきは,フラグファイルのパーミッションです. 書き出されるflagファイルは,なぜ`0640`なのでしょうか.単にrootを取らせる問題ならば,`0600`でよいはずです.つまり`0640`でないと解けない何かがあると考えられます. ファイルやディレクトリのパーミッションで,グループにもread権限をつける場合は,ディレクトリの`SGID`ビットが絡むとみて良いでしょう.以下は`man 2 chmod`にある記載です. ![](https://i.imgur.com/u6LBNXr.png) では,この`flag`が書き出されるディレクトリはどのようなパーミッションでしょうか.それは112~114行目を見れば分かります.`root:root, 0700`ですね.普通に考えたら,ここで所有権が`root`になるため,`SGID`ビットは立てられません. ## 着眼点3 3つ目に着目すべきは,わざわざ`key`による読み込みを行っていることです. 115行目では`key`を`open()`,`read()`しています.しかし`key`はユーザが配置しておくファイルなので,内容を`"\0\0..."`などとすることもでき,XORする意味があまり感じられません.つまり「`open()`と`read()`は別の用途で必要だけど,わざとらしさを回避するためにXORする機能をもたせた」とも考えられます. さて,そのような場合に使うべき筆頭は`pipe`ですね.この`key`がもし名前付き`pipe`なら,`pipe`にデータが書き込まれるまで,親プロセスは`read()`が返ってこず動作を一旦停止させられるはずです. ## 着眼点4 4つ目に着目すべきは,`give_flag()`のコードではなく,問題文です. シングルコアで動作していると書いてあるため,Race Conditionでの攻略は分が悪く,信頼度の高い他の方法を探すべきなように見えます. ## 総合的に つまり,流れとしては以下の3ステップです. 1. 親プロセスは`root:root, 0700`の配下にいる 2. `key`を開いて読み込む.`pipe`だった場合は`read()`時にwaitするはず 3. `read()`してから,XORして`root:???, 0640`で`flag`を保存する ステップ2~3の間に,親プロセスのいるディレクトリが変わる可能性はないか考えてみましょう.もしディレクトリを差し替えられるなら,`root:root, 0700`なディレクトリから`1339:1339, 02777`なディレクトリに差し替えれば,全て解決します. しかし,着眼点4よりRaceではないようです.そんなことはできるのでしょうか. # pivot_root 普通に考えたら,他のプロセスのカレントディレクトリを変更する方法(しかもroot権限のプロセス)は有り得ない,という先入観がありますね.私も当時はそのように思っていました. しかし,他のプロセスのカレントディレクトリを差し替えることができるシステムコールが少なくとも1つ存在するのです.それが,`pivot_root`です. https://linuxjm.osdn.jp/html/LDP_man-pages/man2/pivot_root.2.html ![](https://i.imgur.com/9RibLMd.png) 本来は,Linuxが起動した直後,`initramfs`などの一時的なファイルシステムから,実際の(ディスク上の)ファイルシステムへ,ルートファイルシステムを差し替えるためのシステムコールだそうです. しかし古い方のファイルシステムのどこかを掴んでいるプロセスが他にあれば,新しい方のファイルシステムを掴むよう差し替えなければなりません.このため,全プロセスに対して処理を行うシステムコールになっているようです. 詳細な内容は各自読んでいただければよいのですが,「バグ」のところを良く読んでください. ![](https://i.imgur.com/ujqwVXs.png) 逆説的なのでちょっと違和感がありますが,「システムの他のプロセス全てのルートディレクトリとカレントディレクトリを差し替える」と書かれています. 後はこのシステムコールを用いて,親プロセスのカレントディレクトリを差し替える,これがこの問題を解くための鍵だったようです. # Exploit 最終的に,以下のようなコードでフラグが得られました. 注意点がいくつかあるのでメモしておきます. - シンボリックリンク攻撃を使うために,外の世界における自身の`$PID`を特定する必要がある - `give_flag()`をオラクルのように使って,事前に特定しておく - `pivot_root()`でマウント名前空間を弄る必要がある - オリジナルの名前空間は権限的に弄れないので,`unshare()`でマウント名前空間を分離しておく必要がある - 但し`unshare(CLONE_NEWNS)`の実行には`CAP_SYS_ADMIN`が必要 - `unshare(CLONE_NEWNS|CLONE_NEWUSER)`することで対処可能(`CLONE_NEWUSER`により全ケーパビリティが復活する) - `pivot_root()`の新ファイルシステムを用意する必要がある - `mount()`で,そのディレクトリ自体を同じ位置にバインドマウントしておけば良い {%gist bata24/e74f3582115e4b9c10bc81d87822a5e2 %} 尚,私のコードは成功率が非常に低いため注意してください.ローカルでは体感だと50%以上の確率ですぐ成功しますが,リモートでは全然成功せず,16並列で10分ほどリトライし続けてようやくフラグが取れました. # 終わりに この問題はどうしても解きたかったので,大会終了後に運営へ直接DMしたところ,作問者をご紹介いただきました.また作問者の方も解法を快く教えて下さいました.運営の皆様に感謝いたします. また`pivot_root`は聞いたことがあったものの,これまでmanを読んだことはありませんでした.やはりシステムコールのmanはちゃんと読んでおかないとだめですね. 明日は私の[pythonの関数オブジェクトをいじる話](https://hackmd.io/@bata24/HyYQpp-6H)です. # 補足 この記事を公開したところ,作問者の方から成功率を上げる方法を教えていただきました. ![](https://i.imgur.com/RpiIc8D.png) 以下を訂正すると良いようです. - 事前に`target`ディレクトリへ移動しておき,`pivot_root(".", ".")`を使う - `sleep();`の場所がちょっと違う - 親が`chdir()`,`chown()`,`chmod()`を確実に完了するのを待つ 以下に修正したコードを貼っておきます.ほぼ100%成功します. {%gist bata24/ce49c7b55922c85d3561119922445d61 %}