archlinux / r3t0x@portfolio
ALL SYSTEMS NOMINAL /
← Back to archive · Misc

Securinets Friendly CTF 2026 / Misc WRITEUP

POSTMORTEM

One byte was wrong. The process has prepared several megabytes of excuses.

EVENTSecurinets Friendly CTF 2026CATEGORYMiscDIFFICULTYhardPOINTS999
misc

Walkthrough

Challenge

The player receives a stripped PIE executable, an ELF core dump, and a raw encrypted receipt. There is no remote service. The core was captured while a damaged SROP payload was executing inside runner.

The public prompt is intentionally limited to:

One byte was wrong. The process has prepared several megabytes of excuses.

Architecture

runner maps receipt.enc, creates an anonymous output page, installs a seccomp filter, and reads 0x3000 bytes into a 128-byte stack buffer. NX, PIE, full RELRO, and stripping are enabled; the stack canary is deliberately absent. The filter permits only read, write, rt_sigreturn, exit, and exit_group.

The executable contains these stripped instruction sequences:

pop rax
ret

syscall
ret

mov rax, [rdi]
xor rax, rsi
ror rax, cl
mov [rdx], rax
ret

The flag is padded to an eight-byte boundary. For each plaintext word P_i, the generator selects a 64-bit key K_i and rotation R_i, then writes:

C_i = ROL64(P_i, R_i) XOR K_i

to receipt.enc.

Exploit

The overflow replaces the saved return address with the following preamble:

pop_rax ; 15 ; syscall_ret ; fake_rt_sigframe

Syscall 15 is amd64 rt_sigreturn. Each fake frame restores:

RIP = mix_block
RDI = address of C_i
RSI = K_i
RCX = R_i
RDX = destination word in the output mapping
RSP = address of the next SROP preamble

mix_block computes ROR64(C_i XOR K_i, R_i), stores it, and returns through the restored RSP. The real frames process blocks in shuffled order. Their destination pointers restore the original flag order.

The final two frames perform write(1, output, flag_length) and exit(0). Eleven additional frame-shaped nodes form a plausible cycle but never reach the writer.

One real frame has exactly one byte changed in its saved RSP. Its decryption operation succeeds, but its final ret consumes a non-canonical address and raises SIGSEGV. Every later frame remains intact in the captured stack.

Intended solution

  1. Inspect the binary and core:

``bash file runner crash.core receipt.enc checksec --file=runner gdb -q runner crash.core ``

  1. Locate pop rax; ret, syscall; ret, and the five-instruction mixing sequence in the stripped executable.
  2. Find stack records shaped as pop_rax, 15, syscall_ret, frame and derive the PIE base from their runtime addresses.
  3. Interpret offsets 104, 112, 136, 144, 152, 160, and 168 in each amd64 signal frame as RDI, RSI, RDX, RAX, RCX, RSP, and RIP.
  4. Follow saved RSP pointers until the damaged edge is found.
  5. Enumerate one-byte replacements. A valid graph must decrypt every receipt word exactly once, write every output word exactly once, avoid cycles, reach one write frame, and then reach one exit frame.
  6. Emulate the mixing gadget for every recovered frame.

Run the organizer solver from this directory:

python3 solver.py --audit

It reports one structurally valid repair and prints:

Securinets{you_repaired_a_dead_process_and_it_still_had_better_stack_discipline_than_you}

Reference solver

The reference solver supplied with the writeup:

#!/usr/bin/env python3

import argparse
import struct
from dataclasses import dataclass
from pathlib import Path

from elftools.elf.elffile import ELFFile


FRAME_SIZE = 248
PREAMBLE_SIZE = 24
MASK64 = (1 << 64) - 1
POP_PATTERN = b"\x58\xc3"
SYSCALL_PATTERN = b"\x0f\x05\xc3"
MIX_PATTERN = b"\x48\x8b\x07\x48\x31\xf0\x48\xd3\xc8\x48\x89\x02\xc3"


def u64(data, offset=0):
    return struct.unpack_from("<Q", data, offset)[0]


def ror64(value, count):
    count &= 63
    if not count:
        return value & MASK64
    return ((value >> count) | (value << (64 - count))) & MASK64


@dataclass(frozen=True)
class Segment:
    address: int
    data: bytes
    flags: int

    @property
    def end(self):
        return self.address + len(self.data)


class CoreMemory:
    def __init__(self, path):
        self.segments = []
        with open(path, "rb") as handle:
            elf = ELFFile(handle)
            if elf.header["e_type"] != "ET_CORE":
                raise ValueError("the supplied file is not an ELF core")
            for segment in elf.iter_segments():
                if segment["p_type"] != "PT_LOAD" or not segment["p_filesz"]:
                    continue
                self.segments.append(
                    Segment(int(segment["p_vaddr"]), segment.data(), int(segment["p_flags"]))
                )

    def read(self, address, size):
        for segment in self.segments:
            if segment.address <= address and address + size <= segment.end:
                offset = address - segment.address
                return segment.data[offset : offset + size]
        raise KeyError(f"unmapped core range {address:#x}+{size:#x}")

    def writable_segments(self):
        return [segment for segment in self.segments if segment.flags & 2]


@dataclass(frozen=True)
class Frame:
    address: int
    rdi: int
    rsi: int
    rdx: int
    rax: int
    rcx: int
    rsp: int
    rip: int


@dataclass
class Solution:
    flag: bytes
    candidate_count: int
    pie_base: int
    broken_frame: int
    repaired_rsp: int
    chain: list


def executable_matches(path, pattern):
    matches = []
    with open(path, "rb") as handle:
        elf = ELFFile(handle)
        for segment in elf.iter_segments():
            if segment["p_type"] != "PT_LOAD" or not (int(segment["p_flags"]) & 1):
                continue
            data = segment.data()
            start = 0
            while True:
                offset = data.find(pattern, start)
                if offset < 0:
                    break
                matches.append(int(segment["p_vaddr"]) + offset)
                start = offset + 1
    return matches


def aligned_qwords(segment):
    first = (-segment.address) & 7
    for offset in range(first, len(segment.data) - PREAMBLE_SIZE + 1, 8):
        yield segment.address + offset, u64(segment.data, offset), u64(segment.data, offset + 8), u64(segment.data, offset + 16)


def identify_layout(binary_path, memory):
    pop_offsets = executable_matches(binary_path, POP_PATTERN)
    syscall_offsets = executable_matches(binary_path, SYSCALL_PATTERN)
    mix_offsets = executable_matches(binary_path, MIX_PATTERN)
    if not pop_offsets or not syscall_offsets or not mix_offsets:
        raise ValueError("required instruction sequences were not found")

    observations = []
    for segment in memory.writable_segments():
        for address, first, middle, third in aligned_qwords(segment):
            if middle == 15:
                observations.append((address, first, third))

    layouts = {}
    for _, runtime_pop, runtime_syscall in observations:
        for pop_offset in pop_offsets:
            base = runtime_pop - pop_offset
            if base <= 0 or base & 0xFFF:
                continue
            for syscall_offset in syscall_offsets:
                if base + syscall_offset == runtime_syscall:
                    layouts[(base, runtime_pop, runtime_syscall)] = 0

    for layout in list(layouts):
        _, runtime_pop, runtime_syscall = layout
        layouts[layout] = sum(
            first == runtime_pop and third == runtime_syscall
            for _, first, third in observations
        )

    if not layouts:
        raise ValueError("no SROP preamble layout was recovered")

    base, runtime_pop, runtime_syscall = max(layouts, key=layouts.get)
    if layouts[(base, runtime_pop, runtime_syscall)] < 4:
        raise ValueError("not enough matching SROP preambles")

    best_mix = max(
        (base + offset for offset in mix_offsets),
        key=lambda address: sum(
            memory.read(stage + PREAMBLE_SIZE + 168, 8) == struct.pack("<Q", address)
            for stage, first, third in observations
            if first == runtime_pop and third == runtime_syscall
            and _readable(memory, stage + PREAMBLE_SIZE, FRAME_SIZE)
        ),
    )
    return base, runtime_pop, runtime_syscall, best_mix


def _readable(memory, address, size):
    try:
        memory.read(address, size)
        return True
    except KeyError:
        return False


def collect_frames(memory, runtime_pop, runtime_syscall, runtime_mix):
    frames = {}
    for segment in memory.writable_segments():
        for address, first, middle, third in aligned_qwords(segment):
            if (first, middle, third) != (runtime_pop, 15, runtime_syscall):
                continue
            try:
                raw = memory.read(address + PREAMBLE_SIZE, FRAME_SIZE)
            except KeyError:
                continue
            frame = Frame(
                address=address,
                rdi=u64(raw, 104),
                rsi=u64(raw, 112),
                rdx=u64(raw, 136),
                rax=u64(raw, 144),
                rcx=u64(raw, 152),
                rsp=u64(raw, 160),
                rip=u64(raw, 168),
            )
            if frame.rip == runtime_mix or (
                frame.rip == runtime_syscall and frame.rax in (1, 60)
            ):
                frames[address] = frame
    return frames


def one_byte_apart(first, second):
    return sum(a != b for a, b in zip(first.to_bytes(8, "little"), second.to_bytes(8, "little"))) == 1


def walk(frames, start, broken, replacement):
    chain = []
    seen = set()
    current = start
    while current in frames and current not in seen and len(chain) <= len(frames):
        seen.add(current)
        frame = frames[current]
        chain.append(frame)
        current = replacement if current == broken else frame.rsp
    return chain


def validate_chain(chain, receipt_size, runtime_mix, runtime_syscall):
    block_count = receipt_size // 8
    if len(chain) != block_count + 2:
        return None

    mixes = chain[:block_count]
    writer = chain[block_count]
    exiter = chain[block_count + 1]
    if any(frame.rip != runtime_mix for frame in mixes):
        return None
    if not (writer.rip == runtime_syscall and writer.rax == 1 and writer.rdi == 1):
        return None
    if not (exiter.rip == runtime_syscall and exiter.rax == 60 and exiter.rdi == 0):
        return None
    if writer.rsp != exiter.address:
        return None

    cipher_base = min(frame.rdi for frame in mixes)
    output_base = min(frame.rdx for frame in mixes)
    expected_cipher = [cipher_base + index * 8 for index in range(block_count)]
    expected_output = [output_base + index * 8 for index in range(block_count)]
    if sorted(frame.rdi for frame in mixes) != expected_cipher:
        return None
    if sorted(frame.rdx for frame in mixes) != expected_output:
        return None
    if writer.rsi != output_base:
        return None
    if not ((block_count - 1) * 8 < writer.rdx <= block_count * 8):
        return None
    if any(not (1 <= (frame.rcx & MASK64) <= 63) for frame in mixes):
        return None
    return cipher_base, output_base, writer.rdx


def solve(binary_path, core_path, receipt_path):
    receipt = Path(receipt_path).read_bytes()
    if not receipt or len(receipt) % 8:
        raise ValueError("receipt length must be a non-zero multiple of eight")

    memory = CoreMemory(core_path)
    pie_base, runtime_pop, runtime_syscall, runtime_mix = identify_layout(binary_path, memory)
    frames = collect_frames(memory, runtime_pop, runtime_syscall, runtime_mix)
    if len(frames) < len(receipt) // 8 + 2:
        raise ValueError("too few candidate signal frames")

    candidates = []
    addresses = list(frames)
    for broken_address, broken in frames.items():
        if broken.rip != runtime_mix or broken.rsp in frames:
            continue
        for replacement in addresses:
            if not one_byte_apart(broken.rsp, replacement):
                continue
            for start in addresses:
                chain = walk(frames, start, broken_address, replacement)
                layout = validate_chain(chain, len(receipt), runtime_mix, runtime_syscall)
                if layout is not None:
                    candidates.append((broken_address, replacement, chain, layout))

    unique = {}
    for candidate in candidates:
        identity = (candidate[0], candidate[1], tuple(frame.address for frame in candidate[2]))
        unique[identity] = candidate
    candidates = list(unique.values())
    if len(candidates) != 1:
        raise ValueError(f"expected exactly one structural repair, found {len(candidates)}")

    broken_address, replacement, chain, layout = candidates[0]
    cipher_base, output_base, flag_length = layout
    output = bytearray(len(receipt))
    for frame in chain[: len(receipt) // 8]:
        source_index = (frame.rdi - cipher_base) // 8
        destination_index = (frame.rdx - output_base) // 8
        ciphertext = u64(receipt, source_index * 8)
        plaintext = ror64(ciphertext ^ frame.rsi, frame.rcx)
        struct.pack_into("<Q", output, destination_index * 8, plaintext)

    return Solution(
        flag=bytes(output[:flag_length]),
        candidate_count=len(candidates),
        pie_base=pie_base,
        broken_frame=broken_address,
        repaired_rsp=replacement,
        chain=chain,
    )


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--binary", default="attachments/runner")
    parser.add_argument("--core", default="attachments/crash.core")
    parser.add_argument("--receipt", default="attachments/receipt.enc")
    parser.add_argument("--audit", action="store_true")
    arguments = parser.parse_args()

    result = solve(arguments.binary, arguments.core, arguments.receipt)
    if arguments.audit:
        print(f"PIE base:       {result.pie_base:#x}")
        print(f"broken frame:   {result.broken_frame:#x}")
        print(f"repaired RSP:   {result.repaired_rsp:#x}")
        print(f"chain frames:   {len(result.chain)}")
        print(f"valid repairs:  {result.candidate_count}")
    print(result.flag.decode("ascii"))


if __name__ == "__main__":
    main()

Flag

Securinets{you_repaired_a_dead_process_and_it_still_had_better_stack_discipline_than_you}
Published 2026-09-28 · by r3t0x← Back to archive · Misc
0xSEARCH_INDEX

Trace a memory.

INDEX ONLINE0 addresses
↑↓ TRACEENTER OPENESC DISMISS