glibc 2.43 上 House of Apple 2 FSOP 技术的交互式 GDB 演练,附带可复现的沙箱环境,涵盖 vtable 绕过、栈迁移和 ROP。
本仓库是一个自包含的演练场,灵感来自 pwn.college 的 File Struct Exploitation 模块。它并未引入 House of Apple 2 的新变体,只是解答了我的好奇心:该技术在新版 glibc 上表现如何,以及它是否仍是一条可行的利用路径。本文档提供了一个交互式 GDB 演练,读者可以配合沙箱一起操作,从而对该原语形成更直观的理解。所有实验均使用 glibc 2.43,即撰写本文时 Ubuntu 26.04 和 Fedora 44 所打包的版本。
面向文件流的编程(FSOP)。 这涉及操纵 glibc 文件流结构以劫持控制流。其中一种方式是通过破坏 _IO_FILE_plus 的 vtable 分派机制。现代 glibc 会校验该 vtable,因此将其替换为任意地址这种显而易见的方法行不通。
House of Apple 2 最初由 Roderick 提出,它通过使用一个合法的 _IO_FILE_plus vtable 来绕过这一限制,进而到达宽字符流机制,在那里次级 vtable 会被直接分派而不进行范围校验。这提供了一个 arbitrary call 原语,我们可以将其升级为栈迁移和 ROP 链。
本次探索假设我们可以覆写一个 FILE 结构,并且同时拥有堆泄漏和 libc 泄漏。目标二进制文件已经提供了这些条件。
沙箱运行 Ubuntu 26.04 LTS,为我们提供了一个现代环境来探索该技术。
镜像包含 GDB、pwndbg、pwntools、ropper 和 tmux。它还包含一个目标二进制文件,带有交互式菜单,用于调用诸如 fopen、fread、fwrite 和 fclose 等文件流操作。这为我们提供了一种便捷的方式,在调试和测试想法时操纵流。
使用以下命令构建并运行沙箱:``` ./build.sh ./run.sh
## 探索
让我们从检查 `_IO_FILE` 和 `_IO_FILE_plus` 结构开始:```c
pwndbg> ptype struct _IO_FILE
type = struct _IO_FILE {
int _flags;
char *_IO_read_ptr;
char *_IO_read_end;
char *_IO_read_base;
char *_IO_write_base;
char *_IO_write_ptr;
char *_IO_write_end;
char *_IO_buf_base;
char *_IO_buf_end;
char *_IO_save_base;
char *_IO_backup_base;
char *_IO_save_end;
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2 : 24;
char _short_backupbuf[1];
__off_t _old_offset;
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
__off64_t _offset;
struct _IO_codecvt *_codecvt;
struct _IO_wide_data *_wide_data;
struct _IO_FILE *_freeres_list;
void *_freeres_buf;
struct _IO_FILE **_prevchain;
int _mode;
int _unused3;
__uint64_t _total_written;
char _unused2[8];
}
pwndbg> ptype struct _IO_FILE_plus
type = struct _IO_FILE_plus {
FILE file;
const struct _IO_jump_t *vtable;
}
从实际角度来看,_IO_FILE_plus 是一个带有 vtable 指针的 _IO_FILE。这立刻显得很有趣:如果我们能控制这个指针,就可能重定向间接调用并劫持控制流。
要检查 vtable,让我们检查由 fopen 返回的 FILE 指针。```c
pwndbg> p *(struct _IO_FILE_plus *)0x37ecf010
$4 = {
file = {
_flags = 0xfbad2480,
_IO_read_ptr = 0x0,
_IO_read_end = 0x0,
_IO_read_base = 0x0,
_IO_write_base = 0x0,
_IO_write_ptr = 0x0,
_IO_write_end = 0x0,
_IO_buf_base = 0x0,
_IO_buf_end = 0x0,
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7f58a7f4b4a0 <IO_2_1_stderr>,
_fileno = 0x3,
_flags2 = 0x0,
_short_backupbuf = "",
_old_offset = 0x0,
_cur_column = 0x0,
_vtable_offset = 0x0,
_shortbuf = "",
_lock = 0x37ecf0f0,
_offset = 0xffffffffffffffff,
_codecvt = 0x0,
_wide_data = 0x37ecf100,
_freeres_list = 0x0,
_freeres_buf = 0x0,
_prevchain = 0x7f58a7f4b480 <_IO_list_all>,
_mode = 0x0,
_unused3 = 0x0,
_total_written = 0x0,
_unused2 = "\000\000\000\000\000\000\000"
},
vtable = 0x7f58a7f49030 <_IO_file_jumps>
}
该指针指向 `_IO_file_jumps` 表。

这是一组 21 个函数指针。文件流操作会根据执行路径通过不同的条目进行分发。
### 跟踪 `fwrite` 路径
在本次探索中,我将重点关注 `fwrite` 路径。在每个函数上设置断点并调用 `fwrite` 后,我们命中的第一个断点是 `_IO_file_xsputn`。

该调用发生在 `fwrite+216` 处。这与 [glibc 源码](https://elixir.bootlin.com/glibc/glibc-2.43/source/libio/iofwrite.c#L44) 相符:`_IO_sputn` 是一个通过 vtable 进行分发的宏,对于此流,它解析为 `_IO_file_xsputn`。```asm
0x00007fd5181d362a <+202>: mov rdx,rcx
0x00007fd5181d362d <+205>: mov rdi,rbx
0x00007fd5181d3630 <+208>: mov QWORD PTR [rbp-0x30],r8
0x00007fd5181d3634 <+212>: mov QWORD PTR [rbp-0x28],rcx
0x00007fd5181d3638 <+216>: call QWORD PTR [rax+0x38]
作为首次尝试,让我们用 desired_func - 0x38 覆盖 vtable 指针,并在 fwrite+216 处设置断点。```c
pwndbg> p &win
$3 = (<text variable, no debug info> *) 0x4019e1
pwndbg> p/x &win - 0x38
$4 = 0x4019a9
pwndbg> set ((struct _IO_FILE_plus *)0x5334010)->vtable = (void *)0x4019a9
pwndbg> b *fwrite+216
Breakpoint 4 at 0x7fd5181d3638: file ./libio/libioP.h, line 1042.

执行在到达断点之前就中止了。错误表明 glibc 在执行间接调用之前会验证 vtable 指针。让我们检查回溯,看看这发生在哪里。
`fwrite` 到达 `_IO_vtable_check`,该函数拒绝了伪造的 vtable 指针。

该实现包含一种接受外部 vtable 的机制,但它不在我们的控制之下。相关代码可在 [`vtables.c`](https://elixir.bootlin.com/glibc/glibc-2.43/source/libio/vtables.c#L504) 中找到。
### 理解 vtable 验证
当 `_IO_vtable_check` 被调用时已经太晚了,vtable 验证已经失败。回溯中更早的 `IO_validate_vtable` 帧才是关键部分,所以我们改为检查它。```c
pwndbg> disass IO_validate_vtable
❌️ No symbol "IO_validate_vtable" in current context.
GDB 无法将 IO_validate_vtable 解析为符号。查看源代码,我们可以看到它被内联到了 fwrite 中。```asm
0x00007fd5181d35f6 <+150>: lea rdi,[rip+0x1838e3] # 0x7fd518356ee0 <__io_vtables>
0x00007fd5181d35fd <+157>: mov rax,QWORD PTR [rbx+0xd8]
0x00007fd5181d3604 <+164>: mov r14,QWORD PTR [rbx+0xc8]
0x00007fd5181d360b <+171>: mov r15,QWORD PTR [rbx+0x28]
0x00007fd5181d360f <+175>: mov r12,QWORD PTR [rbx+0x20]
0x00007fd5181d3613 <+179>: mov rdx,rax
0x00007fd5181d3616 <+182>: sub rdx,rdi
0x00007fd5181d3619 <+185>: cmp rdx,0x92f
0x00007fd5181d3620 <+192>: ja 0x7fd5181d3780 <__GI__IO_fwrite+544>
仅当 vtable 指针落在 `[__io_vtables, __io_vtables + IO_VTABLES_LEN)` 范围内时才会被接受。因此我们不能随意将其指向任意位置。不过,这仍然是一个相当大的区域,包含多个跳转表,为我们提供了可探索的空间。
有效范围的开头如下:

## House of Apple 2
我们现在已经理解了基本机制及其主要约束:`_IO_FILE_plus` 的 vtable 必须指向 glibc 有效 vtable 区域内的某个位置。这阻止了显而易见的做法,但并没有完全堵死这条路。
House of Apple 2 通过宽字符流机制绕过了这一限制,从而到达第二个 vtable。这个第二个 vtable 不会以同样的方式被验证。让我们在 GDB 中跟踪这条路径,看看各个部分是如何连接起来的。
### 宽字符流机制
回到 `_IO_FILE`,有一个 `_wide_data` 字段指向一个 `_IO_wide_data` 结构。这个结构拥有自己的 vtable。```c
pwndbg> ptype struct _IO_wide_data
type = struct _IO_wide_data {
wchar_t *_IO_read_ptr;
wchar_t *_IO_read_end;
wchar_t *_IO_read_base;
wchar_t *_IO_write_base;
wchar_t *_IO_write_ptr;
wchar_t *_IO_write_end;
wchar_t *_IO_buf_base;
wchar_t *_IO_buf_end;
wchar_t *_IO_save_base;
wchar_t *_IO_backup_base;
wchar_t *_IO_save_end;
__mbstate_t _IO_state;
__mbstate_t _IO_last_state;
struct _IO_codecvt _codecvt;
wchar_t _shortbuf[1];
const struct _IO_jump_t *_wide_vtable;
}
它的布局看起来与 _IO_FILE 非常相似。它是 glibc 处理宽字符流机制的一部分。