深入理解堆

这里会详细说明关于堆块的各种细节(跟着WIKI来的)

unlink是一种合并堆块的前置操作,主要作用将free的chunk从bin中取出来

  • malloc

    • 从恰好大小合适的 large bin 中获取 chunk。
      • 这里需要注意的是 fastbin 与 small bin 就没有使用 unlink,这就是为什么漏洞会经常出现在它们这里的原因。
      • 依次遍历处理 unsorted bin 时也没有使用 unlink 。
    • 从比请求的 chunk 所在的 bin 大的 bin 中取 chunk。
  • free

    • 后向合并,合并物理相邻低地址空闲 chunk。
    • 前向合并,合并物理相邻高地址空闲 chunk(除了 top chunk)。
  • malloc_consolidate(堆块合并)

    • 后向合并,合并物理相邻低地址空闲 chunk。
    • 前向合并,合并物理相邻高地址空闲 chunk(除了 top chunk)。
  • realloc

    • 前向扩展,合并物理相邻高地址空闲 chunk(除了 top chunk)。

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
37
38
39
40
41
42
43
44
45
46
47
48
/* Take a chunk off a bin list */
// unlink p
#define unlink(AV, P, BK, FD) { \
// 由于 P 已经在双向链表中,所以有两个地方记录其大小,所以检查一下其大小是否一致。
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0)) \
malloc_printerr ("corrupted size vs. prev_size"); \
FD = P->fd; \
BK = P->bk; \
// 防止攻击者简单篡改空闲的 chunk 的 fd 与 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; \
// 下面主要考虑 P 对应的 nextsize 双向链表的修改
if (!in_smallbin_range (chunksize_nomask (P)) \
// 如果P->fd_nextsize为 NULL,表明 P 未插入到 nextsize 链表中。
// 那么其实也就没有必要对 nextsize 字段进行修改了。
// 这里没有去判断 bk_nextsize 字段,可能会出问题。
&& __builtin_expect (P->fd_nextsize != NULL, 0)) { \
// 类似于小的 chunk 的检查思路
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); \
// 这里说明 P 已经在 nextsize 链表中了。
// 如果 FD 没有在 nextsize 链表中
if (FD->fd_nextsize == NULL) { \
// 如果 nextsize 串起来的双链表只有 P 本身,那就直接拿走 P
// 令 FD 为 nextsize 串起来的
if (P->fd_nextsize == P) \
FD->fd_nextsize = FD->bk_nextsize = FD; \
else { \
// 否则我们需要将 FD 插入到 nextsize 形成的双链表中
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; \
} \
} \
} \
}

unlink攻击

因为unlink的存在,由此得到了一种攻击手段——Unlink(没错,就是叫Unlink),为什么存在这个攻击手法呢?,主要是unlink宏的检查不仔细,

1
2
3
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);

这里对FD和BK有一个检查,需要FD的bk,即FD_header_addr+0x18(64位),需要使这个等于P ,P即是我们需要从链中取出的chunk,同时也得让 BK的fd,即BK+0x10(64位) == P ,当这两者都满足时会进行下一步,即else部分

1
else {                                                         FD->bk = BK;                                   			BK->fd = FD;    

这里可以看到,将BK的值赋给FD的bk, FD的值赋给BK的fd ,而前面我们又知道, FD = p->fd ;BK = p->bk;那么就可以得到

1
2
3
FD->bk = p->bk |	 
|
BK->fd = p->fd |

这里把FD的bk指针指向BK,BK的fd指针指向FD ,而这里的FD和BK是经过我们篡改的,FD = p-0x18 BK= p-0x10 ,那么不就相当于, 我们把 p 这个地方的值覆盖为了p-0x10嘛,那么我们再次申请chunk就可以申请到p-0x10的内存了

那么现在,如果我们伪造一个fake_chunk,并且精心伪造其fd和bk指针(注意,next_prv_size和next_size也需要伪造,要不然会出现堆块的结构错误)

最常用的是,存放chunk指针的.bss段上的数组的那一段内存,假设这个数组的开头是 heap_list = 0x00600120 ,现在我们伪造一个chunk ,其fd和bk的值分别是fd = heap_list - 0x18 ; bk = heap_list - 0x10那么在绕过if的判断之后,进入下面的赋值阶段 ,此时p ->fd 为heap_list - 0x18 ,这样就使得,heap_list-0x18 –>heap_list -0x10

这样,再次申请内存就可以得到,heap_list = heap_list - 0x10 ,那么我们再次申请,就可以控制heap_list - 0x10往上的内存了,那么也就可以控制任意堆块来进行 got表替换,或者 替换 __free_hook ,__malloc_hook

以上是对unlink的前面的部分的解释,和利用,这种利用的方式虽然强大,但是局限性比较大,比如 如果开启了PIE且无法得到一些有效的指针数组的地址,这种方式往往难以奏效

ok,接下来是对于unlink的一些解释以及一些应用的解释

unlink_smallbin

(从wiki上偷的/ww);咳咳,这里可以看到unlink工作的整个过程,然后你就会发现,中间那个chunk(p)的fd和bk的指针并没有置零,那么就有了以下的用处

  • libc 地址
    • P 位于双向链表头部,bk 泄漏
    • P 位于双向链表尾部,fd 泄漏
    • 双向链表只包含一个空闲 chunk 时(即只含有P),P 位于双向链表中,fd 和 bk 均可以泄漏
  • 泄漏堆地址,双向链表包含多个空闲 chunk
    • P 位于双向链表头部,fd 泄漏
    • P 位于双向链表中,fd 和 bk 均可以泄漏
    • P 位于双向链表尾部,bk 泄漏

注意

  • 这里的头部指的是 bin 的 fd 指向的 chunk,即双向链表中最新加入的 chunk。
  • 这里的尾部指的是 bin 的 bk 指向的 chunk,即双向链表中最早加入的 chunk。

不过需要注意的是,这里都是有检查的,伪造的时候需要格外注意这个检查

1
2
3
4
5
6
 if (__builtin_expect (FD->bk != P || BK->fd != P, 0))     
malloc_printerr (check_action, "corrupted double-linked list", P, AV);
// ........
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);
// ........

malloc_printerr

在 glibc malloc 时检测到错误的时候,会调用 malloc_printerr 函数。

1
2
3
4
5
static void malloc_printerr(const char *str) {
__libc_message(do_abort, "%s
", str);
__builtin_unreachable();
}

主要会调用 __libc_message 来执行abort 函数,如下

1
2
3
4
5
6
7
if ((action & do_abort)) {
if ((action & do_backtrace))
BEFORE_ABORT(do_abort, written, fd);

/* Kill the application. */
abort();
}

abort 函数里,在 glibc 还是 2.23 版本时,会 fflush stream。

1
2
3
4
5
6
7
/* Flush all streams.  We cannot close them now because the user
might have registered a handler for SIGABRT. */
if (stage == 1)
{
++stage;
fflush (NULL);
}

额额,作用是啥呢?我还不知道……大概是报输出错误吧?

堆的初始化

堆初始化是在用户第一次申请内存时执行 malloc_consolidate 再执行 malloc_init_state 实现的

具体可以参考malloc_state等函数

申请内存

ok,硬骨头来了,这一块的内容非常多

malloc?no, it is __libc_malloc

其实在glibc源码中并不存在malloc这个函数,一般是__libc_malloc ,那么为什么不使用一个malloc呢? 因为我们在不同情况下对malloc的名称的不同要求(即取名不同),而 __libc_malloc只是对__int _malloc的简单封装,真正使用到申请内存作用的其实是这个函数

__libc_malloc这个函数首先会去检查__malloc_hook这个钩子函数是否存在

1
/*(hook类的钩子函数其实就是类似于got表类的东西,只不过got里面是函数的真实地址,而这个hook里面是用户自定义的函数)*/

值得注意的是,用户申请 的字节一旦进入申请的内存函数中就会变成无符号整数,下面是__libc_malloc的部分源码

1
2
3
4
5
6
7
8
// wapper for int_malloc
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 来试图分配内存

1
arena_get(ar_ptr, bytes);

然后调用__int_malloc申请对应的内存

1
victim = _int_malloc(ar_ptr, bytes);

如果分配失败,ptmalloc会尝试再去寻找一个可用的arena 并且分配内存

1
2
3
4
5
6
7
    /* Retry with another arena only if we were able to find a usable arena before.  */
/*译:仅当我们之前能够找到可用的 arena 时,才使用另一个 arena 重试*/
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);
}

如果申请到了 arena,那么在退出之前还得解锁。

1
if (ar_ptr != NULL) __libc_lock_unlock(ar_ptr->mutex);

判断目前的状态是否满足以下条件

  • 要么没有申请到内存
  • 要么是 mmap 的内存
  • 要么申请到的内存必须在其所分配的 arena 中
1
2
assert(!victim || chunk_is_mmapped(mem2chunk(victim)) ||
ar_ptr == arena_for_chunk(mem2chunk(victim)));

最后返回内存。

1
2
    return victim;
}

可以看到相当繁琐(至少我是这样觉得的 /ww);那么其实这里实际上主要的是__int_malloc这个函数申请内存,那么接下来解释__int_malloc这个函数

__libc_malloc?no it is __int_malloc

_int_malloc 是内存分配的核心函数,其核心思路有如下

  1. 它根据用户申请的内存块大小以及相应大小 chunk 通常使用的频度(fastbin chunk, small chunk, large chunk),依次实现了不同的分配方法
  2. 它由小到大依次检查不同的 bin 中是否有相应的空闲块可以满足用户请求的内存
  3. 当所有的空闲 chunk 都无法满足时,它会考虑 top chunk
  4. 当 top chunk 也无法满足时,堆分配器才会进行内存块申请

这个函数里面有自己定义的,所需要的变量,并且将用户申请的内存大小转换为内部的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
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.*/
/*译:通过添加 SIZE_SZ 字节将请求大小转换为内部表单 销加上可能更多的开销以获得必要的对齐和/或获得至少 MINSIZE 的大小,则最小可分配
大小。此外,checked_request2size traps(返回 0)请求大小
它们如此之大,以至于它们在填充时包裹在 0 周围,并且一致。*/
checked_request2size(bytes, nb);

ok,接下来是arena

arena

1
2
3
4
5
6
7
/* 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;
}

各种bin

FAST BIN

fastbin的是一个漏洞常出的地方,因为这个地方,是单链表,非常好伪造堆块(相对于双链表),虽然针对于fastbin有一些检查,但是因为其特性,终究是防不胜防,

fastbin的范围是(64位):0x20 ~ 0x80

需要注意的是这里比较的是无符号整数此外,是从 fastbin 的头结点开始取 chunk

以下是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
27
28
29
30
31
32
33
    /*If the size qualifies as a fastbin, first check corresponding bin. This code is safe to execute even if av is not yet initialized, so we can try it without checking, which saves some time on this fast path.*/
/*如果大小符合 FastBin 的条件,请首先检查相应的 Bin。即使 av 尚未初始化,此代码也可以安全执行,因此我们无需检查即可尝试,从而在此快速路径上节省一些时间*/
if ((unsigned long) (nb) <= (unsigned long) (get_max_fast())) {
// 得到对应的fastbin的下标
idx = fastbin_index(nb);
// 得到对应的fastbin的头指针
mfastbinptr *fb = &fastbin(av, idx);
mchunkptr pp = *fb;
// 利用fd遍历对应的bin内是否有空闲的chunk块,
do {
victim = pp;
if (victim == NULL) break;
} while ((pp = catomic_compare_and_exchange_val_acq(fb, victim->fd,
victim)) != victim);
// 存在可以利用的chunk
if (victim != 0) {
// 检查取到的 chunk 大小是否与相应的 fastbin 索引一致。
// 根据取得的 victim ,利用 chunksize 计算其大小。
// 利用fastbin_index 计算 chunk 的索引。
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;
}
// 细致的检查。。只有在 DEBUG 的时候有用
check_remalloced_chunk(av, victim, nb);
// 将获取的到chunk转换为mem模式
void *p = chunk2mem(victim);
// 如果设置了perturb_type, 则将获取到的chunk初始化为 perturb_type ^ 0xff
alloc_perturb(p, bytes);
return p;
}
}

SMALL BIN

small bin是存储 小于1024字节(0x400)的chunk的,个数为62个,且为双链表,同时,相邻的free chunK会合并,内存分配速度比larger bin快,但是比fast bin慢,

small 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
    /* If a small request, check regular bin.  Since these "smallbins" hold one size each, no searching within bins is necessary.(For a large request, we need to wait until unsorted chunks are processed to find best fit. But for small ones, fits are exact anyway, so we can check now, which is faster.) */
/*如果请求较小,请检查常规 bin。由于这些 “smallbin” 每个保持一个大小,无需在 bin 中搜索.(对于大型请求,我们需要等到未排序的 chunk 加工以找到最合适的。但对于小孩子来说,适合是精确的 总之,我们现在就可以检查一下,哪个更快*/

if (in_smallbin_range(nb)) {
// 获取 small bin 的索引
idx = smallbin_index(nb);
// 获取对应 small bin 中的 chunk 指针
bin = bin_at(av, idx);
// 先执行 victim = last(bin),获取 small bin 的最后一个 chunk
// 如果 victim = bin ,那说明该 bin 为空。
// 如果不相等,那么会有两种情况
if ((victim = last(bin)) != bin) {
// 第一种情况,small bin 还没有初始化。
if (victim == 0) /* initialization check */
// 执行初始化,将 fast bins 中的 chunk 进行合并
malloc_consolidate(av);
// 第二种情况,small bin 中存在空闲的 chunk
else {
// 获取 small bin 中倒数第二个 chunk 。
bck = victim->bk;
// 检查 bck->fd 是不是 victim,防止伪造
if (__glibc_unlikely(bck->fd != victim)) {
errstr = "malloc(): smallbin double linked list corrupted";
goto errout;
}
// 设置 victim 对应的 inuse 位
set_inuse_bit_at_offset(victim, nb);
// 修改 small bin 链表,将 small bin 的最后一个 chunk 取出来
bin->bk = bck;
bck->fd = bin;
// 如果不是 main_arena,设置对应的标志
if (av != &main_arena) set_non_main_arena(victim);
// 细致的检查,非调试状态没有作用
check_malloced_chunk(av, victim, nb);
// 将申请到的 chunk 转化为对应的 mem 状态
void *p = chunk2mem(victim);
// 如果设置了 perturb_type , 则将获取到的chunk初始化为 perturb_type ^ 0xff
alloc_perturb(p, bytes);
return p;
}
}
}

LARGE BIN

堆基础

chunk的结构

prv_size : 记录上一个free——chunk的大小,当这个chunk释放时,将这个chunk的地址(chunk_header)减去prv_size的大小,作为新的chunk_heade

例如有三个0x20大小的chunk

1
2
chunkA chunkB chunkC ,现在修改chunkC的prv_size为chunkA+chunkB的大小(0x40),那么,
新的chunk_header = chunkC_header-0x40(len(chunkA)+len(chunkB))

FASTBIN ATTACH

DOUBLE FREE

double free是一种很古老的利用方式(虽然可能以后的版本也有得用);其核心思想就是,通过对一个fastbin_chunk进行两次释放,就会造成一些意想不到的结果,

1
2
假设存在两个free_chunk,效果如下
fastbin_heander --> chunk1 --> chunk0

Fastbin Double Free 能够成功利用主要有两部分的原因

1
2
1> fastbin里面的chunk的p位恒为1 ,不会造成chunk合并的效果
2> fastbin只对表头即fastbin_header( main_arena)指向的堆块检测double free

因此,当我们对chunk0 free时,fastbin_header –> chunk0

当我们此时再释放一个chunk1就会造成fastbin_heander –> chunk1 –> chunk0 ;这个时候,chunk0就不在表头,再次释放chunk0就不会触发double free的检查,从而顺利的double free

那么我们可以有如下操作:

1
2
3
4
main_arena ----> chunk0 #第一步,释放一个fastbin_chunk
main_arena ----> chunk1 ----> chunk0 #第二步,释放另一个chunk1,

main_arena ----> chunk0 ----> chunk1 ----> chunk0 #再次释放chunk0

那么如果存在溢出,或者UAF可以修改chunk0的的fd指针指向我们需要的内存空间(一般是存有一大堆指针的内存空间,或者是got表),那么我们通过两次申请就可以申请到这一块的内存,通过对这一块内存的修改就可以实现控制这些指针,从而控制一些函数或者hook的实际应用

当然,我们可以申请回chunk0 ,并且编辑它,使它的内容为一个指针,那么在bin中的chunk0 的内容也会更改为这个指针,

HOUSE OF系列

一种简单的利用方式,

适用范围 Glibc 2.23——至今

通过堆溢出修改下一个chunk的size位,使得chunk 重叠

chunkA chunkB chunkC ,通过chunkA溢出修改chunkB的size位,使得chunkB覆盖chunkC的内存,那么此时对chunkB进行读写就可以修改chunkC的内容,再free chunkC,那么通过chunkB可以修改chunkC的fd指针,从而实现任意地址写

不过这种利用手法一般出现再fastbin和tcache ,因为这两个都是单链表,比较好绕过检查,注意一些fastbin的检查

HOUSE OF EINHERJAR

适用范围 Glibc 2.23——至今

类似于unlink但不是

存在三个chunk :chunkA chunkB chunkC 当存在溢出,我们填充chunkB的内容修改chunkC的inuse_P位为0,和prv_size为chunkA+chunkB的大小,那么释放chunkC 时,就会导致chunkB这个正在被使用的chunk也被释放,被合并成一个大chunk进入unshortbin,那么此时就可以通过chunkB 达到泄露libc地址,并且结合其他方法可以实现任意地址读写

HOUSE OF FORCE

适用于 Glibc2.23——2.29

这是对TOP Chunk的修改利用手法,因为申请chunk的时候会切割TOP chunk, 而在TOP chunk中,TOP chunk的size位是无符号整型,因此可以通过整数溢出修改TOP chunk的size位为一个非常大的数,当我们申请一个很大的chunk的时候,就会发生整数回绕,又因为会更新TOP chunk的指针

1
2
remainder      = chunk_at_offset(victim, nb);
av->top = remainder;

可以看到,top 这个指针是受remainder这个剩余chunk大小控制的,也就是说,如果我们申请一个很大的chunk,就会回绕使得remainder这个值变小,让TOP chunk前移,当我们再次申请chunk的时候就可以控制目标区域的内存了

比如

1
2
3
got@__malloc_hook 在 0x600110
此时TOP chunk在 0x600200 ,那么我们需要申请-0x100的chunk到0x600100,为什么会多0x10呢?因为top chunk切割堆块之后会自动给堆块补齐chunk_header
那么此时TOP chunk在0x600100 再次申请0x10的chunk就可以控制got@__malloc_hook的内存了

HOUSE OF LORE

适用范围:Glibc 2.23——至今

HOUSE OF ORANGE

适用于Glibc 2.23——2.26

这个是针对于程序没有free,并且结合了I/O file 的利用手法,