pwncollege_Dynamic Allocator Misuse

描述

The glibc heap consists of many components distinct parts that balance performance and security. In this introduction to the heap, the thread caching layer, tcache will be targeted for exploitation. tcache is a fast thread-specific caching layer that is often the first point of interaction for programs working with dynamic memory allocations.

1
glibc 堆由多个兼顾性能与安全的独立组件构成。在本次堆内存介绍中,线程缓存层 tcache 将成为漏洞利用的目标。tcache 作为快速的线程专属缓存层,通常是程序处理动态内存分配时的首要交互接口。

Introduction

堆是什么

以不同方式存在的内存类型

image.png 上述主要是在ELF头中的常见段,用于存储不同类型的内存数据,这些看着都是一些静态的数据,且大部分生命周期看着较短,反之,当需要存储动态分配且生命周期较长类型的内存数据,就需要引入一个特殊的结构了,它就是堆。最形象的例子比如“坦克大战”游戏,当击落一个坦克后,坦克消失,随即又动态地自动生成新的坦克,在代码中它可能以可变列表等形式定义;而在函数调用期间,堆和栈是有区别的,考虑下从代码角度来理解:

堆和栈之间的区别

(1)栈: 由编译器自动管理,采用后进先出(LIFO)的线性结构。当函数被调用时,栈会为局部变量、参数和返回地址预留连续内存空间。例如战斗时产生的子弹、坦克坐标等临时变量:

1
2
3
4
void combat() {
int bullet_count = 30; // 自动分配在栈上
// 函数结束时自动释放
}

(2)堆: 需要程序员显式申请(如malloc/new)和释放(如free/delete)。例如坦克对象的动态生成:

1
2
3
4
5
Tank* spawn_tank() {
Tank* new_tank = (Tank*)malloc(sizeof(Tank)); // 堆内存申请
new_tank->health = 100;
return new_tank; // 即便函数返回,对象依然存在
}

生命周期

  • 栈:与函数调用周期严格绑定。当spawn_tank()函数结束时,其栈帧中的临时计算数据(如坐标校验的中间变量)会被立即回收
  • 堆:坦克对象的内存会持续存在,直到显式调用destroy_tank(tank)。这使得多个函数可以跨作用域操作同一坦克对象,如:
1
2
3
4
5
void battle_loop() {
Tank* enemy = spawn_tank(); // 堆对象诞生
attack(enemy); // 跨函数调用
// 即使循环迭代,enemy对象仍然存在
}

总之,在调用函数时,对于栈而言,预留空间分配临时内存,但当函数返回后,该内存会被释放;而对于堆而言,在不同函数的多次调用期间,通过堆方式创建的数据/对象是保持存活的。

讨论堆之前的其他替代方案

mmap()

如果只是解决如何存储动态分配且生命周期较长类型的内存数据,用mmap()可以实现,并且支持不同函数的多次调用期间,该内存数据依然保持存活。即通过内存映射的方式,为目标变量等建立内存存储区域的映射,就相当于为其分配空间了。但从下述方面来看该方案有缺陷:

  • 带来的问题 (1)分配大小非常不灵活,必须是4096字节的倍数(因为受限于内存页固定大小)。 (2)速度慢,因为内存映射的每次创建、变更、解除,都必须通过内核来操作,在交由内核操作期间还要涉及到从用户空间切换到内核空间等流程。 (3)基于(2)来说造成的开销很大。

由于上述问题,我们需要一个更智能的方案。

更智能的方案-动态内存分配器的出现

image.png 上述最主要的问题就是分配空间不够灵活,且容易造成内存页的浪费,比如只需要16字节,但受限于内存映射机制,分配了4096字节的页。要解决这种问题,编程角度来看,可以编写某种库,包含实现灵活分配内存的函数。 比如用户需要为变量A分配128字节空间,库的函数会返回指向该页起始位置的指针,并标注该起始位置开始的128字节已被使用;接着用户又需要为另一变量B分配256字节空间,该库会像上述一样继续单独为B指向对应的页,在这个过程中确保用多少分配多少,不造成页资源的过度浪费。然后这个库还会提供一些其他函数管理内存,比如当用户不需要某个内存空间了,就会调用某个函数清空该内存空间。 第一个提出上述想法并实践的人: image.png 现在很多类型的malloc都是借鉴他的dlmalloc而设计与拓展的。在这里我们主要重点关注Linux的ptmalloc

注意

上述提到的智能方案实际上就说明了为什么会存在内存动态分配器(“allocator”),而这个动态分配器管理的内存空间在很多地方都被称之为“heap”,注意!这与数据结构中的堆并非同一个东西! image.png

heap做了什么

image.png 即使是简单的helloworld程序,也会用到heap,heap是软件构建过程中不可缺少的模块。

heap是如何运行的

image.png 注意:ptmalloc使用的是数据段而不是内存映射mmap!在x86出现之前,内存空间被严重分段,数据段也有严格的划分,但今天这个段在某种程度来说已经被整合到了heap中。heap主要由brksbrk来管理,这有点类似于栈中的ebpesp,与heap的操作相关。底层实现上,上述的一些函数几乎都是对内核的系统调用,内核映射内存空间,将其放置到指定的位置,实现的效果类似于mmap

追踪数据段的设置过程

实验-追踪heap初始化

1
2
3
4
5
6
7
char msg[] = "About to malloc()!";

int main(int argc, char **argv)
{
write(1, msg, strlen(msg));
malloc(16);
}

image.png 可以看到当调用write时,开始利用heap相关的系统调用为其分配了动态的内存空间。

  • brk(NULL):程序启动时,初始heap空间较小,brk(NULL) 通常用于获取heap的初始地址,为后续内存分配(如 malloc)做准备。返回值0x629852ea0000 表示当前heap内存的结束地址,注意该值是由内核决定。
  • brk(0x629852ec1000):将堆内存的末尾地址扩展到 0x629852ec1000,以扩大heap空间,0x629852ec1000 是目标地址,表示堆的新结束位置。这里的brk通常是因为程序接下来即将调用动态内存分配函数(如 malloc)。 所以基于上述,利用后者减去前者可以计算出此时分配了多少内存空间: image.png 同时可以看出正好是0x21个页大小,即十进制的33页,因为0x21000/0x1000=0x21=33

strace 是 Linux 系统下的一个​​调试工具​​,用于跟踪进程执行时与操作系统内核之间的交互(如系统调用、信号传递等)。通过它,开发者可以了解程序在底层如何与操作系统交互,帮助诊断性能问题、权限问题、文件访问错误或程序崩溃等场景。

实验-追踪malloc()分配动态内存空间前后的内存映射情况

1
2
3
4
5
6
7
8
9
char msg[] = "About to malloc()!";

int main(int argc, char **argv)
{
write(1, msg, strlen(msg));
sendfile(1, open("/proc/self/maps", 0), 0, 4000);
malloc(16);
sendfile(1, open("/proc/self/maps", 0), 0, 4000);
}

image.png 显然,在动态内存分配之前,内存映射中不存在heap,而在分配之后就有了。并且可以看到映射中的heap范围0x84200~0x86300正好对应brk()拓展后的地址范围。

但注意,现在的sendfile()不允许这样使用了,较新的版本不能直接读取/proc 伪文件,内核拒绝操作,会返回报错:“非法参数”。

实验-追踪malloc()分配大量内存时的情况

1
2
3
4
5
6
7
8
9
10
11
12
char msg[] = "About to malloc()!";

int main(int argc, char **argv)
{
write(1, msg, strlen(msg));
sendfile(1, open("/proc/self/maps", 0), 0, 4000);
malloc(16);
sendfile(1, open("/proc/self/maps", 0), 0, 4000);
malloc(0x10000);
malloc(0x10000);
malloc(0x10000);
}

image.png 可以发现后续指定分配大量内存的malloc()时,brk()又进一步拓展到更大的内存空间。

注意

如果malloc一次分配的内存超过了0x20ff8,malloc不再从堆中分配空间,而是使用mmap()这个系统调用从映射区寻找可用的内存空间。malloc 会使用 mmap来创建独立的匿名映射段。匿名映射的目的主要是可以申请以0填充的内存,并且这块内存仅被调用进程所使用。

总之,ptmalloc 在进行小规模分配时会切掉数据段的一些位,而在进行大规模分配时会使用 mmap() 。

heap的风险

image.png

如何检测风险

image.png 但注意glibc中有些选项会严重影响heap的性能,比如上面的第三个选项。

heap优化与安全性之间的矛盾

image.png 上面这幅图很形象地介绍了开发者与安全研究员之间的矛盾,对于任何库而言,函数调用、指令调用、内存分配的相关操作都会造成开销,开发者抱怨这种开销,他们想让动态内存加载器更快,于是不断尝试优化性能,但是在过度优化的过程中几乎忽略了安全性(比如创建缓存层),一旦被攻破,他们却又不得不修复这些安全问题。而新增的安全检查项又会增加新的开销,这使动态加载器开发者又开始抱怨太慢,从而又尝试优化,进而形成死循环。

导致heap滥用的原因

image.png 关于第三种方式,像栈一样,堆同样也存在类似的缓冲区溢出,一旦发生,就会造成堆元数据被破坏,导致堆管理混乱。

风险-内存泄露(Leak)

image.png 上述例子中,为指针blah分配了动态内存空间,但函数的最后并没有对其指向的空间进行释放。此时,该空间在数据段中会一直存在,直到整个程序进程终止前都不会被释放。而如果该内存空间的数据是敏感的信息,就可能造成内存泄漏的风险。

注意这里的Leak指的是一些敏感信息不小心丢了。

风险-内存资源耗尽

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
int i = 0;
char *a = 1;
while(a)
{
i++;
a = malloc(0x100000000);
printf("Allocated: %p\n", a);
}
print("%d\n", i);
}

代码中,将指针a作为循环条件,每次循环中如果malloc能够分配到空间,返回对应指向分配空间的指针,直到没有足够的空间分配,malloc会返回NULL,导致循环终止。 image.png

风险-释放后使用

实验-Use After Free堆块复用_Intput

image.png 代码中,指针user_input使用完后通过free()释放,但发现后面又再次使用了这个已释放的user_input,并且没有用如malloc()为它重新分配动态内存空间,这就会造成安全风险。接着通过getuid()来判断用户是否为root或strcmp()校验密码,满足其一则校验通过,接着判断校验标志是否为1,是则查看flag。加几个输出语句用来观察指针地址间的关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(){
char *user_input = malloc(8);
printf("user_input address: %p\n", user_input);

printf("Name?");
scanf("%7s", user_input);
free(user_input);

long *authenticated = malloc(8);
printf("authenticated address: %p\n", authenticated);
*authenticated = 0;

printf("Password?");
scanf("%7s", user_input);

if(getuid() == 0 || strcmp(user_input, "hunter2") == 0) *authenticated = 1;
if(*authenticated) sendfile(0, open("/flag", 0), 0, 128);
}

利用如下: image.png image.png 可以发现无论是root还是普通用户,输入错误密码就可以实现获取flag,这是为什么? 这段代码中存在 ​​use-after-free​​ 和 ​​堆块复用​​ 漏洞, user_input 初始分配 8 字节,随后被释放;authenticated 紧接着分配 8 字节,可能复用 user_input 释放的内存块(因 glibc 的 fastbins 机制,相同大小的堆块会被优先复用)。从上述输出结果来看,两个指针指向了同一地址。释放 user_input 后,程序仍用其读取密码。若 authenticated 复用了该内存,则通过 user_input 输入数据会直接覆盖 *authenticated 的值,这导致没有经过第一个if判断,就提早将*authenticated指向的值设置为非0,所以就能直接获取flag。 image.png

注意:free()释放目标后,原先分配的动态内存空间并没有完全被清除,而是设置free标记,并让指针指向NULL而已,所以指针和内存空间都仍然存在,并不是物理上的清除,这类似于栈在pop后没有完全清除原空间。

风险-内存信息泄露(Disclosure)

Memory Leak和Memory Disclosure的区别

​Memory Leak​ ​Memory Disclosure​
定义与本质 ​资源管理缺陷​​:程序分配内存后未正确释放,导致内存被永久占用。 ​信息泄露漏洞​​:程序意外将敏感内存数据暴露给攻击者。
​问题根源​​:代码逻辑错误(如忘记 free(),循环中未释放临时内存)。 ​问题根源​​:未初始化内存、越界读取、悬垂指针等导致内存残留数据被读取。
​示例​​:malloc() 后未调用 free(),内存池逐渐耗尽。 ​示例​​:打印未初始化的堆缓冲区,泄露其他用户的密码或密钥。
利用方式 无法直接利用​​:通常不会直接引发攻击,但会导致程序崩溃或系统拒绝服务(DoS)。 ​可直接利用​​:攻击者通过泄露的内存信息绕过安全机制(如 ASLR),或提取敏感数据(如密码、Cookie)。
​长期影响​​:内存耗尽后程序崩溃,影响可用性。 ​即时威胁​​:泄露的数据可能成为后续攻击的跳板(如构造 ROP 链)。
​典型场景​​:长时间运行的服务因内存泄漏逐渐变慢直至崩溃。 ​典型场景​​:printf("%s", uninitialized_buffer) 输出其他线程的密钥。
后果与危害 ​资源耗尽​​:进程占用内存持续增长,最终触发 OOM(Out-of-Memory)机制被系统杀死。 ​信息暴露​​:攻击者获取内存中的密码、加密密钥、堆地址(破坏 ASLR)、程序逻辑等。
​稳定性问题​​:服务中断、性能下降。 ​权限提升​​:结合其他漏洞(如堆溢出)实现代码执行或权限绕过。
​修复优先级​​:通常属于中低风险(除非导致严重 DoS)。 ​修复优先级​​:高风险,需立即修复。

总之,Leak是​​资源管理漏洞​​(自己丢东西);Disclosure是信息泄露漏洞​​(别人偷看你的东西)。

实验-Use After Free堆块复用_Output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <assert.h>
int main() {
char *password = malloc(8);
char *name = malloc(8);

printf("Password?");
scanf("%7s", password);
assert(strcmp(password, "hunter2") == 0);
free(password);

printf("Name? ");
scanf("%7s", name);
printf("Hello %s!\n", name);
free(name);

printf("Goodbye, %s!\n", name);
}

assert 是 C/C++ 标准库中用于 ​​运行时断言检查​​ 的宏,核心作用是在代码中嵌入“自检逻辑”,帮助开发者快速定位程序中的逻辑错误,条件为真​​:无操作,程序继续执行;​​条件为假​​:终止程序,打印错误信息(文件名、行号、条件表达式)。

与上一个实验类似,只是现在释放后的对象不再作为输入的参数,而是格式化输出的参数。 发现当最后输出name时,并不是来自于我们的输入,而是一个其他地方的值,且存在不可打印字符。尝试dump出十六进制数据: image.png 尝试在程序运行时查看内存映射情况: image.png 发现获取到的该值正好就是在heap的内存映射范围内,说明可以通过这种利用来泄露出heap中的其他数据。

但在较高版本的系统中可能因为各种不可抗因素没法成功,了解原理就行。

终极风险-heap元数据损坏

image.png 如果覆盖了元数据会怎么样? 一般来说,heap默认是相信它自己的元数据信息的,即使被篡改。因为动态内存加载器的设计是追求性能的,而如果要对元数据再设计灵活变通的校验方案,成本高昂。如果覆盖了元数据,很可能导致动态内存加载器行为失常,甚至控制它。

House系列的heap利用-概述

image.png 针对heap的元数据利用,黑客中还形成了自己的利用流派体系,如上述的House系列,其中部分技术至今为止还可能利用成功。基于此,越来越多黑客发现的相关新利用技术,也会继续沿用“House of xxx“这种命名方式。

重叠内存分配

image.png

heap滥用-tcache

image.png T-cache是线程本地缓存的简称,它是ptmalloc的一项特性,主要用于加速重复的单线程小型内存分配,通常是一页或更小。现代处理器几乎都是多线程了,应用程序对多线程的依赖也越来越高,导致动态内存分配器对多线程的支持与性能要求也非常重要。tcache是以单向链表的形式实现的,每个线程结构体都有一个tcache实例。结构体中,指针数组entries中的元素每一个都指向一个链表的开头,tcache_entry即之前被释放的分配,这些分配(列表)被称为bins,且有特定的大小。堆分配通常会四舍五入到最接近的某个固定大小的总和,不同大小的分配方案会进入到不同的列表(bins)。 image.png counts数组用于记录不同内存块的数量(例如 count_64:8 表示64字节的内存块有8个可用),entries指针数组用于管理不同大小的链表(例如 entry_32:&c 表示32字节链表的头节点地址指向tcache_entry C链表。struct tcache_perthread_struct *key是指针回指所属的管理结构(图中指向Ben实例,代表着从属关系),接着每个链表中的next指针表示指向下一个节点。其中,这里的每个链表都可以看做是一个allocation

在调用free()前,也就是没有任何heap被释放,tcache没有缓存,此时上面所有的counts元素都为0,所有的entries元素都指向空值。

其中,各个heap在分配时对应的大小分别为: image.png 接下来以如下顺序模拟各heap释放后,tcache结构体中发生的变化:

  • free(B)count_16: 1entry_16: &B
  • free(A)count_16: 2entry_16: &A 由于此时A和B都为16字节,tcache管理结构体的指向发生变化,且A和B建立联系,同时两者都标注为tcache管理结构体Ben的从属,这是为了辨认属于哪个cache: image.png可以发现这样的结构就像“数据结构”课程中的“单向链表”。
  • free(F): 与free(B)时同理。
  • free(E): 与free(A)时同理,然后E和F建立同样的联系。
  • free(E): 与free(A)时同理,然后C和E和F建立同样的联系。 image.png
  • free(D): 与free(B)时同理。
  • 最后,剩下没有释放的X、Y、Z,此时注意它们的nextkey均为NULL

tcache如何free

image.png 对于一个tcache_entry,当调用free()时,tcache_perthread_struct结构体会先为其选择正确的size,接着检查tcache_entry是否有从属于它,没有则说明未被释放,否则将其push到对应单链表中的头部,就像上述free(A)时,管理结构体不再指向之前的B而是新的A,最后将释放后的A添加上从属关系。

tcache如何allocate

image.png