Featured image of post PWN-5

PWN-5

二进制漏洞与利用——堆利用

[TOC]

前文:以下是我入门PWN的记录,欢迎各位前来观看,小弟领教!

在前面的章节中,我们已经粗略的了解了栈相关的知识点: ROP、shellcode、栈转移等。

内存中还有一块区域叫做堆(heap)。

接下来的一整章的内容,我们就来讲堆。

堆章节比较抽象,我还刻意加了很多图,但依旧比栈章节要更加抽象...,以至于作者也学的迷迷糊糊,看不懂的欢迎来评论区讨论。

Linux动态内存管理机制

堆的概述

堆的各类操作

什么是堆?

堆是程序用于分配动态内存的一段内存区域。 他独立的存在于内存中,介于程序内存 基地址和libc地址之间,从低地址向高地址生长,与用户打交道最多。

带你举个例子理解动态分配内存:

开一个统计表格,每人最多输入的最大内存长度是4096个字节,按照以往惯性思维去分配的话,你是不是应该每人都在之前填表时就已经分好了内存给他们了,但不是每个人都能用完这4096个字节的,有些人只输入一个字节,那就意味着就有4095个字节的内存区域被浪费,如果每个人都这样做或输入少于最大字节长度的内存的数据,是不是浪费更多?

对于这种现象的下一步应该是回收,怎么回收?

那么,有没有一种方法,能让程序根据用户所需要的内存长度大小来分配内存呢?并且不需要我们管理内存呢?有的,在libc中,我们可以通过malloc(size)来给用户分配一段长度为size的内存,通过free(ptr)来释放这段内存区域。 这些数据,被统一的存放在了堆中,维护这些数据的运行机制在glibc中,称之为ptmalloc

malloc函数

在glibc的malloc.c中,malloc的说明如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  malloc(size_t n)
  Returns a pointer to a newly allocated chunk of at least n bytes, or null
  if no space is available. Additionally, on failure, errno is
  set to ENOMEM on ANSI C systems.
  If n is zero, malloc returns a minumum-sized chunk. (The minimum
  size is 16 bytes on most 32bit systems, and 24 or 32 bytes on 64bit
  systems.)  On most systems, size_t is an unsigned type, so calls
  with negative arguments are interpreted as requests for huge amounts
  of space, which will often fail. The maximum supported value of n
  differs across systems, but is in all cases less than the maximum
  representable value of a size_t.

很长是吧?确实很长我自己写的更长,这我还是从wiki复制过来的相对描述简洁的了😄,那我再帮你解释一下。

malloc函数是用于在堆区申请一块连续的指定大小的内存块区域以void*类型返回分配的内存区域地址。

可以看出,malloc 函数返回对应大小字节的内存块的指针。它属于标准库的一部分,所以需要包含<stdlib.h>头文件才能使用它。

调用malloc,就需要接受一个参数,表示要分配的内存大小(以字节为单位)。它返回一个指向分配内存的指针;如果分配失败则返回NULL

1
2
3
int *ptr;
ptr = (int*)malloc(sizeof(int));
//使用 malloc(sizeof(int)) 分配了一个整型变量大小的内存空间。

在malloc作用生效、分配完内存后,我们就可以使用指针ptr来访问分配的内存空间。你可以将值存储在其中,读取其中的值,或者对其进行其他操作。

最后,当你不再需要分配内存时,应该使用free函数来释放它,以便系统回收,后续可以重新使用它。(free函数在下面会讲解到...)

1
2
free(ptr);  
// 释放之前分配的内存空间

此外,该函数还对一些异常情况进行了处理:

1、当n=0时,返回当前系统允许的堆的最小内存块。

2、当n为负数时,由于在大多数系统上,size_t是无符号数!!所以程序就会申请很大的内存空间,但通常来说都会失败,因为系统没有那么多的内存可以分配。

free函数

在 glibc 的malloc.c中,free 的说明如下

1
2
3
4
5
6
7
8
      free(void* p)
      Releases the chunk of memory pointed to by p, that had been previously
      allocated using malloc or a related routine such as realloc.
      It has no effect if p is null. It can have arbitrary (i.e., bad!)
      effects if p has already been freed.
      Unless disabled (using mallopt), freeing very large spaces will
      when possible, automatically trigger operations that give
      back unused memory to the system, thus reducing program footprint.

可以看出,free 函数会释放由 p 所指向的内存块。这个内存块有可能是通过 malloc 函数得到的,也有可能是通过相关的函数 realloc 得到的。

此外,该函数也同样对异常情况进行了处理:

1、当 p 为空指针时,函数不执行任何操作。

2、当 p 已经被释放之后,再次释放会出现乱七八糟的效果,这其实就是 double free(在后面章节会提到)。

3、除了被禁用 (mallopt) 的情况下,当释放很大的内存空间时,程序会将这些内存空间还给系统,以便于减小程序所使用的内存空间。

calloc函数

calloc和malloc函数相似,都需要包含<stdlib.h>头文件才能使用它。

calloc函数用于动态分配内存空间,并将分配的内存空间初始化零

看回malloc就可以发现,它在分配内存空间时是需要将内存手动清零的。而calloc会在分配内存时自动将内存清零

调用calloc函数,它会接受两个参数,第一个参数表示要分配的元素个数,第二个参数表示每个元素的大小(字节单位)。它返回一个指向分配内存的指针,如果分配失败就返回NULL

1
2
3
int *ptr;  
ptr = (int*) calloc(10, sizeof(int));
//使用 calloc(10, sizeof(int)) 分配了一个包含 10 个整型变量的内存空间,并将其初始化为零。

分配完成后,我们就可以使用指针ptr来访问分配的内存空间,可以将值储存进其中,然后进行其他操作。

1
2
*ptr = 10;  // 将整数值 10 存储到分配的内存中  
printf("*ptr = %d\n", *ptr);  // 打印出分配的内存中的值

最后就是释放不再需要使用这个内存时,用free释放掉...

1
2
free(ptr);  
// 释放之前分配的内存空间

注意哦,calloc和malloc函数在分配内存时必须在使用之前进行初始化,否则其内容是未定义的。

calloc分配的内存会比calloc更多一些,用于额外存储某种信息...。因此如果你只需要分配单个元素的空间并且不需要清零功能,那malloc会更加高效些。

realloc函数

作用:当malloc函数或者calloc函数申请的空间或者数组的空间不够大或太大时就可以用realloc函数对空间的大小进行调整。

头文件需要包含<stdlib.h>,有些编译器是需要<malloc.h>。

1
void *realloc(void *mem_address, unsigned int newsize);

其实realloc函数就是在已经分配的内存区域中重新分配一块指定大小的空间,如果原有的内存区域不够,那就会重新分配一个更大的内存区域。

使用规则:当new_size大于旧的容量时,会对其进行扩容操作:

1、首先会考虑在最近的地方进行扩容,就近扩容,新的内存区域和旧的内存区域的起始地址相同,同时保留旧的内存区域的数据成员,但新内存的容量是new_size。

记得要考虑旧堆块(old_size)的物理后方有足够空闲空间(即 next chunk 是空闲且大小 ≥ added_size),就会直接在原地扩容,新内存的起始地址和旧地址完全相同。

你要是还看不懂就要压力你了,这就相当于你电脑(原1T内存)扩容,现在又加了块硬盘1T,那么现在新内存就是2T。

2、当就近扩容无法进行,即无法就近申请一块new_size的连续的新地址,此时将申请一块新的容量为new_size的地址,然后将原有数据从头到尾拷贝到新的内存区域。

相当于你电脑里面没有扩容硬盘槽了,然后我把你旧电脑全部数据复制粘贴到又充足扩容地方的新电脑上。

realloc函数接受两个参数,第一个参数是指向重新分配的内存块的地址,第二个参数是要为新的内存块分配字节数(就是确定扩容完之后的大小的意思)。

先看回上图...

realloc第二个参数是有点误导性的,它不是确定你要扩容多少,不是确定added_size的大小,而是确定new_size的大小哦,这千万不要理解错😄。

realloc的使用方法:

首先你已经通过这三个函数去分配了一个内存块,并将这个内存块的地址存储在一个指针变量中。

使用realloc函数来重新分配内存块。将指针变量作为第一个参数变量,将new_size(单位是字节数)作为第二个参数。

确实有点难理解,在这里举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>
#include <stdlib.h>
#pragma warning(disable:4996)

int main()
{
	int current_size = 10;
	int* ptr = (int*)malloc(current_size * sizeof(int));  // 分配一个包含10个整数的内存块  
	// ... 在内存块中进行操作  
	for (int i = 0; i < 10; i++)
	{
		ptr[i] = i;
	}
	printf("当前分配内存空间大小:%d\n", current_size);
	printf("当前分配内存空间地址:%X\n", ptr);
	printf("当前分配空间保存的值:");
	for (int i = 0; i < 10; i++)
	{		
		printf("%d ", ptr[i]);
	}
	printf("\n=======================================================\n");

	int new_size = 20;  // 重新分配更大的内存块  

	int* new_ptr = (int*)realloc(ptr, new_size * sizeof(int));
	if (new_ptr != NULL) // 检查 realloc() 是否成功  
	{
		ptr = new_ptr;  // 将指针指向新的内存块 
	}
	else
	{
		// 处理重新分配失败的情况  
		printf("处理重新分配失败的情况\n");
	}

	printf("新分配内存空间大小:%d\n", new_size);
	printf("新分配内存空间地址:%X\n", new_ptr);
	printf("新分配空间保存的值:");
	for (int i = 0; i < 10; i++)
	{
		printf("%d ", new_ptr[i]);
	}
	//free(ptr);//err
	free(new_ptr);
	printf("\n=======================================================\n");
	return 0;
}

运行结果(kali示例)

这里我运行了三次(其实一次也够了😄)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(base) ┌──(kali㉿kali)-[~/Desktop/output]
└─$ '/home/kali/Desktop/output/test' 
当前分配内存空间大小:10
当前分配内存空间地址:4856C2A0
当前分配空间保存的值:0 1 2 3 4 5 6 7 8 9 
=======================================================
新分配内存空间大小:20
新分配内存空间地址:4856C6E0
新分配空间保存的值:0 1 2 3 4 5 6 7 8 9 
=======================================================
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(base) ┌──(kali㉿kali)-[~/Desktop/output]
└─$ '/home/kali/Desktop/output/test' 
当前分配内存空间大小:10
当前分配内存空间地址:2126F2A0
当前分配空间保存的值:0 1 2 3 4 5 6 7 8 9 
=======================================================
新分配内存空间大小:20
新分配内存空间地址:2126F6E0
新分配空间保存的值:0 1 2 3 4 5 6 7 8 9 
=======================================================
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(base) ┌──(kali㉿kali)-[~/Desktop/output]
└─$ '/home/kali/Desktop/output/test' 
当前分配内存空间大小:10
当前分配内存空间地址:DF3F2A0
当前分配空间保存的值:0 1 2 3 4 5 6 7 8 9 
=======================================================
新分配内存空间大小:20
新分配内存空间地址:DF3F6E0
新分配空间保存的值:0 1 2 3 4 5 6 7 8 9 
=======================================================

可以发现旧地址和新地址有点相似,如4856C2A0和4856C6E0,在扩容前后,地址前五位无变化,仅后三位变化了(新地址和原地址属于同一个内存页 / 同一片堆区域),这也更进一步说明了“realloc扩容”的确是依照“就近优先”的分配策略。

但如果说后方的物理地址不太够了,那就另当别论了,不是在旧地址基础上扩容了。

完成了对新内存块的操作,那最后就是释放了。

1
2
free(ptr);  // 释放之前分配的内存块
free(new_ptr);

内存分配背后的系统调用

初步了解了free、malloc、realloc、calloc函数之,这些是我们在动态地申请和释放内存时,都经常会使用的,但是他们只是包含在<stdlib.h>头文件里的标准函数,和printf这些都是在一个头文件库里的东西,所以说它们并不是真正与系统交互的函数。

这些函数背后的系统调用主要是 (s)brk 函数以及 mmap, munmap 函数。

如下图所示,我们主要考虑对堆进行申请内存块的操作:

这张图清晰展示了Linux 环境下 C 语言动态内存分配的调用层级,从应用层到内核的完整调用链如下:

  1. 应用层(Application)
  • 编写的用户程序通过调用 malloc(或 calloc/realloc)来申请堆内存。
  • 这部分代码是系统无关的,在不同平台(Linux/Windows/macOS)上写法一致。
  1. 标准库层(malloc)
  • malloc 是 C 标准库(glibc 等)提供的封装函数,属于系统无关的库代码。
  • 它会根据分配场景选择两种底层系统调用:
    • 小内存分配:调用 __brk,通过扩展堆段(heap)来分配内存。
    • 大内存分配:调用 __mmap,通过内存映射(memory mapping)来分配匿名页。
  1. 库陷阱层(__brk /__mmap)
  • 这是系统相关的库代码,负责将库函数调用转换为内核系统调用的格式,触发内核态切换。
  • __brk 对应 sys_brk__mmap 对应 sys_mmap_pgoff
  1. 内核层(sys_brk /sys_mmap_pgoff)
  • 这是内核提供的系统调用,真正执行物理内存的分配、虚拟地址空间的管理。
  • sys_brk:调整进程堆的 break 指针,扩展或收缩堆段。
  • sys_mmap_pgoff:创建匿名内存映射,分配独立的虚拟内存区域(适合大块内存,避免堆碎片)。

(s)brk函数

在堆中,操作系统为此提供了brk函数进行管理内存,glibc库提供了(s)brk函数,我们可以通过brk的大小来向操作系统申请内存。

初始时,堆的起始地址 start_brk 以及堆的当前末尾 brk 指向同一地址。根据是否开启 ASLR,两者的具体位置会有所不同

  • 不开启 ASLR 保护时,start_brk 以及 brk 会指向 data/bss 段的结尾。
  • 开启 ASLR 保护时,start_brk 以及 brk 也会指向同一位置,只是这个位置是在 data/bss 段结尾后的随机偏移处(正是因为ASLR 保护才发生空间地址随机化的)。

我这里展示下32 位 Linux 系统中典型的进程虚拟地址空间分布(用户态 3GB + 内核态 1GB):

  1. 内核空间(Kernel Space)

地址范围0xC0000000 ~ 0xFFFFFFFF(1GB)

特性:仅内核态可访问,存放内核代码、数据及页表等核心资源,TASK_SIZE 标记了用户态与内核态的地址边界。

  1. 栈(Stack)

增长方向:向下(高地址 → 低地址)

大小限制:由 RLIMIT_STACK 控制(默认约 8MB),用于存储函数调用栈、局部变量、函数返回地址等。

安全特性:存在随机栈偏移(Random Stack offset),用于缓解栈溢出攻击。

  1. 内存映射段(Memory Mapping Segment)

增长方向:向下

功能:用于文件映射(如动态链接库 /lib/libc.so)和匿名内存映射(如大内存分配、共享内存)。

安全特性:存在随机 mmap 偏移(Random mmap offset),同样用于地址空间布局随机化(ASLR)。

  1. 堆(Heap)

增长方向:向上(低地址 → 高地址)

管理方式:由 brk/sbrk 系统调用调整 program breakstart_brk 为堆起始地址),malloc 等函数基于此实现动态内存分配。

安全特性:存在随机 brk 偏移(Random brk offset)

  1. BSS 段(Block Started by Symbol)

功能:存放未初始化的全局 / 静态变量,程序加载时会被内核自动清零。

示例const int b; 或未初始化的 static int a;

  1. 数据段(Data Segment)

功能:存放已初始化的全局 / 静态变量,由程序员在代码中显式赋值。

示例static const int a = 1;

  1. 代码段(Text Segment)

功能:存放 ELF 可执行文件的机器指令,只读且可共享,防止程序意外修改自身指令。

(这里是唠嗑,不想看的跳过这蓝色的部分...)

停停!!!要晕了😵...,这怎么比栈还抽象啊,这里我给你加深一下理解,讲一下我们所学的PWN知识内容和现实零部件、软硬件的关系:

1、整体结构(那个图3+1GB的虚拟地址空间,32位系统典型布局)

1GB Kernel Space:

内核空间,硬件对应:SoC + RAM + 所有外设的控制权;字面意思就是电脑手机的核心了,是电子设备外设的最高控制权,触及到系统核心代码运行区,在电脑相当于管理CPU、内存、屏幕摄像头等的所有硬件、APP资源调度、控制权限等等。(系统核心,拿到这里就是 root 权限)

3GB User Space:

用户空间;硬件对应:运行内存(RAM)

运行内存条

作用:普通 APP / 进程的代码和数据都存在这里,和内核隔离。

2、用户空间

Stack:

在硬件中,它位于运存条内的一块固定分区,特点是从高地址向低地址增长,后进先出,是缓冲区溢出最经典的攻击目标,存储函数调用、局部变量、返回地址等。

Memory Mapping Segment:

位于运存条RAM中,加载动态链接库(比如libc)、文件映射、共享内存,特点是会将参数的地址进行随机化(ASLR),防止libc基地址泄露。

Heap:

也位于运存条RAM中,进行着动态分配内存(malloc/free),同时也会存在着堆漏洞(UAF、Off-By-One、House of系列等),特点是低地址向高地址增长,内存管理复杂。

BSS:

位于运存条RAM中,存放原始的、未初始化的全局变量和静态变量,初始值为0,特点是程序加载时由系统清零,体积小、攻击场景较少。

Data Segment:

位于运存条RAM中,和BSS截然相反,这存放已初始化的变量,特点是包含可读可写数据,有时可被利用来篡改全局状态。

Text Segment:

这里不太一样,先在闪存(ROM/UFS),运行时再加载到运存条RAM中,它是存放程序的可执行代码(指令),通常是只读的,防止被篡改;特点:是程序的 “代码本体”,Pwn 中常通过修改 GOT/PLT 来劫持执行流到这里的代码片段(ROP)

上面提到的芯片都是焊在电子产品如手机主板上的,是主板上最显眼的大芯片之一:

RAM(运行内存 / 运存)

  • 位置:通常和 SoC(主芯片,骁龙 / 天玑 / A 系列) 叠封在一起(PoP 封装),或者紧邻 SoC 放置
  • 外观:小块方形芯片,常见容量 8GB/12GB/16GB,是手机 “临时工作区”
  • 作用:程序运行时才加载数据,断电后数据清空

ROM/UFS(闪存 / 存储)

  • 位置:主板另一侧或 SoC 周边,是独立的长方形芯片
  • 外观:比 RAM 稍大,常见容量 128GB/256GB/512GB,是手机 “仓库”
  • 作用:永久存储系统、APP、照片、文件,断电后数据不丢失

到这里你似乎有点理解了,我们开始将PWN和现实的硬件建立联系,我下载一个”王者荣耀“高达30GB,这里用到了ROM/UFS(闪存 / 存储)——Text Segment段,将王者荣耀APP里的运行程序代码等东西静态存储到了Text Segment里面,欸?我开启游戏了,这里用到的就是RAM(运行内存 / 运存)——堆栈、动态映射等,那运存不够大就运行不了”王者荣耀“了。(你应该去转转换新手机...)

听明白了吗?

好,我们讲回sbrk。

在堆中,操作系统为此提供了brk函数进行管理内存,glibc库提供了sbrk函数,我们可以通过brk的大小来向操作系统申请内存。

初始时,堆的起始地址 start_brk 以及堆的当前末尾 brk 指向同一地址。根据是否开启 ASLR,两者的具体位置会有所不同

  • 不开启 ASLR 保护时,start_brk 以及 brk 会指向 data/bss 段的结尾。
  • 开启 ASLR 保护时,start_brk 以及 brk 也会指向同一位置,只是这个位置是在 data/bss 段结尾后的随机偏移处(正是因为ASLR 保护才发生空间地址随机化的)。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/* sbrk and brk example + 自动打印heap映射 */
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

// 打印当前进程的heap映射
void print_heap_maps() {
    FILE *fp = fopen("/proc/self/maps", "r");
    if (!fp) {
        perror("fopen failed");
        return;
    }
    char buf[256];
    printf("===== 当前heap内存映射 =====\n");
    while (fgets(buf, sizeof(buf), fp)) {
        if (strstr(buf, "heap")) { // 只打印heap相关行
            printf("%s", buf);
        }
    }
    fclose(fp);
    printf("============================\n");
}

int main()
{
    void *curr_brk, *tmp_brk = NULL;

    printf("Welcome to sbrk example:%d\n", getpid());

    /* 初始程序断点 */
    tmp_brk = curr_brk = sbrk(0);
    printf("Program Break Location1:%p\n", curr_brk);
    print_heap_maps(); // 打印初始heap映射
    getchar();

    /* 扩展程序断点 */
    brk(curr_brk+4096);
    curr_brk = sbrk(0);
    printf("Program break Location2:%p\n", curr_brk);
    print_heap_maps(); // 打印扩展后heap映射
    getchar();

    /* 恢复程序断点 */
    brk(tmp_brk);
    curr_brk = sbrk(0);
    printf("Program Break Location3:%p\n", curr_brk);
    print_heap_maps(); // 打印恢复后heap映射
    getchar();

    return 0;
}

需要注意的是,在每一次执行完操作后,都执行了 getchar() 函数,这是为了我们方便我们查看程序真正的映射。

运行结果(示例,因为存在ASLR保护,每次运行地址不一样,所以建议各位自己运行试一试):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Expl0rer.Ct@ubuntu:~/Desktop/output$ 
Welcome to sbrk example:4415
Program Break Location1:0x560a038ea000
===== 当前heap内存映射 =====
560a038c9000-560a038ea000 rw-p 00000000 00:00 0                          [heap]
============================

Program break Location2:0x560a038eb000
===== 当前heap内存映射 =====
560a038c9000-560a038eb000 rw-p 00000000 00:00 0                          [heap]
============================

Program Break Location3:0x560a038ea000
===== 当前heap内存映射 =====
560a038c9000-560a038ea000 rw-p 00000000 00:00 0                          [heap]
============================

在第一次调用 brk 之前

从上面的输出结果可以看出,并没有出现堆。因此

start_brk = brk = end_data = 0x560a038ea000

第一次增加 brk 后

从输出可以看出,已经出现了堆段

start_brk = end_data = 0x560a038ea000

brk = 0x560a038eb000

  • 0x560a038ea000是相应堆的起始地址
  • rw-p 表明堆具有可读可写权限,并且属于隐私数据。
  • 00000000 表明文件偏移,由于这部分内容并不是从文件中映射得到的,所以为 0。
  • 00:00 是主从 (Major/mirror) 的设备号,这部分内容也不是从文件中映射得到的,所以也都为 0。
  • 0 表示着 Inode 号。由于这部分内容并不是从文件中映射得到的,所以为 0。

mmap/munmap函数

最全最细的演示代码(输出较长,包含mmap所有内存映射,自己编译后去运行吧。)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/* Private anonymous mapping example using mmap syscall */
#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

// 错误处理函数
void static inline errExit(const char* msg)
{
    printf("%s failed. Exiting the process\n", msg);
    exit(-1);
}

// 核心功能:打印所有内存映射,并标注mmap申请的地址范围
void print_all_mmaps(void* mmap_addr, size_t mmap_size, const char* stage) {
    FILE *fp = fopen("/proc/self/maps", "r");
    if (!fp) {
        perror("fopen /proc/self/maps failed");
        return;
    }

    char buf[256];
    unsigned long start, end;
    void* mmap_end = (char*)mmap_addr + mmap_size;
    printf("=========================================\n");
    printf("【%s】所有内存映射(重点看mmap申请的段):\n", stage);
    
    // 遍历所有映射段,标注目标段
    while (fgets(buf, sizeof(buf), fp)) {
        // 解析地址范围(如 7fe62861c000-7fe62863d000)
        if (sscanf(buf, "%lx-%lx", &start, &end) == 2) {
            printf("  %s", buf);
            // 如果当前段是我们mmap申请的,标注出来
            if (mmap_addr != NULL && start == (unsigned long)mmap_addr && end == (unsigned long)mmap_end) {
                printf("  ← 这是代码中mmap申请的132KB匿名段!\n");
            }
        }
    }
    printf("=========================================\n\n");
    fclose(fp);
}

int main()
{
    int ret = -1;
    size_t mmap_size = 132 * 1024; // 132KB
    void* mmap_addr = NULL;

    printf("Welcome to private anonymous mapping example::PID:%d\n", getpid());
    
    // 阶段1:mmap之前
    printf("===== Before mmap =====\n");
    print_all_mmaps(NULL, 0, "Before mmap"); // 无目标地址,只打印所有映射
    getchar();

    // 执行mmap申请132KB私有匿名内存
    mmap_addr = mmap(NULL, mmap_size, PROT_READ|PROT_WRITE, 
                MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (mmap_addr == MAP_FAILED)
        errExit("mmap");
    
    // 阶段2:mmap之后
    printf("===== After mmap =====\n");
    printf("mmap申请的内存:起始地址=%p,大小=%lu KB\n", mmap_addr, mmap_size/1024);
    print_all_mmaps(mmap_addr, mmap_size, "After mmap"); // 标注目标段
    getchar();

    // 执行munmap释放内存
    ret = munmap(mmap_addr, mmap_size);
    if(ret == -1)
        errExit("munmap");
    
    // 阶段3:munmap之后
    printf("===== After munmap =====\n");
    print_all_mmaps(NULL, 0, "After munmap"); // 无目标地址,验证段消失
    getchar();

    return 0;
}

在执行 mmap 之前

我们可以从下面的输出看到,目前只有. so 文件的 mmap 段。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Welcome to private anonymous mapping example::PID:6309
===== Before mmap =====
=========================================
【Before mmap】所有内存映射(重点看mmap申请的段):
  55b02e175000-55b02e177000 r-xp 00000000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02e376000-55b02e377000 r--p 00001000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02e377000-55b02e378000 rw-p 00002000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02f725000-55b02f746000 rw-p 00000000 00:00 0                          [heap]
  7fd899567000-7fd89974e000 r-xp 00000000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd89974e000-7fd89994e000 ---p 001e7000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd89994e000-7fd899952000 r--p 001e7000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd899952000-7fd899954000 rw-p 001eb000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd899954000-7fd899958000 rw-p 00000000 00:00 0 
  7fd899958000-7fd899981000 r-xp 00000000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b60000-7fd899b62000 rw-p 00000000 00:00 0 
  7fd899b81000-7fd899b82000 r--p 00029000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b82000-7fd899b83000 rw-p 0002a000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b83000-7fd899b84000 rw-p 00000000 00:00 0 
  7fff21eef000-7fff21f10000 rw-p 00000000 00:00 0                          [stack]
  7fff21feb000-7fff21fee000 r--p 00000000 00:00 0                          [vvar]
  7fff21fee000-7fff21ff0000 r-xp 00000000 00:00 0                          [vdso]
  ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]
=========================================

mmap 后

从下面的输出可以看出,我们申请的内存与已经存在的内存段结合在了一起构成了 0x7fd899b3f0000x7fd899b62000 的 mmap 段(第十五行)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
===== After mmap =====
mmap申请的内存:起始地址=0x7fd899b3f000,大小=132 KB
=========================================
【After mmap】所有内存映射(重点看mmap申请的段):
  55b02e175000-55b02e177000 r-xp 00000000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02e376000-55b02e377000 r--p 00001000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02e377000-55b02e378000 rw-p 00002000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02f725000-55b02f746000 rw-p 00000000 00:00 0                          [heap]
  7fd899567000-7fd89974e000 r-xp 00000000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd89974e000-7fd89994e000 ---p 001e7000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd89994e000-7fd899952000 r--p 001e7000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd899952000-7fd899954000 rw-p 001eb000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd899954000-7fd899958000 rw-p 00000000 00:00 0 
  7fd899958000-7fd899981000 r-xp 00000000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b3f000-7fd899b62000 rw-p 00000000 00:00 0 
  7fd899b81000-7fd899b82000 r--p 00029000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b82000-7fd899b83000 rw-p 0002a000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b83000-7fd899b84000 rw-p 00000000 00:00 0 
  7fff21eef000-7fff21f10000 rw-p 00000000 00:00 0                          [stack]
  7fff21feb000-7fff21fee000 r--p 00000000 00:00 0                          [vvar]
  7fff21fee000-7fff21ff0000 r-xp 00000000 00:00 0                          [vdso]
  ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]
=========================================

munmap

从下面的输出,我们可以看到我们原来申请的内存段已经没有了,内存段又恢复了原来的样子了(还是第十五行,你可以看到0x7fd899b82000还在,可是前面的 0x7fd899b3f000变回了0x7fd899b81000)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
===== After munmap =====
=========================================
【After munmap】所有内存映射(重点看mmap申请的段):
  55b02e175000-55b02e177000 r-xp 00000000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02e376000-55b02e377000 r--p 00001000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02e377000-55b02e378000 rw-p 00002000 08:01 1727265                    /home/ctfshow/Desktop/output/test
  55b02f725000-55b02f746000 rw-p 00000000 00:00 0                          [heap]
  7fd899567000-7fd89974e000 r-xp 00000000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd89974e000-7fd89994e000 ---p 001e7000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd89994e000-7fd899952000 r--p 001e7000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd899952000-7fd899954000 rw-p 001eb000 08:01 2361570                    /lib/x86_64-linux-gnu/libc-2.27.so
  7fd899954000-7fd899958000 rw-p 00000000 00:00 0 
  7fd899958000-7fd899981000 r-xp 00000000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b60000-7fd899b62000 rw-p 00000000 00:00 0 
  7fd899b81000-7fd899b82000 r--p 00029000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b82000-7fd899b83000 rw-p 0002a000 08:01 2361554                    /lib/x86_64-linux-gnu/ld-2.27.so
  7fd899b83000-7fd899b84000 rw-p 00000000 00:00 0 
  7fff21eef000-7fff21f10000 rw-p 00000000 00:00 0                          [stack]
  7fff21feb000-7fff21fee000 r--p 00000000 00:00 0                          [vvar]
  7fff21fee000-7fff21ff0000 r-xp 00000000 00:00 0                          [vdso]
  ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]
=========================================

After mmap 新增的 0x7fd899b3f000-7fd899b62000 段,在 After munmap 中消失;

After munmap 只保留了系统原本的 0x7fd899b60000-7fd899b62000 小段,和初始状态一致;

你申请的 0x7fd899b3f000-7fd899b60000 段被彻底释放,没有残留 → 这就是 “内存段恢复原样” 的核心依据。

简单说:0x7fd899b3f000 开头的那段是否存在 —— 申请后有,释放后无,只剩系统原本的小段,就证明恢复了(其他不变)。

堆的内存管理机制

堆是真无敌恶心....堆的管理机制相比于栈十分复杂,可以算是对我这样0基础的萌新杀手。

但是堆的漏洞比栈有更多的形式和利用方式,而且堆漏洞所需要的条件比栈更 少。 一般情况下栈溢出起码需要16个字节,也就是至少溢出到返回地址才能利用,但是堆的话只需要一个字节就可完成利用,甚至这个字节可以是个\x00,也就是空字节,nullbyte。

栈的话基本都会关闭一两个保护机制,堆的话一般全开。

CTFpwn的主流是堆利用。

堆块介绍

chunk

在了解ptmalloc的内存管理机制前,我们先了解一下堆块在内存中的存储形式。在内存中,堆是以一个个堆块构成的,这些堆块称之为chunk。

在64位系统中,堆块的大小是8字节对齐的,也就是说,我们申请一个15字节长度的堆块,实际到我们手中的用户可控的数据区域大小为16字节。

但是在管理中,一个堆块除了用户数据区外,还有头部字段,头部字段的长度为16字节。同时在64位系统中,一个堆块最小长度为32字节(包括头部),也就是说,我们分配一个1字节的堆块,他的实际长度是32字节。

堆块结构图

prev_size和size字段分别代表一个chunk对的大小,大小都是8字节,两个一共16字节,称之为chunk的头部字段。后面的user data区域是用户可以输入数据的地方。

chunk的大小8字节对齐,所以说对于分配器来说,0x80、0x81、0x82大小的堆块都是一样的,都为0x80大小。

可能你举得有点懵,但这是一种简化表述,核心是堆块大小必须满足 8 字节对齐,所以分配器会把不满足的大小调整到最近的 8 字节对齐的大小,这样就会出现多个请求大小对应同一个实际分配大小的情况。

为了节省空间,将size的最低三个bit设置为三个标志位。

从高到低分别为non_main_arenais_mmapprev_inuse,这里分别解释一下:

non_main_arena用来记录当前chunk是否不属于主线程,1表示不属于,0表示属于。

is_mmap表示当前chunk是否由mmap分配的,1表示属于,0表示不属于。

prev_inuse用来表示前面紧邻的那个chunk是否正在使用,0表示前面的chunk已经被释放,1表示正在被用户使用。

prevsize记录前面一个chunk的大小。这里注意,prevsize只有在前面的chunk被free掉的时候才生效,也就是说,只有在prev_inuse为0的时候,系统才把prev_size字段当作prevsize。

那么其他时候这个字段有用吗?没用的话不就浪费了八个字节?

有用的!如果chunk正在被使用,那么他会把下一个chunk的prevsize字段当作userdata。充分利用空间。

也就是说,如果我们申请一个0x80长度大小的区域,系统实际给我们0×90大小(0x10头部),如果我们申请0x88大小的区域,系统同样也会给我们0x90大小的区域(算头部),剩下的8字节,使用nextchunk的prevsize区域。因为,只有当一个chunk被释放的时候,nextchunk的prevsize才真正代表前一个chunk的大小,所以就这么设计了。


topchunk

topchunk是一个特殊的chunk,类似于bss字段未被分配的内存一样,比较原始的。

最开始时,程序的堆还未被使用,整个的堆区域属于一个很大的堆块叫做topchunk。当已经被使用的空间不够时,程序就会从topchunk中分割一块出来个程序使用。

堆块的管理

为了保证程序的快速运行,而且方便系统内存管理,所以ptmalloc将释放后的堆块根据其大小分成不同的bin。

fastbin:大小范围从0x20-0×80

smallbin:大小范围:0×90-0x400

Large bin:大小范围:0x410以上

unsortedbin:未被归类的bin,临时存储用,存放的堆块大小不一定多大,后续详细介绍。

chunk被free之后如图:

堆释放示意图

由于chunk被free了,所以按常理说用户不应该能够访问到这个chunk。于是乎在userdata区域存放一些用于管理内存的指针信息。

fastbin:单链表结构,只会用到fd这个指针;

small &unsortedbin:双向链表结构,fd和bk都用;

largebin:双向链表,fd、bk都用,同时还会用fd nextsize和bk nextsize。

堆块的合并操作

如果我们free掉一个堆块,(可能)会触发向前合并和向后合并。

堆

向前合并:检查当前chunk的prev inuse位,如果为0,则根据当前chunk的prev size找到prev chunk的头,两个堆块合并;

向后合并:检查当前chunk的next next chunk的prev inuse位(因为一个堆块的状态由他后面chunk的prev inuse位决定,所以确定next chunk的状态需要检查next next chunk的prev inuse位,怎么找? size就行),然后根据next chunk的状态决定是否合并。

变化示意图:




这四张图看得有点懵?说人话就是prev_inuse=0说明前面chunk被释放了,呃身体都没了你要头有什么用?我的话过重了...身体(chunk)被释放free没了应该头也跟着一起灰飞烟灭!!一起消失掉,和其它堆合并。(先向前合并再考虑向后合并

“合并” 的关系

向前合并(关键关联 prev_inuse = 0

若当前堆块的 prev_inuse = 0 时,说明前一个堆块是空闲的。此时,当前堆块可以:

1、读取前一个堆块的头部信息(通过当前堆块的 prev_size 字段,获取前一个堆块的大小)。

2、将 “当前堆块” 与 “前一个空闲堆块” 合并,形成一个更大的空闲堆块,减少内存碎片。

向后合并(与 prev_inuse 无直接关联)

1、“向后合并” 是指当前空闲堆块后一个相邻的空闲堆块合并,其判断条件是:后一个堆块是否为空闲(通过检查后一个堆块的 prev_inuse 标志)。

For instace,当前堆块是空闲的,若后一个堆块的 prev_inuse = 0(说明后一个堆块认为 “前一个堆块(即当前堆块)是空闲的”),则可以进行向后合并。

2、可见,“向后合并” 的核心是后一个堆块的 prev_inuse 标志,而非当前堆块的 prev_inuse

考虑到相对关系,简单来说对于当前你要讨论的堆头部来说,它的prev_inuse=0时,它本身就应该和前面的chunk合并。

而“向后合并” 的触发条件是后一个堆块的 prev_inuse = 0(表示后一个堆块允许与前一个堆块 —— 即当前堆块 —— 合并)。

结构体

aren

是一块结构体,用于管理bins。主线程创建的arena称之为main_arena,其他的叫threadarena。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct malloc state
{
	/* Serialize access.*/
	mutex_t mutex;
	int flags;
    /*Fastbins*/
	mfastbinptr fastbinsY[NFASTBINS];
	/* Base of the topmost chunk -- not otherwise kept in a bin*/
	mchunkptr top;
	/* The remainder from the most recent split of a small request */
	mchunkptr last remainder;
    /*Nromal bins packed as described above*/
	mchunkptr bins[NBINS * 2 - 2];
    /*Bitmap of bins*/
	unsigned int binmap[BINMAPSIZE];
    /*Linked list*/
	struct malloc state *next;
    /*Linked list for free arenas.*/
	struct malloc state *next_free;
	/* Memory allocated from the system in this arena.*/
	INTERNAL_SIZE_T_system_mem;
	INTERNAL_SIZE_T_max_system_mem;
}

各种内存块介绍

各种bins:

Fastbin

管理fastbin free chunk,单链表结构,FILO(最后一个进入fastbin链表的,会被放在头部)总共有十个fastbin链表,每个链表中fastbin的size一样,0x10递增。

大小属于fastbin的chunk被free掉时,不会改变next chunk的prev inuse位,也就是说不会被合并。

Fastbin

Unsortedbin

管理unsorted chunk,只有一个双向链 表。所有大小大于fastbin的chunk都会先被暂 时放入unsortedbin中,链表中的chunk大小不一样。

*注意:这里的指向箭头比较多

Smallbin

管理small chunk,由62个双向链表组成, 每个链表中的chunk大小一样,大小以0x10递 增。长得和unsortedbin差不多的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
+-------------------+      +-------------------+      +-------------------+
|     smallbin      | <--> |   free chunk 1    | <--> |   free chunk 2    | <--> ...
|  (size = 0x20)    |      |                   |      |                   |
+-------------------+      +-------------------+      +-------------------+
         |
         v
+-------------------+      +-------------------+      +-------------------+
|     smallbin      | <--> |   free chunk 1    | <--> |   free chunk 2    | <--> ...
|  (size = 0x30)    |      |                   |      |                   |
+-------------------+      +-------------------+      +-------------------+
         |
         v
       ... (更多 smallbin,大小递增)
         |
         v
+-------------------+      +-------------------+      +-------------------+
|     smallbin      | <--> |   free chunk 1    | <--> |   free chunk 2    | <--> ...
|  (size = 0x3F0)   |      |                   |      |                   |
+-------------------+      +-------------------+      +-------------------+

Largebin

管理large chunk,63个双向链表,FIFO。同一个双线链表中chunk大小可以不一样,但是在一定范围内,bins大小从小到大排列。

在此我们先不学习larbin attack的相关内容,所以看看就行,了解一下。比较少遇见Largebin的相关题。

Malloc运行流程

了解完各种bin之后,现在来看看这:

一、当我们调用malloc时,程序都干了些什么?

1、计算真正的堆块的大小(加上堆头部长度、对齐):

判断是否在fastbin范围内:

  • 确定在,检查对应大小的bin链表中有无chunk。

    • 有,那就分配给用户,至此完成。
  • 如果不在fastbin范围内,或者没用chunk可用。(两者满足一个或者都满足的话)

    • 继续判断是否在smallbin范围内:

      • 在smallbin范围内,检查对应大小的bin链表中有无chunk。
        • 有chunk,那就取出来给程序,至此完成。
      • 不在smallbin范围内,或者smallbin里面也没有chunk。这时候跳到unsortedbin的检查。
    • unsortedbin中有无chunk?

      • 有,从尾部取出第一个chunk,看看大小是否满足需求。

        • 满足,切分后大小是否大于minsize
          • 大于,再切分块,返回给用户,剩下的块放进unsortedbin。
          • 小于或等于minsize,直接返回给用户,完成。
        • 不满足大小需求,把这个块放入smallbin / largebin对应的链表中,继续遍历下一个块。
      • 没。unsortedbin的所有块都不满足,那此时就判断是否在largebin范围。

        • 是,检查对应的bin链表中有无符合的chunk。
          • 有符合的,找到满足需求最小的chunk,切分块返回,剩下的放进unsortedbin中。
        • 不在,那就再次遍历smallbin / largebin找best fit的chunk。
        • 我去?还是没用,那就从topchunk中切割。
        • ??搞什么鬼??topchunk也不够?那就mmap系统调用

二、当我们调用了free时,程序都干了些什么?

free的chunk大小属于fastbin吗?

  • 是,放进fastbin,至此完成。

  • 不属于,那就接着判断这个free的chunk是否是mmap分配的。

    • 是,那就调用munmap回收,完成。

    • 不是,那就接着判断前一个chunk是否是空闲的。

      • 是,那就向前合并

      • 不是,接着判断:后一个chunk是topchunk吗?

        • 是,那就和topchunk合并,至此完成。

        • 不是topchunk,那就判断:后一个chunk是free的吗?

          • 是,那就向后合并,然后放进unsortedbin,终于完成了。

堆动态保护机制的小总结

我的妈啊,难死我了,堆的内存管理机制比较复杂,多刷刷题吧,受不了了,刚开始就上强度了😫。

我这里有一个发自内心的疑问:堆的本质是指针指来指去吗?O(∩_∩)O

堆的本质可以理解为通过指针操作管理动态内存的机制,但 “指针指来指去” 只是其表现形式之一,更深层的本质是内存块的分配、释放和复用规则,以及攻击者通过破坏规则实现漏洞利用的过程。理解堆的核心在于掌握内存块的组织结构、堆管理器的行为逻辑,而非单纯的指针操作。

堆溢出

概述

堆溢出是指程序向某个堆块中写入的字节数超过了堆块本身可使用的字节数,(人话就是你放太多东西到这个堆块了,然后这个堆块容量有限要溢出东西了)所以导致了数据溢出,并覆盖到了物理相邻的高地址的下一个堆块。

串联一下栈溢出吧?它是从高地址到低地址生长的,自然而然就是当这个栈帧发生了数据溢出的时,就会覆盖到物理相邻的低地址的下一个栈帧。

堆溢出是一种特定的缓冲区溢出(还有栈溢出, bss 段溢出等)。但是其与栈溢出所不同的是,堆上并不存在劫持返回地址等能让攻击者劫持执行流程的数据,因此我们一般无法直接通过堆溢出来控制 EIP。

堆的结构比栈要相对复杂些,覆盖顺序依次为:

prev_inuse(bit0)→ is_mapped(bit1)→ non_main_arena(bit2);(堆块头部包含 prev_size、size,size 分标志位 + 真实大小,且有 3 个关键标志位)

比特位 名称 缩写 含义(核心)
bit 0 PREV_INUSE P 前一个堆块是否处于使用状态(1 = 占用,0 = 空闲)
bit 1 IS_MMAPPED M 当前堆块是否由 mmap 分配(1 = 是,0 = 不是)
bit 2 NON_MAIN_ARENA N 当前堆块是否属于非主线程的 arena(1 = 是,0 = 不是)
bit 3~63 The True chunk size - 当前堆块的真实大小(需 16 字节对齐)
  • prev_size
  • size,主要有三个比特位,以及该堆块真正的大小。
    • NON_MAIN_ARENA
    • IS_MAPPED
    • PREV_INUSE
    • the True chunk size
  • chunk content,从而改变程序固有的执行流。

了解传递顺序后,在后续可以利用堆中的机制(如 unlink 等 )来实现任意地址写入( Write-Anything-Anywhere)或控制堆块中的内容等效果,从而来控制程序的执行流。

简单堆溢出示例

将C语言源代码用C文件保存,名字命名为"test " 。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>
#include <stdlib.h>  
int main(void) 
{
  char *chunk;
  chunk = malloc(24);  
  puts("Get input:");
  gets(chunk);         
  return 0;
}

编译:

1
gcc -g -o test test.c -fno-stack-protector -no-pie

Gdb+pwndbg的调试指令:

1
2
3
4
# 断点1:malloc之后、gets之前(第9行,gets执行前)
pwndbg> b test.c:9  
# 断点2:gets之后(第10行,gets执行后)
pwndbg> b test.c:10  

接着r运行,弹出Gdb界面。

x/8xg 0x602260

为什么是0x602260是我们要看的堆地址?一开始我也不知道,我是先注入100个A后看它注入的地址才知道的,然后再调一变,因为编译过一次后堆地址都是定的(PIE关闭)

又或者你可以:

1
2
3
pwndbg> p chunk
$1 = 0x602260
#这可以避免有PIE的干扰

事先得到这个chunk的指针地址。

输入100个A。

A覆盖后,查看chunk的当前状态:

x/8xg 0x602260

这里可以看到全是A(0x41)。

漏洞利用思路

寻找堆分配函数

通常来说堆是通过调用 glibc 函数 malloc 进行分配的,在某些情况下会使用 calloc 分配。calloc 与 malloc 的区别是 calloc 在分配后会自动进行清空,这对于某些信息泄露漏洞的利用来说是致命的

1
2
3
4
calloc(0x20);
//等同于
ptr = malloc(0x20);
memset(ptr,0,0x20);

这里跟栈利用思路很相似,栈溢出漏洞利用就是找读取到栈的函数比如gets、strcpy等。

除此之外,还有一种分配是经由 realloc 进行的,realloc 函数可以身兼 malloc 和 free 两个函数的功能。

1
2
3
4
5
6
7
8
#include <stdio.h>
int main(void) 
{
  char *chunk,*chunk1;
  chunk = malloc(16);
  chunk1 = realloc(chunk,32);
  return 0;
}

(下面有关realloc的内容这里有点重复了,相当于复习的了)

realloc具有堆块的“扩容”作用,但其实它的操作并不是像字面意义上那么简单,其内部会根据不同的情况进行不同操作:

  • 当 realloc(ptr,size) 的 size 不等于 ptr 的 size 时
    • 如果申请 size > 原来 size
      • 如果 chunk 与 top chunk 相邻,直接扩展这个 chunk 到新 size 大小
      • 如果 chunk 与 top chunk 不相邻,相当于 free(ptr),malloc(new_size)
    • 如果申请 size < 原来 size
      • 如果相差不足以容得下一个最小 chunk(64 位下 32 个字节,32 位下 16 个字节),则保持不变
      • 如果相差可以容得下一个最小 chunk,则切割原 chunk 为两部分,free 掉后一部分(缩容
  • 当 realloc(ptr,size) 的 size 等于 0 时,相当于 free(ptr)
  • 当 realloc(ptr,size) 的 size 等于 ptr 的 size,不进行任何操作

寻找危险函数

输入函数:

函数 危险原因 堆溢出示例
gets 无长度限制,读取到 \n/EOF 为止,忽略 \x00 char *p = malloc(24); gets(p);
scanf %s/%[ 格式无长度限制,遇空白符停止 char *p = malloc(24); scanf("%s", p);
vscanf 可变参数版 scanf,风险和 scanf 一致 char *p = malloc(24); vscanf("%s", &p);

输出函数:

函数 危险原因 堆溢出示例
sprintf 无长度检查的格式化输出,遇 \x00 停止 char *p = malloc(24); sprintf(p, "%s", 超长字符串);

字符串相关:

函数 危险原因 堆溢出示例
strcpy 无长度检查,遇 \x00 停止 char *p = malloc(24); strcpy(p, 超长字符串);
strcat 从目标字符串末尾(\x00 位置)拼接,无长度检查 char *p = malloc(24); strcpy(p, "a"); strcat(p, 超长字符串);
bcopy 仅指定拷贝长度,若长度 > 目标内存大小则溢出 char *p = malloc(24); bcopy(源, p, 100);

补充:安全替代函数

危险函数 安全替代 核心区别
gets fgets(p, size, stdin) 指定最大读取长度
scanf scanf("%ns", p) n 为最大读取长度
strcpy strncpy(p, src, size) 指定最大拷贝长度
strcat strncat(p, src, size) 指定最大拼接长度
sprintf snprintf(p, size, ...) 指定最大输出长度

确定填充长度

在这一部分,要讲解计算我们开始写入的地址与我们所要覆盖的地址之间的距离

到这里你可别想把堆当成栈那样去溢出覆盖了,一个常见的误区是 malloc 的参数等于实际分配堆块的大小,但是事实上 ptmalloc 分配出来的大小是对齐的。

这个长度一般是字长的 2 倍,比如 32 位系统是 8 个字节,64 位系统是 16 个字节。但是对于不大于 2 倍字长的请求,malloc 会直接返回 2 倍字长的块也就是最小 chunk,比如 64 位系统执行malloc(0)会返回用户区域为 16 字节的块。

附件:test

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>
#include <stdlib.h>
int main(void) 
{
  char *chunk;
  chunk = malloc(24);
  puts("Get input:");
  gets(chunk);
  return 0;
}

如果我们申请的 chunk 大小是 24 个字节。但是我们将其编译为 64 位可执行程序时,实际上分配的内存会是 32 个字节而不是 24 个。

用户可用空间是32个字节,堆块总大小是48字节(包含堆头8+8)

1
2
3
4
5
pwndbg> x/8xg 0x602260
0x602260:	0x0000000000000000	0x0000000000000000
0x602270:	0x0000000000000000	0x0000000000000411
0x602280:	0x75706e6920746547	0x00000000000a3a74
0x602290:	0x0000000000000000	0x0000000000000000

但在漏洞利用中,64 位malloc(24) → 实际分配的堆块总大小 32 字节 → 用户区 16 字节,可借下一个 chunk 的 prev_size+size(8 字节),总共可写 24 字节。

核心:prev_size的 “延迟生效” 特性 —— 当前 chunk 未 free 时,下一个 chunk 的prev_size无作用,覆盖了也不会立刻崩溃;

只要记住 “写入数> 纯用户区就借 prev_size”。

这是 “能借用” 的核心前提:不是prev_size没用,是暂时没用,给了溢出利用的窗口。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
========== 你的 chunk(从 0x602260 开始)==========

0x602260          ← 堆块开头(malloc 返回的地址!)
┌─────────────────┐  8 字节
│  prev_size      │  → 存【前一个堆块】的大小
├─────────────────┤  8 字节
│  size            │  → 存【当前堆块总大小】+ 标志位
├─────────────────┤  从这开始才是【用户区】
│                 │
│  user data      │  → 你真正能用的空间
│                 │
└─────────────────┘

========== 下一个堆块(top chunk)==========
0x602280
┌─────────────────┐
│  prev_size      │  ← 这是【下一个chunk的头】,不是你的用户区!
├─────────────────┤
│  size            │
├─────────────────┤
│  user data      │
└─────────────────┘
1
2
3
4
5
6
7
8
9
地址范围          | 所属区域               | 24字节填充覆盖情况
-----------------|------------------------|-----------------------
0x602260 - 0x602267 | 当前chunk的prev_size   | ✅ 被覆盖(payload前8字节)
0x602268 - 0x60226F | 当前chunk的size字段    | ✅ 被覆盖(payload 8-15字节)
0x602270 - 0x602277 | 当前chunk的用户区前8字节 | ✅ 被覆盖(payload 16-23字节)
0x602278 - 0x60227F | 当前chunk的用户区后8字节 | ❌ 未覆盖(24字节刚好用完)
0x602280 - 0x602287 | 下一个chunk(top chunk)的prev_size | ❌ 完全没碰
0x602288 - 0x60228F | 下一个chunk的size字段  | ❌ 完全没碰
0x602290 - ...     | 下一个chunk的用户区    | ❌ 完全没碰

当malloc申请24字节时,chunk用户区分配到大小是32字节。而除去 chunk 头部的 16 个字节。实际上用户可用 chunk 的字节数为 16。而根据我们前面学到的知识可以知道 chunk 的 pre_size 仅当它的前一块处于释放状态时才起作用。所以用户这时候其实还可以使用下一个 chunk 的 prev_size 字段,正好 24 个字节。实际上 ptmalloc 分配内存是以双字为基本单位,以 64 位系统为例,分配出来的空间是 16 的整数倍,即用户申请的 chunk 都是 16 字节对齐的。

UAF

介绍

UAF全称Use After Free,即释放后利用。

开发者的角度看,一块内存如果被释放,按照逻辑不应该被用户访问到;如果用户访问到了释放后的内存区域,这种情况就称之为UAF。

在堆的利用中,核心就是构造UAF,因为释放后的堆块中会存储一些链表指针信息用于指向空闲内存。

如果我们能够控制这些指针,通过精心的内存布局,就可以达到AAW(任意内存地址)。

如果栈方面的漏洞的核心是覆盖返回地址然后直接劫持控制流的话,那么堆漏洞的核心就是构造UAF(类似栈,栈漏洞的核心就是构造栈溢出)。

如果能够利用堆漏洞来进行利用,那么他一定需要UAF,反之,若没有UAF,那么堆漏洞没用。

为什么?

在之前的学习过程中,我们知道一个chunk中有不同的字段。当这个chunk被分配给用户时,有用的字段就只有size、prevsize。而当这个chunk被free时,用户数据区域就会存储一些指针信息,如fd、bk等等。

在正常逻辑下设计者认为,一个已经被释放的堆块不应该能够被用户访问到,但是如果存在指针悬挂或者溢出等等问题时,这些问题就会间接的导致用户能够访问并且控制已经被释放的堆块的内容,这种漏洞形式就被称之为UAF。

UAF的成因

  • 堆溢出
  • 指针悬挂
  • overlap
  • ......

UAF对漏洞利用的作用

一个bin链表是通过指针方式连接。就拿最简单的fastbin来举例: fastbin只存储fastbin最前面的一个free的chunk,每次要用的时候直接拿出头部chunk,然后根据头部chunk的fd来寻找下一个chunk。

如果我们有UAF,那么我们是不是可以利用UAF来修改fd字段为target,然后再次分配内存时,target是不是就会被分配给用户。

本来程序限定的逻辑是用户只能被分配到堆的内存区域,用户只能在这一块区域为所欲为。现在用户只要指明一个target。利用UAF,程序就会把target分配给用户,用户是不是就可以任意内存地址访问、任意内存地址写了呢?

有了任意内存地址读写后,就可以修改一些内存地址,比如got、hook指针,栈等等,就可以控制执行流,进而getshell。

这么一描述,感觉UAF很容易构造出来,堆漏洞似乎很容易利用,看起来像栈溢出——溢出覆盖返回地址,从而劫持进程。

我想说的是,glibc版本在不断更迭,堆管理器、检查机制也随着迭代更新,堆的漏洞利用没有上述说的这么简单了,即使在glibc-2.23,也有很多check检查(类似canary、PIE这般的保护机制),到现在Ubuntu24.04的glibc-2.39更多了/(ㄒoㄒ)/~~。

多数的PWN题目不会直接给你个UAF,因为太简单了,就算直接给也会有很多限制条件,难以利用。而且,就算有了uaf,堆的分配机制也不是那么简单粗暴,他也会对一些字段进行检查,也算是一些内置的安全机制。

这里给一个示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <stdlib.h>
typedef struct name {
  char *myname;
  void (*func)(char *str);
} NAME;
void myprint(char *str) { printf("%s\n", str); }
void printmyname() { printf("call print my name\n"); }
int main() {
  NAME *a;
  a = (NAME *)malloc(sizeof(struct name));
  a->func = myprint;
  a->myname = "I can also use it";
  a->func("this is my function");
  // free without modify
  free(a);
  a->func("I can also use it");
  // free with modify
  a->func = printmyname;
  a->func("this is my function");
  // set NULL
  a = NULL;
  printf("this pogram will crash...\n");
  a->func("can not be printed...");
}

如果没看出来UAF长什么样子,那我先再给一个函数,你来看看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void del()
{
    int index;
    puts("index:");
    scanf("%d",&index);
    if(index < 0 || index > 19)
    {
        puts("NO MAY ");
        exit(1);
    }
    free(heap_list[index]);
}

可以看到漏洞点就是这个delete函数,它只将heap ptr给释放掉,但是并没有将heap list的指针清空。

函数仅执行 free(heap_list[index]),但没有将 heap_list[index] 置为 NULL

那么这样的话,在一个堆块释放掉后,我们仍可以通过edit和show函数来读写释放后的堆块内容。

那是不是说free是清零?不是的,free() 是 C 语言标准库函数,作用是将已申请的堆内存归还给操作系统 / 内存管理器,而非 “清空 / 清零” 内存内容。free 仅修改内存管理器的 “空闲内存链表”,把该堆块标记为 “可复用”,后续调用 malloc 时可能重新分配这块内存。

总之,free ≠ 清零,free 的价值不在于 “清空内容”,而在于 “管理内存的使用权”。

来,我们看看free的行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    // 1. 申请堆内存并写入数据
    char *p = malloc(20);
    strcpy(p, "hello pwn!");
    printf("free前,p指向的内容:%s\n", p);  // 输出:hello pwn!
    printf("free前,p的地址值:%p\n", p);    // 例如:0x55f8a7a282a0

    // 2. 执行free
    free(p);
    printf("free后,p指向的内容:%s\n", p);  // 仍输出:hello pwn!(内容没清零)
    printf("free后,p的地址值:%p\n", p);    // 仍输出:0x55f8a7a282a0(指针没变化)

    // 3. 验证:free后内存可被重新分配
    char *q = malloc(20);
    printf("新malloc的q地址:%p\n", q);      // 大概率和p相同(0x55f8a7a282a0)
    strcpy(q, "hacked!");
    printf("q写入后,p指向的内容:%s\n", p); // 输出:hacked!(原内存被覆盖)
    return 0;
}

可以去尝试一下运行这段代码。

好,大致结果想必也知道了free 后,原内存内容还在、指针地址也没改;

新 malloc 的内存大概率复用同一块地址,写入新数据才会覆盖原有内容。

malloc(p) → 用p读写数据 → free(p) → p=NULL(标记失效,不再碰)

用生活比喻理解 free 的意义:

把内存管理器比作 “房东”,你的程序比作 “租客”,堆内存比作 “房子”:

  1. malloc:你向房东租了一间房子(申请内存),拿到房门钥匙(指针),可以往房子里放东西(写入数据);
  2. free:你告诉房东 “我不住了,房子还给你”(归还使用权),但你没把房子里的东西搬走(内容还在),钥匙还在你手里(指针地址不变);
  3. malloc 再次分配:房东把这间空房子租给另一个租客(新的 malloc 调用),新租客可以把房子里的旧东西清掉(写入新数据),也可以直接用(读取旧数据)。

如果没有 free:

你租了房子却不归还,房东没法把房子租给别人 → 程序运行越久,占用的内存越多,最终导致内存泄漏(比如长期运行的服务器,内存会被耗尽)。

“清零” 的正确做法:如果要清空内存内容,需要用 memset(p, 0, 大小),而非 free;

“指针置 NULL” 的意义:free 后手动 p = NULL,是为了标记 “该指针已无效”,避免后续误操作(比如重复 free、使用悬空指针)—— 这是修复 double free 漏洞的核心,而非 “清零内存”。

Fastbin Attack

bins有很多,这里先理解一下这10个fastbins(区间0x20-0x80,步长0x8)。

fastbin 不是只有 10 个,而是x86_64 架构下默认有 10 个 fastbin 链表(编号 0~9),对应的内存块大小正好覆盖了 0x20~0x80(步长 0x8),这10个能应付当前绝大多数的开发场景。

fastbin只管 0x20~0x80 的小块,更大的块由 unsorted bin/large bin 接管,这是 glibc 堆管理器的核心分工逻辑。

管理fastbin free chunk,单链表结构,FILO(最后一个进入fastbin链表的,会被放在头部)。总共10个fastbin链表,每个链表中fastbin的size一样,0x10的大小递增。

大小属于fastbin的chunk被free掉时,不会改变nextchunk的previnuse位,也就是说不会被合并。

放入fastbin时,这就类似于栈结构,最新加入的放在链表头部,出现UAF,将fd修改到目标地址即可实现漏洞利用。

但是堆漏洞利用难在绕过系统检查这一块,fastbin会检查,在分配堆时,会检查堆块头部的size字段,例如分配一个0x70大小的chunk,会检查分配的chunk的size字段,如果size符合0x7x,则可以分配,如果不是这个大小,程序会出现异常退出!

fastbin会有两个检查:分配检查、释放检查。

在释放时,会检查fastbin的链表头部指针是否和当前free的地址相同,如果相同就会判定异常并退出,程序不允许我们连续释放同一个堆块。(本质是防止最基础的 double free(双重释放)漏洞被利用

Fastbin利用

第一种情况:已存在UAF

修改fd指针,跳转可控的分配地址,但是需要注意分配的目标地址附近一定要有个头部size字段

通常来说,这样的size字段挺好寻找的,因为Libc中函数地址的指针都是形如0x7fxxxxxxxx,我们把偏移改一下,就会得到一个0x7f,由于堆块都是0x10字节对齐的,所以0x7f自然而然会被当成0x70处理。

通常来说,修改的地方会是_malloc_hook或者got表或者栈。

第二种情况:没有UAF,但有指针悬挂

没有直接的UAF,但是我们可以访问到被free掉的指针。这个时候,就需要用fastbin来构造UAF:

在没有glibc检查机制之下,我们将堆块free掉两次,这样它的fd指针就指向自己,然后再申请它,改掉它的fd指针,再次申请后,fastbin中就有目标target了。

初始状态(malloc 完,还没 free)

1
2
3
4
fastbin[0]: NULL

p: [ 堆块 | 数据区 ]
(还没 free,所以没有 fd)

第一次 free (p)

free 做的事:

  1. 读旧表头:old_head = NULL
  2. p->fd = old_head
  3. 新表头 = p
1
2
3
fastbin[0]:  ────→ p
              p->fd = NULL

简化:

1
fastbin[0] → p → NULL

此时:p->fd = NULL

第二次 free (p)(无检查)

现在表头已经是 p

free 还是按规则执行:

  1. 读旧表头:old_head = p
  2. p->fd = old_head
  3. 新表头 = p
1
2
3
fastbin[0]:  ────→ p
                ↖ ↙
                 p->fd = p

简化成环:

1
fastbin[0] → p ←→ p ←→ p ...(无限循环)

此时:p->fd = p(自己)

第 1 次 free:p->fd = 旧表头 (NULL)

第 2 次 free:p->fd = 旧表头 (p)

所以第二次 free 后,fd 指向自己,形成环。

好像有点懵,回顾下堆结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 使用中的堆块(malloc后)
地址:0x555555756000: prev_size = 0x0
     0x555555756008: size = 0x20 (堆块总大小)
     0x555555756010: 数据区(你能操作的内存,比如存字符串)
     0x555555756018: 数据区
     0x555555756020: 堆块结束

# 空闲堆块(free后)
地址:0x555555756000: prev_size = 0x0
     0x555555756008: size = 0x20
     0x555555756010: fd = 下一个空闲块地址 (核心!新增的fd指针)
     0x555555756018: 无意义(fastbin不用bk指针)
     0x555555756020: 堆块结束

free(p)(我们说 “free(p)”,不是释放变量 p 本身,而是释放 “p 存储的地址对应的堆块”)。

将堆块free掉两次后,调用 malloc 申请一块和 p 相同大小的内存,程序会从 fastbin 链表头取出 p,此时你获得了对 p 的写权限,将 p->fd 篡改为你想要的目标地址 target(如 __free_hook)。

接着再次申请,让 fastbin 链表头指向 target,再次调用 malloc,程序会从链表头取出 p,然后将新的链表头更新为 p->fd,也就是你篡改后的 target

最终利用,第三次调用 malloc,程序会从链表头(即 target 地址)分配内存,你就可以向这个关键地址写入恶意数据(如 system 函数地址),从而控制程序流程。

Double free--glibc检查机制绕过

在上面提到过,会有一个double free的检查机制来针对这一类攻击,一旦地址相同(要释放的地址 = 链表头地址)就会出现异常。

绕过方法:

假设我们有两个同尺寸的堆块 a 和 b:

第一次 free(a)

  • a 被插入 fastbin 链表头。
  • 链表结构:fastbin → a → NULL
  • 检查:aNULL(原链表头),通过。

插入 free(b)

  • b 被插入 fastbin 链表头。
  • 链表结构:fastbin → b → a → NULL
  • 检查:ba(原链表头),通过。

第二次 free(a)

  • 此时,fastbin 链表头是 b
  • 检查:ab,检查通过!
  • a 被再次插入链表头,链表结构变为:fastbin → a → b → a → NULL,形成了环链表。

Unsortedbin Attack

介绍

unsortedbin attack是堆漏洞利用中第二常用的攻击手法——最简单的攻击手法,前提条件是有UAF,在任意内存地址写一个不确定的非常大的数(libc地址)。

不同于fastbin,chunk被释放进入unsortedbin时,fdbk字段会留下一个main_arena的地址信息,而fastbin如果是第一个,fd只会是0,之后的fastbin才会在fd中保存一个chunk的地址,单链表形式存储fastbin链表的信息。

那么如果在malloc的时候没有进行相应的内存信息清空或者设置工作,那么分配给用户的chunk中会留下一些脏数据,也就是fdbk等的libc地址信息。

如果程序中有相应的show功能,那么这时候打印出chunk中数据,就可以泄露地址信息了。由于unsortedbin中的main_arena指针信息是一个libc中地址,可以通过这种方式泄露libc地址,同样也可以利用这种方式泄露堆地址信息

通常,我们利用unsortedbin attack来修改一些类似于修改次数限制上限信息伪造堆头配合局部写等。

攻击原理

重要的事情说三遍:首先得先有UAF!首先得先有UAF!首先得先有UAF!

修改unsortedbin中的BK字段为target addr - 0x10,然后malloc一个相同的chunk,即可完成攻击。

Unsortedbin在使用的过程中,采用的遍历顺序是FIFO(first in first out),即插入的时候插入unsortedbin的头部,取出的时候从链表尾获取。

在程序malloc时,如果在fastbin,small bin中找不到对应大小的chunk,就会尝试从Unsorted Bin中寻找chunk,如果取出来的chunk大小刚好满足,就会直接返回给用户,否则就会把这些chunk分别插入到对应的bin中。

关键代码

当将一个unsorted bin取出的时候,会将bck->fd的位置写入本Unsorted Bin的位置。

1
2
3
4
5
/*remove from unsorted list*/
if (_glibc_unlikely (bck->fd != victim))
   malloc_printerr ("malloc(): corrupted unsorted chunks 3");
unsorted_chunks (av)->bk = bck;
bck->fd = unsorted_chunks (av);

结论:只要控制了unsortedbin的bk字段,就可以往BK+0x10位置写入unsorted bin的地址。

Off-By-One

了解了堆利用原理,必须有UAF,其实本质就是修改一些fdbk指针。

正常来说,我们希望程序的堆溢出能够溢出到next chunnk的fd或者bk字段,进而完成利用。

Offbyone属于一种特殊的堆溢出形式,他的溢出字节就如他的名字一样,只能溢出一个字节。但是实际生活中这种漏洞很常见,程序开发过程中很容易出现这种错误,例如边界检查不严格等情况。

当程序存在off-by-one漏洞,又在此刻我们申请了一个0x78或者0x98这种长度为0x8的堆块,程序会分配给我们0x70+0x10长度的chunk给我们使用,也就是说,这个chunk会使用prevsize字段,那我们就可以修改nextchunk的size字段

举个例子,程序中存在Offbyone漏洞,堆中有ABCD四个已经被分配的大小为0x70的chunk,现在都是使用状态。

我们输入'A' * 0x68 + '\xe1',此时堆块的布局如下:

这时可以看到b的size被改大了,正好覆盖到了c的末尾,我们就构造出了chunkoverlap。 (有点类似栈溢出覆盖返回地址那种感觉了...)

这时候我们将C给free(释放),它会进入fastbin。我们再将B free掉,B+C这一区域会进入unsorted bin。

我们再次申请一个大小为0xd0的堆块,也就是说B+C的这段内存又被我们控制了,此时我们就可以控制C的fd字段,就可以进行fastbin attack。

Off-By-Null

字面意思就是一种特殊的offbyone,溢出的字节是个空字符null,也就是\x00

好吧,这里我们复习一下这个\x00字符串的特性,它也是个结束符,想起来没?在栈溢出漏洞解析过程中,你会想到构造后门函数system("/bin/sh");,问题是怎读取问题,就是利用到\x00的结束字符的作用,即b'/bin/sh\x00'

\x00具有截断作用,如果边界检查不严格,那就会出现offbynull

它比起常规的offbyone利用方式稍微复杂一些,但本质上都是构造一个UAF给我们利用。

由于溢出字节只能说\x00,所以思路通常是改变其prev inuse位,通过合并构造overlap,然后构造UAF。

利用方式

假设有以下的堆布局:

同样的abcd四个大小为0x100的堆块,都是处于使用状态,这时候我们要攻击的堆块是C堆块。

接下来我们在B中输入'A' * 0x90 + p64(0x200) + '\x00'

此时内存布局如下:

可以看到C的prev inuse位被改成了0,也就是说,程序会将B看作已经被释放的堆块。

系统如何定位前一个堆块?

是通过prev size位,在C这里,我们将其改成了0x200,也就是说定位到了A堆块。但是A堆块明明没有被free,这时候我们如果free C会出现异常。

那我们现需要做的是“欺骗”系统,绕过检查,其实我们需要做的仅仅就是先将A释放掉,放入unsortedbin中,这时候再接着释放掉C堆块,就会触发合并操作。

触发合并操作后,ABC会被看作一个大小为0x300的堆块放入unsortedbin中。然而实际上,B并没有被free,我们也就通过这样的方式构造了overlap。然后后续的操作也就和offbyone一样,通过overlap构造UAF,进而完成利用。

Unsortedbin attack要求有UAF,控制BK指针为target -0x10,申请一个大小和unsortedbin一样的堆块,可以往那个地址写一个很大的值。(除非修好main_arena 的unsortedbin那两个fd/bk,否则只能用一次,一发入魂!)

Offbyone:构造overlap → UAF

Offbynull:修改prev_inuse位 → 触发合并 → overlap → UAF

原理

Unlink是指在一个堆块进行free时,由于涉及合并等操作,会将chunk从双向链表中取出来(例如 free 时和目前物理相邻的 free chunk 进行合并),这个过程叫unlink。

基本的过程如下:

unlink攻击局限性比较大的(不如Offbynull、Offbyone等),需要有UAF还需要修改inuse位,而且攻击的完成效果也是比较局限,可以在堆上进行任意内存地址写。

P→fd就是P + 0x10

P→bk就是P + 0x18

所以如果我们控制了P的fd和bk指针,比如我们将fd控制成address1,bk控制成address2。

这时,P→fd也就是address1,address1的bk就是address1 + 0x18,P→bk就是address2,address2的fd就是address2 + 0x10。

此时执行unlink操作,实际上就是:

Address1 + 0x18 = address2

Address2 + 0x10 = address1

这就可以达到任意内存地址写了。

如今的unlink可不容易实现的,现在有检查:

1
2
3
// fd bk
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))                      \
  malloc_printerr (check_action, "corrupted double-linked list", P, AV);  \

它会检查P->fd->bk == P 以及P->bk->fd == P。在正常情况下这俩条件是一定成立的,如果有不成立说明被破坏了。

那我们就考虑绕过吧。

令fd = &P - 0x18,bk = &P - 0x10即可。

最终就是P = &P - 0x18。(也就是P存储指向其自身内存地址往前0x18字节的地址)

利用方式

如果存储在堆的列表在堆上,那么用户实际访问的读写地址就存储在堆上。通过unlink修改那个列表地址信息,任意内存地址写。

必须有UAF还必须能改inuse位,还必须在堆上存储堆的列表信息...(这有点绕,感觉有点鸡肋,说实话我不太想用这,大多数情况下有了UAF就够了,用不着这么麻烦)

House of xxx

House of 系列的简要概括

“House of XXX” 是 glibc 堆溢出利用中一系列经典攻击手法的统称(翻译为 “XXX 之屋”),本质是针对堆管理机制(bin 结构、chunk 合并、malloc/free 逻辑)的漏洞利用技巧,每种 “House” 对应一种核心利用思路,且都以 “House of” 命名,是堆溢出领域的核心知识点。

这些手法最早由安全研究者总结并命名,用 “House(屋子)” 比喻 “利用堆结构的某种‘漏洞空间’/‘规则漏洞’搭建攻击路径”,核心是借助 glibc 堆管理的逻辑缺陷,劫持程序执行流(比如修改__free_hookmalloc_hook,或直接控制返回地址)。

所有 House 系列手法的前提:程序存在堆相关漏洞(UAF、堆溢出、double free、off-by-one 等)。

手法名称 核心漏洞前提 核心利用逻辑 关键特点 适用 glibc 版本 典型优势 典型局限
House Of Einherjar off-by-one 溢出(1 字节) 篡改下一个 chunk 的 prev_sizePREV_INUSE,触发向后合并,伪造目标 chunk 仅需 1 字节溢出,对漏洞条件要求极低 全版本(需绕过 tcache) 漏洞条件宽松,兼容性强 需堆地址泄露,tcache 环境需额外处理
House Of Force 堆溢出(可篡改 top chunk size) 篡改 top chunk size 为极大值,使 malloc 越界分配到目标地址 无需 free/UAF,操作直接粗暴 全版本 实现简单,无需复杂链表操作 必须能覆盖 top chunk size,高版本需绕过保护
House of Lore UAF(可修改 unsortedbin chunk 的 fd/bk) 进阶 Unsortedbin Attack,修复链表结构实现多次利用 可多次向目标地址写入,突破 “一发入魂” 限制 全版本 灵活性强,支持多次利用 复杂度高,需精准控制 fd/bk 指针
House of Orange 堆溢出(可篡改 top chunk/unsortedbin) 篡改 _IO_list_all,触发 _IO_flush_all_lockp 执行恶意代码 无需 UAF/free hook,依赖 IO 操作 2.24+(高版本更适配) 绕过 malloc/free hook 限制,无需 UAF 依赖 IO 操作,IO_FILE 结构复杂
House of Rabbit double free / UAF(fastbin 范围 chunk) 伪造 fastbin 链表,使 malloc 返回目标地址(如 __malloc_hook 稳定利用,对 ASLR 友好 全版本(tcache 需额外绕过) CTF 高频,利用稳定,ASLR 绕过效果好 仅适用于 fastbin 范围 chunk,tcache 环境需额外步骤
House of Roman UAF(可创建任意大小 chunk) 结合 fastbin + Unsortedbin attack,12-bit 爆破绕过 ASLR 仅需一个 UAF,可分配到 __malloc_hook 附近 2.26+(带 tcache) 适配高版本 tcache,仅需单个 UAF 依赖地址爆破(1/4096 成功率),需优化爆破逻辑
House of Pig 堆溢出(可篡改 largebin chunk 指针) 结合 largebin attack + IO_FILE + tcache stashing unlink,针对 calloc 场景 适配 calloc 分配,高版本 glibc 强力利用 2.31+ 适配现代 glibc,支持 calloc,利用链完整 复杂度极高,调试难度大

House Of Einherjar

House Of Force

House Of Lore

House Of Orange

House Of Rabbit

House Of Roman

House Of Pig

最后更新于 2026-03-21