源码2.27

先看两个exit.h中的重要结构体

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
struct exit_function
{
/* `flavour' should be of type of the `enum' above but since we need
this element in an atomic operation we have to use `long int'. */
long int flavor;
/*函数类型, 可以是{ef_free, ef_us, ef_on, ef_at, ef_cxa}
- ef_free表示此位置空闲
- ef_us表示此位置被使用中, 但是函数类型不知道
- ef_on, ef_at, ef_cxa 分别对应三种不同的析构函数类型, 主要是参数上的差异*/
union //一个联合体func
{
void (*at) (void); //ef_at类型 没有参数
struct
{
void (*fn) (int status, void *arg); //ef_on类型
void *arg;
} on;
struct
{
void (*fn) (void *arg, int status); //ef_cxa类型
void *arg;
void *dso_handle;
} cxa;
} func;
};
struct exit_function_list
{
struct exit_function_list *next; //下一个exit_function_list,单链表
size_t idx; //记录有多少个函数
struct exit_function fns[32]; //exit_function数组,析构函数数组
};

首先是exit的源码

1
2
3
4
5
6
void
exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true, true);
}
libc_hidden_def (exit)

libc经典的套娃函数,可以看出exit的主体是__run_exit_handlers

run_exit_handlers()的主要工作就是调用exit_funcs中保存的各种函数指针

看其工作流程

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
//调用atexit与on_exit注册的函数,顺序为注册的逆序
void attribute_hidden __run_exit_handlers(int status, struct exit_function_list **listp, bool run_list_atexit)
{
//首先释放线程局部储存, 即TLS
#ifndef SHARED
if (&__call_tls_dtors != NULL)
#endif
__call_tls_dtors();

//遍历exit_fundtion_list链表,链表种每个节点里又有一个函数指针数组,根据里面的函数类型进行调用
while (*listp != NULL)
{
struct exit_function_list *cur = *listp; //cur指向当前exit_function_list节点

//cur->idx表示cur->fns中有多少个函数,从后往前遍历
while (cur->idx > 0) //遍历exit_function_list节点中 析构函数数组fns[32]中的函数指针
{
const struct exit_function *const f = &cur->fns[--cur->idx]; //f指向对应析构函数的描述符

switch (f->flavor) //选择析构函数类型
{
//三种函数指针
void (*atfct)(void);
void (*onfct)(int status, void *arg);
void (*cxafct)(void *arg, int status);

//这两种类型不调用
case ef_free:
case ef_us:
break;

//on类型的参数为注册时设定的参数
case ef_on:
onfct = f->func.on.fn; //设置函数指针
#ifdef PTR_DEMANGLE
PTR_DEMANGLE(onfct);
#endif
onfct(status, f->func.on.arg); //调用这个函数指针
break;

//at没有参数
case ef_at:
atfct = f->func.at;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE(atfct);
#endif
atfct();
break;

//cxa类型则先为设定时的参数,再为状态码
case ef_cxa:
cxafct = f->func.cxa.fn;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE(cxafct);
#endif
cxafct(f->func.cxa.arg, status);
break;
}
}

*listp = cur->next; //listp指向下一个exit_function_list节点

//最后一个链表节点为libc .data段中的initial,不需要释放
//除此以外的节点都是malloc申请得到的, 所以需要释放
if (*listp != NULL)
free(cur);
}

if (run_list_atexit) //调用_atexit
RUN_HOOK(__libc_atexit, ());

_exit(status); //真正的exit系统调用
}

思考1:能否劫持__exit_funcs数组?

在exit调用run_exit_handlers()时下断点, 找到exit_funcs指针

可以看到其中最重要的fns[0]被加密成乱码了,要想利用的话还要获得存储在fs:0x30的密钥,难度高,几乎难以利用

__exit_funcs如何添加析构函数()

既然难以攻击exit_funcs, 那么尝试从exit_funcs中的函数入手

我们首先要弄明白, __exit_funcs中的函数是怎么添加的

libc提供了一个接口: atexit()用来注册exit()时调用的析构函数

1
2
3
4
5
6
7
8
/* DSO由GCC定义,用来识别模块的*/
extern void *__dso_handle __attribute__((__weak__));

/* 注册一个exit时调用的析构函数*/
int atexit(void (*func)(void))
{
return __cxa_atexit((void (*)(void *))func, NULL, &__dso_handle == NULL ? NULL : __dso_handle);
}

cxa_atexit()是对internal_atexit()的封装

注意: __exit_funcs就是exit()时用的那个指针

1
2
3
4
5
6
//注册一个exit/共享库被卸载时调用的函数,只会被C++编译器生产的代码调用,C会通过atexit调用
int __cxa_atexit(void (*func)(void *), void *arg, void *d)
{
return __internal_atexit(func, arg, d, &__exit_funcs);
}
libc_hidden_def(__cxa_atexit)

internel_atexit()通过new_exitfn()找到一个在__exit_funcs链表上注册析构函数的位置, 然后进行写入

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
/*
参数:
- func 析构函数指针
- arg 参数指针
- d DSO
- listp 析构函数数组链表指针
*/
int attribute_hidden __internal_atexit(void (*func)(void *), void *arg, void *d, struct exit_function_list **listp)
{
struct exit_function *new = __new_exitfn(listp); //先在__exit_funcs链表上添加一个描述析构函数的结构体

if (new == NULL)
return -1;

#ifdef PTR_MANGLE
PTR_MANGLE(func);
#endif

//然后设置分配到的这个结构体
new->func.cxa.fn = (void (*)(void *, int))func; //函数指针
new->func.cxa.arg = arg; //参数
new->func.cxa.dso_handle = d;
atomic_write_barrier();
new->flavor = ef_cxa; //类型

return 0;
}

__new_exitfn()的逻辑大致为

  • 先尝试在__exit_funcs中找到一个exit_function类型的ef_free的位置, ef_free代表着此位置空闲
  • 如果没找到, 就新建一个exit_function节点, 使用头插法插入__exit_funcs链表, 使用新节点的第一个位置作为分配到的exit_function结构体
  • 设置找到的exit_function的类型为ef_us, 表示正在使用中, 并返回
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
//从listp上返回一个新的exit_function结构体
struct exit_function *__new_exitfn(struct exit_function_list **listp)
{
struct exit_function_list *p = NULL;
struct exit_function_list *l;
struct exit_function *r = NULL;
size_t i = 0;

__libc_lock_lock(lock); //上锁

//寻找一个析构函数类型为ef_free的位置
for (l = *listp; l != NULL; p = l, l = l->next) //遍历链表,l指向当前节点, p指向l的前一个节点
{
for (i = l->idx; i > 0; --i) //搜索l中的函数指针数组fns[32]
if (l->fns[i - 1].flavor != ef_free) //有一个不是ef_free的就停止
break;

if (i > 0) //在l中找到了, 停止链表遍历
break;

/* 只有全部都是ef_free才能走到这里 */
l->idx = 0;
}

if (l == NULL || i == sizeof(l->fns) / sizeof(l->fns[0])) //没有找到空闲位置
{
/*
l==null 说明整个__exit_funcs中都没有ef_free
i == sizeof(l->fns) / sizeof(l->fns[0]) 说明对于l节点, fns已经全部遍历了, 都没找到ef_free
此时就需要插入一个新的exit_function_list节点
*/
if (p == NULL)
{
assert(l != NULL);
p = (struct exit_function_list *)calloc(1, sizeof(struct exit_function_list)); //申请一个结构体, p指向新节点
if (p != NULL) //分配失败
{
p->next = *listp; //头插法, 再__exit_funcs中插入一个节点
*listp = p;
}
}

if (p != NULL) //分配成功
{
r = &p->fns[0]; //r指向新节点的第一个析构函数描述结构体
p->idx = 1;
}
}
else //找到空闲位置了, l节点中第i个为ef_free
{
r = &l->fns[i];
l->idx = i + 1;
}

/* 此时这个函数位置的类型从空闲(ef_free)变为使用中(ef_us), 等待写入函数指针 */
if (r != NULL)
{
r->flavor = ef_us;
++__new_exitfn_called;
}

__libc_lock_unlock(lock);

return r;
}

析构函数的注册—__libc_start_main()

__libc_start_main()函数初窥

  • 首先是其参数列表也就是_start()传递的参数, 我们中重点注意下面三个
    • init: ELF文件 也就是main()的构造函数
    • fini: ELF文件 也就是main()的析构函数
    • rtld_fini: 动态链接器的析构函数
1
2
3
4
5
6
7
8
9
10
11
12
static int __libc_start_main(
int (*main)(int, char **, char **MAIN_AUXVEC_DECL), //参数: main函数指针
int argc, char **argv, //参数: argc argv
ElfW(auxv_t) * auxvec,
__typeof(main) init, //参数: init ELF的构造函数
void (*fini)(void), //参数: fini ELF的析构函数
void (*rtld_fini)(void), //参数: rtld_fini ld的析构函数
void *stack_end //参数: 栈顶
)
{
...函数体;
}

进入函数体, __libc_start_mian()主要做了以下几件事

  • 为libc保存一些关于main的参数, 比如__environ…
  • 通过atexit()注册fini 与 rtld_fini 这两个参数
  • 调用init为main()进行构造操作
  • 然后调用main()函数
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
static int __libc_start_main(...)
{
/* 保存main的返回地址 */
int result;

//获取环境变量指针, 并保存到libc的.data中
char **ev = &argv[argc + 1];
__environ = ev;

/* 保存下栈顶 */
__libc_stack_end = stack_end;

...;

/* 初始化TLS */
__pthread_initialize_minimal();

/* 设置stack guard */
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard(_dl_random);

/* 设置pointer gurad */
uintptr_t pointer_chk_guard = _dl_setup_pointer_guard(_dl_random, stack_chk_guard);

...;

/* 注册动态链接器(ld.so.2)的析构函数 */
if (__glibc_likely(rtld_fini != NULL))
__cxa_atexit((void (*)(void *))rtld_fini, NULL, NULL);

/* 进行一些简单的libc初始化工作: 在libc中保存argc argv env三个参数 */
__libc_init_first(argc, argv, __environ);

/* 注册ELF的fini函数 */
if (fini)
__cxa_atexit((void (*)(void *))fini, NULL, NULL);


/* 如果ELF有构造函数的话, 那么先调用init() */
if (init)
(*init)(argc, argv, __environ MAIN_AUXVEC_PARAM);

...;

/* 调用main() */
result = main(argc, argv, __environ MAIN_AUXVEC_PARAM);

/* 如果main()返回后, __libc_start_main()回帮他调用exit()函数 */
exit(result);
}

至此我们知道libc_start_mian()会在exit_funcs中放入下面两个函数

ELF的fini函数ld的rtld_fini函数

然后会调用一个构造函数:

init()

ELF的fini()

被编译在elf的text段中, 由_start()传递地址给__libc_start_main()

发现其是一个空函数,因为其只有在静态编译下才会起作用,故而动态编译该函数为空

静态编译时:该函数会逐一取出fini_array数组中的函数指针执行,该函数指针数组位于bss段上

ELF的init()

让我们思考一个问题: 如果只有fini与init的话, ELF只能有一个构造/ 析构函数

当具有多个构造析构函数时改怎么办呢?

ELF的解决方法是, 把所有的构造函数的指针放在一个段: .init_array中, 所有的析构函数的指针放在一个段 .fini_array中

init()就负责遍历.init_array, 并调用其中的构造函数, 从而完成多个构造函数的调用

ld的rtdl_fini()

我们说完了.init_array, 那么对于.fini_array呢?

很明显不是ELF的fini()负责 , 因为他就是一个空函数, 那么就只能由rtdl_fini来负责

rtdl_fini实际指向_dl_fini()函数, 源码在dl-fini.c文件中, 会被编译到ld.so.2中

我们把进程空间中的一个单独文件, 称之为模块

ld.so.2会通过dl_open()把所需文件到进程空间中, 他会把所有映射的文件都记录在结构体_rtld_global中

当一个进程终止, ld.so.2自然需要卸载所映射的模块, 这需要调用每一个非共享模块的fini_arrary段中的析构函数

一言以蔽之: _dl_fini()的功能就是调用进程空间中所有模块的析构函数

rtld_global结构体

接着来看_rtld_global结构体, 这个结构体很复杂, 我们只看与本文相关的

_rtld_global一般通过宏GL来引用, 这个结构体定义在ld.so.2的data段中

1
2
3
#define GL(name) _rtld_global._##name

extern struct rtld_global _rtld_global __rtld_global_attribute__;
  • 再看其结构体struct rtld_global的定义
    • 一些缩写的含义:
      • ns代表着NameSpace
      • nns代表着Num of NameSpace
    • struct rtld_global先以命名空间为单位建立了一个数组 _dl_ns[DL_NNS]
    • 在每个命名空间内部加载的模块以双向链表组织, 通过_ns_loaded索引
    • 同时每个命名空间内部又有一个符号表_ns_unique_sym_table, 记录着所有模块导出的符号集合
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
struct rtld_global
{

#define DL_NNS 16

struct link_namespaces
{
//每个模块用_ns_loaded描述, 这个命名空间中所映射的模块组成一个双向链表, _ns_loaded就是这个链表的指针
struct link_map *_ns_loaded;

/* _ns_loaded中有多少模块 */
unsigned int _ns_nloaded;

/* 映射模块的搜索表 */
struct r_scope_elem *_ns_main_searchlist;
/* This is zero at program start to signal that the global scope map is
allocated by rtld. Later it keeps the size of the map. It might be
reset if in _dl_close if the last global object is removed. */
size_t _ns_global_scope_alloc;

/* 这个命名空间中的符号表, 单个命名空间中的符号不允许重复 */
struct unique_sym_table
{
__rtld_lock_define_recursive(, lock) struct unique_sym
{
uint32_t hashval; //符号hash值
const char *name; //名称
const ElfW(Sym) * sym; //符号
const struct link_map *map; //所属模块
} * entries; //entries可以理解为struct unique_sym数组的指针, 通过entries[idx]就可找到第idx个符号
size_t size; //有多少个元素
size_t n_elements;
void (*free)(void *); //析构函数
} _ns_unique_sym_table;

/* 记录命名空间变化的, debug用 */
struct r_debug _ns_debug;
} _dl_ns[DL_NNS]; //一个命名空间一个link_namespace结构体

/* _dl_nns表示使用了多少个命名空间: Dynamic Link Num of NameSpace */
size_t _dl_nns;

...;
}

接着我们分析下struct link_map, 来看看ld是怎么描述每一个模块的

  • ELF文件都是通过节的组织的, ld自然也延续了这样的思路,
  • l_info中的指针都指向ELF中Dyn节中的描述符, Dyn中节描述符类型是ElfW(Dyn)
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
struct link_map
{
ElfW(Addr) l_addr; /* 模块在内存中的的基地址 */
char *l_name; /* 模块的文件名 */
ElfW(Dyn) * l_ld; /* 指向ELF中的Dynamic节 */
struct link_map *l_next, *l_prev; /* 双向链表指针 */
struct link_map *l_real;

/* 这个模块所属NameSapce的idx */
Lmid_t l_ns;

struct libname_list *l_libname;

/*
l_info是ELF节描述符组成的的数组
ELF中一个节, 使用一个ElfW(Dyn)描述
各个类型的节在l_info中的下标固定, 因此可以通过下标来区分节的类型
*/
ElfW(Dyn) * l_info[DT_NUM + DT_THISPROCNUM + DT_VERSIONTAGNUM + DT_EXTRANUM + DT_VALNUM + DT_ADDRNUM];

const ElfW(Phdr) * l_phdr; /* ELF的头表 */
ElfW(Addr) l_entry; /* ELF入口点 */
ElfW(Half) l_phnum; /* 头表中有多少节 */
ElfW(Half) l_ldnum; /* dynamic节中有多少描述符 */

...;
}

ElfW(Dyn)是一个节描述符类型(也就是一个宏), 宏展开结果为Elf64_Dyn, 这个类型被定义在elf.h文件中, 与ELF中的节描述对应

这个结构体在elf的学习中很重要

1
2
3
4
5
6
7
8
9
typedef struct
{
Elf64_Sxword d_tag; /* 便签, 用于标注描述符类型,每一个描述符类型都对应一个类型 */
union
{
Elf64_Xword d_val; /* 内容可以是一个值 */
Elf64_Addr d_ptr; /* 也可以是一个指针 */
} d_un;
} Elf64_Dyn;

至此rtld_global的结构就清楚了, 他自顶向下按照: 命名空间->模块->节 的形式描述所有的模块, 通过_ns_unique_sym_table描述命名空间中所有的可见符号

_dl_fini()源码分析

理解了模块是如何组织的之后, _dl_fini的任务就显而易见了:

  • 遍历rtld_global中所有的命名空间
  • 遍历命名空间中所有的模块
  • 找到这个模块的fini_array段, 并调用其中的所有函数指针
  • 找到这个模块的fini段, 调用fini()
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
void internal_function _dl_fini(void)
{
#ifdef SHARED
int do_audit = 0;
again:
#endif
for (Lmid_t ns = GL(dl_nns) - 1; ns >= 0; --ns) //遍历_rtld_global中的所有非共享模块: _dl_ns[DL_NNS]
{
__rtld_lock_lock_recursive(GL(dl_load_lock)); //对rtld_global上锁

unsigned int nloaded = GL(dl_ns)[ns]._ns_nloaded;
/* 如果这个NameSapce没加载模块, 或者不需要释放, 就不需要做任何事, 就直接调用rtld中的函数指针释放锁 */
if (nloaded == 0 || GL(dl_ns)[ns]._ns_loaded->l_auditing != do_audit)
__rtld_lock_unlock_recursive(GL(dl_load_lock));
else //否则遍历模块
{
/* 把这个命名空间中的所有模块指针, 都复制到maps数组中 */
struct link_map *maps[nloaded];
unsigned int i;
struct link_map *l;
assert(nloaded != 0 || GL(dl_ns)[ns]._ns_loaded == NULL);
for (l = GL(dl_ns)[ns]._ns_loaded, i = 0; l != NULL; l = l->l_next) //遍历链表
if (l == l->l_real) /* Do not handle ld.so in secondary namespaces. */
{
assert(i < nloaded);

maps[i] = l;
l->l_idx = i;
++i;

/* Bump l_direct_opencount of all objects so that they are not dlclose()ed from underneath us. */
++l->l_direct_opencount;
}
...;
unsigned int nmaps = i; //多少个模块

/* 对maps进行排序, 确定析构顺序 */
_dl_sort_fini(maps, nmaps, NULL, ns);

//释放锁
__rtld_lock_unlock_recursive(GL(dl_load_lock));

/* 从前往后, 析构maps中的每一个模块 */
for (i = 0; i < nmaps; ++i)
{
struct link_map *l = maps[i];

if (l->l_init_called)
{
/* Make sure nothing happens if we are called twice. */
l->l_init_called = 0;

/* 是否包含fini_array节, 或者fini节 */
if (l->l_info[DT_FINI_ARRAY] != NULL || l->l_info[DT_FINI] != NULL)
{
/* debug时打印下相关信息 */
if (__builtin_expect(GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS, 0))
_dl_debug_printf("\ncalling fini: %s [%lu]\n\n",DSO_FILENAME(l->l_name),ns);

/* 如果有fini_array节的话 */
if (l->l_info[DT_FINI_ARRAY] != NULL)
{
/*
l->l_addr: 模块l的加载基地址
l->l_info[DT_FINI_ARRAY]: 模块l中fini_array节的描述符
l->l_info[DT_FINI_ARRAY]->d_un.d_ptr: 模块l中fini_arrary节的偏移
array: 为模块l的fini_array节的内存地址
*/
ElfW(Addr) *array = (ElfW(Addr) *)(l->l_addr + l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);

/*
ELF中 fini_arraysz节用来记录fini_array节的大小
l->l_info[DT_FINI_ARRAYSZ]: 模块l中fini_arraysz节描述符
l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val: 就是fini_array节的大小, 以B为单位
i: fini_array节的大小/一个指针大小, 即fini_array中有多少个析构函数
*/
unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val / sizeof(ElfW(Addr)));
while (i-- > 0) //从后往前, 调用fini_array中的每一个析构函数
((fini_t)array[i])();
}

/* 调用fini段中的函数 */
if (l->l_info[DT_FINI] != NULL)
DL_CALL_DT_FINI(l, l->l_addr + l->l_info[DT_FINI]->d_un.d_ptr);
}

...;
}

/* Correct the previous increment. */
--l->l_direct_opencount;
}
}
}

...;
}

思考2:rtdl_fini()带来的可利用点

rtdl_fini()十分依赖与rtld_global这一数据结构, 并且rtld_global中的数据并没有被加密, 这就带来了两个攻击面

  • 劫持rtld_global中的锁相关函数指针
  • 修改rtld_global中的l_info, 伪造fini_array/ fini的节描述符, 从而劫持fini_array/ fini到任意位置, 执行任意函数

0x1劫持rtld_global中的函数指针

ld相关函数在使用rtld_global时都需要先上锁, 以避免多进程下的条件竞争问题

相关函数包括但不限于:

  • _dl_open()
  • _dl_fini()
  • ….

上锁操作是通过宏进行的, 宏定义:

1
2
# define __rtld_lock_lock_recursive(NAME) GL(dl_rtld_lock_recursive) (&(NAME).mutex)
# define GL(name) _rtld_global._##name

查看宏GL的定义:

1
2
3
4
5
# if IS_IN (rtld)
# define GL(name) _rtld_local._##name
# else
# define GL(name) _rtld_global._##name
# endif

宏展开:

1
2
3
    __rtld_lock_lock_recursive(GL(dl_load_lock)); //对rtld_global上锁
=> GL(dl_rtld_lock_recursive) (& GL(dl_load_lock).mutex)
=> _rtld_global.dl_rtld_lock_recursive(&_rtld_global.dl_load_lock.mutex)

可以看到实际调用的是dl_rtld_lock_recursive函数指针

释放锁的操作也是类似的, 调用的是_dl_rtld_unlock_recursive函数指针, 这两个函数指针再rtld_global中定义如下

1
2
3
4
5
6
7
struct rtld_global
{
...;
void (*_dl_rtld_lock_recursive)(void *);
void (*_dl_rtld_unlock_recursive)(void *);
...;
}

ld作为mmap的文件, 与libc地址固定

也就是说, 当有了任意写+libc地址后, 我们可以通过覆盖_rtld_global中的lock/ unlock函数指针来getshell

0x2劫持l_info伪造fini_array节

我们的目标是伪造rtld_global中关于fini_array节与fini_arraysize节的描述

将fini_array节迁移到一个可控位置, 比如堆区, 然后在这个可控位置中写入函数指针, 那么在exit()时就会依次调用其中的函数指针

l_info中关于fini_array节的描述符下标为26, 关于fini_arraysz节的下标是28,l_info中的指针正好指向的就是Dynamic段中相关段描述符

此时我们就可以回答ELF中fini_array中的析构函数是怎么被调用的这个问题了:

exit()调用__exit_funcs链表中的_rtdl_fini()函数, 由_rtdl_fini()函数寻找到ELF的fini_array节并调用

假设我们修改rtld_global中的l_info[0x1a]为addrA, 修改l_info[0x1c]为addrB

那么首先在addrA addrB中伪造好描述符

1
2
addrA: flat(0x1a, addrC)
addrB: flat(0x1b, N)

然后在addrC中写入函数指针就可以在exit时执行了

0x3fini_array与ROP(SROP)

当我们可以劫持fini_array之后, 我们就具备了连续调用多个函数的能力, 那么有无可能像ROP一样, 让多个函数进行组合, 完成复杂的工作?

多个fini_array函数调用之间, 寄存器环境十分稳定, 只有: rdx r13会被破坏, 这是一个好消息

考察执行call时的栈环境, 我们发现rdi总是指向一个可读可写区域, 可以当做我们函数的缓冲区

那么就已经有了大致的利用思路,

  1. 让fini_array先调用gets()函数, 在rdi中读入SigreturnFrame
  2. 然后再调用setcontext+53, 即可进行SROP, 劫持所有寄存器
  3. 如果高版本libc, setcontext使用rdx作为参数, 那么在gets(rdi)后还需要一个gadget, 能通过rdi设置rdx,
    再执行setcontext

0x4劫持fini

fini段在l_info中下标为13,这个描述符中直接放的就是fini函数指针(前面有提到动态链接下这是个空函数,由_dl_fini调用), 利用手法较为简单, 但是只能执行一个函数, 通常设置为onegadget

例如我们可以修改rtld_global中l_info[0xd]为addrA, 然后再addrA中写入

1
addrA: flat(0xd, onegadget)

就可以在exit()时触发

0x5exit()与FILE

一开始的run_exit_handlers么, 在遍历完exit_funcs链表后, 还有最后一句

1
2
if (run_list_atexit) //调用_atexit
RUN_HOOK(__libc_atexit, ());

__libc_atexit其实是libc中的一个段

这个段中就是libc退出时的析构函数

其中默认只有一个函数fcloseall()

这个函数会调用_IO_cleanup()

1
2
3
4
5
int __fcloseall (void)
{
/* 关闭所有流. */
return _IO_cleanup ();
}

_IO_cleanup()会调用两个函数

  • _IO_flush_all_lockp()会通过_IO_list_all遍历所有流, 对每个流调用_IO_OVERFLOW(fp), 保证关闭前缓冲器中没有数据残留
  • _IO_unbuffer_all()会通过_IO_list_all遍历所有流, 对每个流调用_IO_SETBUF(fp, NULL, 0)即无缓冲模式, 来释放流的缓冲区
1
2
3
4
5
6
7
8
9
10
int _IO_cleanup(void)
{
/* 刷新所有流 */
int result = _IO_flush_all_lockp(0);

/* 关闭所有流的缓冲区 */
_IO_unbuffer_all();

return result;
}
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
int
_IO_flush_all_lockp (int do_lock)
{
int result = 0;
struct _IO_FILE *fp;

#ifdef _IO_MTSAFE_IO
_IO_cleanup_region_start_noarg (flush_cleanup);
_IO_lock_lock (list_all_lock);
#endif

for (fp = (_IO_FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
{
run_fp = fp;
if (do_lock)
_IO_flockfile (fp);

if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
|| (_IO_vtable_offset (fp) == 0
&& fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
> fp->_wide_data->_IO_write_base))
)
&& _IO_OVERFLOW (fp, EOF) == EOF)
result = EOF;

if (do_lock)
_IO_funlockfile (fp);
run_fp = NULL;
}

#ifdef _IO_MTSAFE_IO
_IO_lock_unlock (list_all_lock);
_IO_cleanup_region_end (0);
#endif

return result;
}


static void
_IO_unbuffer_all (void)
{
struct _IO_FILE *fp;

#ifdef _IO_MTSAFE_IO
_IO_cleanup_region_start_noarg (flush_cleanup);
_IO_lock_lock (list_all_lock);
#endif

for (fp = (_IO_FILE *) _IO_list_all; fp; fp = fp->_chain)
{
if (! (fp->_flags & _IO_UNBUFFERED)
/* Iff stream is un-orientated, it wasn't used. */
&& fp->_mode != 0)
{
#ifdef _IO_MTSAFE_IO
int cnt;
#define MAXTRIES 2
for (cnt = 0; cnt < MAXTRIES; ++cnt)
if (fp->_lock == NULL || _IO_lock_trylock (*fp->_lock) == 0)
break;
else
/* Give the other thread time to finish up its use of the
stream. */
__sched_yield ();
#endif

if (! dealloc_buffers && !(fp->_flags & _IO_USER_BUF))
{
fp->_flags |= _IO_USER_BUF;

fp->_freeres_list = freeres_list;
freeres_list = fp;
fp->_freeres_buf = fp->_IO_buf_base;
}

_IO_SETBUF (fp, NULL, 0);

if (fp->_mode > 0)
_IO_wsetb (fp, NULL, NULL, 0);

#ifdef _IO_MTSAFE_IO
if (cnt < MAXTRIES && fp->_lock != NULL)
_IO_lock_unlock (*fp->_lock);
#endif
}

/* Make sure that never again the wide char functions can be
used. */
fp->_mode = -1;
}

#ifdef _IO_MTSAFE_IO
_IO_lock_unlock (list_all_lock);
_IO_cleanup_region_end (0);
#endif
}

发现攻击点:

  1. libc2.23以前三个标准流的vtable是可写的,可以修改函数指针

  2. 之后的版本因为位于libc段中的vtable是无法写入的,故可以选择伪造vtable中的setbuf或overflow(其中overflow需要达到一些条件)函数,来达到getshell

例题

2018hctf-the_end

这道题有两种解法,但都是利用exit函数