Securinets Friendly CTF 2026 / Misc WRITEUP
POSTMORTEM
One byte was wrong. The process has prepared several megabytes of excuses.
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
- Inspect the binary and core:
``bash file runner crash.core receipt.enc checksec --file=runner gdb -q runner crash.core ``
- Locate
pop rax; ret,syscall; ret, and the five-instruction mixing sequence in the stripped executable. - Find stack records shaped as
pop_rax, 15, syscall_ret, frameand derive the PIE base from their runtime addresses. - Interpret offsets 104, 112, 136, 144, 152, 160, and 168 in each amd64 signal frame as
RDI,RSI,RDX,RAX,RCX,RSP, andRIP. - Follow saved
RSPpointers until the damaged edge is found. - Enumerate one-byte replacements. A valid graph must decrypt every receipt word exactly once, write every output word exactly once, avoid cycles, reach one
writeframe, and then reach oneexitframe. - 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}