• 碎碎念:之前看到打exit的题目都有点不太像去看,这周尝试去看了一题打exit的,虽然没打出来但是在牢题目的时候大致了解了exit的大致流程,也知道了哪些地方可以进行利用。并且还了解了exit函数,在系统调用exit之前会进行一个IO_FILE的刷新流。所以类似于house of apple这种堆利用基本上也快可以理解了。
  • 在打exit的时候也尝试去劫持vtable,虽然也没利用成功,但是也理解了FSOP的相关利用方式,感觉堆的30种类型应该这个学期就能结束了。(结束了就开内核了,有点单线程,在堆没结束前不太想碰其他的pwn题。)
  • 参考博客:exit()分析与利用-安全KER - 安全资讯平台

exit函数执行流程(结束时)

  • exit()函数的源码位于glibc-2.35\stdlib\exit.c,在exit()函数调用的还会用到exit.h

  • 由于低版本的堆由于hook函数,利用还是比较容易的,所以这个exit()函数,就使用glibc2.35版本的源码进行调试(刚好本地Ubuntu的libc版本其实也是glibc2.35),本地就有现成环境。

  • 首先总体介绍一下exit()函数的调用流程,注意glibc中的exit()exit系统调用需要区分开来:

1
2
3
4
5
exit()->
__run_exit_handlers()->
__call_tls_dtors()
__libc_atexit()
_exit() // 执行exit系统调用

image-20250909180658225

exit函数两个重要结构体

  • 在介绍exit函数执行流程之前,需要介绍两个结构体,以便于理解exit()函数的执行过程。
  • 结构体1:exit_function结构体,这里其实是一个注册函数的结构体。
  • 程序在启动的时候会注册许多函数,这些函数在程序运行结束的时候调用exit()的时候就会被exit()函数一个一个的执行过去,主要目的是在退出程序的时候可以自动执行一些清理操作,从而达到自动释放资源的目的。
  • exit_function结构体主要就两个东西:
    • flavor变量:用于表示是否有注册函数,flavor=0时表示没有注册函数,flavor=1的时候表示存在注册函数,该函数没有参数传递进去。flavor=2的时候表示存在注册函数,该函数调用时带有参数。flavor=3的时候表示存在注册函数,该函数是C++的析构函数。
    • 析构函数:C++在使用类模版创建了一个实例对象之后,程序结束后需要调用该析构函数释放这个实例对象。
    • func这个联合体:存储着函数指针,需要传递的参数,以及析构函数的void *dso_handle;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
union
{
void (*at) (void);
struct
{
void (*fn) (int status, void *arg);
void *arg;
} on;
struct
{
void (*fn) (void *arg, int status);
void *arg;
void *dso_handle;
} cxa;
} func;
};
  • 结构体2:exit_function_list,用于管理注册函数的链表,是单向链表,其结构体如图所示。
1
2
3
4
5
6
struct exit_function_list
{
struct exit_function_list *next;
size_t idx;
struct exit_function fns[32];
};

image-20250909183535553

  • 程序在执行main函数之前调用了glibc中的某些函数来注册函数,而glibc也向用户提供了atexit()、on_exit()、__cxa_atexit()这三个注册函数,使得我们在Linux编写C程序的时候也能注册属于自己的一些函数。注意:注册的函数按照栈结构进行调用,也就是后面注册的函数,会先被执行
    • atexit()函数注册的是无参函数
    • on_exit()函数注册的是带参数的函数
    • __cxa_atexit()函数注册的是析构函数
  • 先注册一个无参函数并调试看看
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 注册一个无参函数,该函数会在程序执行结束后执行
// int atexit(void (*func)(void))
#include<stdio.h>
#include<stdlib.h>
void my_atexit1(void)
{
printf("This is my_atexit\n");
}

int main()
{
if (atexit(my_atexit1) !=0)
{
printf("atexit() error\n");
}
printf("This program is over\n");
return 0;
}

image-20250910192635113

在调用atexit之前进行查看initial这个结构体,该结构体就是libc中存储注册函数的结构体,也就是这个结构体exit_function_list

image-20250910192940226

调用atexit后,该结构体的fns就会多出来一个结构体,并且idx也会变成2,还会发现at这个函数指针并不是真实的函数地址是被加密过的函数地址。

image-20250910193208482

  • 接下来再注册一个带参数的函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// int on_exit(void (* function) (int void*), void *arg);
#include<stdio.h>
#include<stdlib.h>
void my_atexit2(int status,void *s)
{
printf("bye~ %s\n",(char*)s);
}

int main()
{
char *msg = "Tom";
if (on_exit(my_atexit2,msg) !=0)
{
printf("atexit() error\n");
}
printf("This program is over\n");
return 0;
}

image-20250910194011806

image-20250910194054142

image-20250910194112657

  • 第三个就不举例了,接下来还需要看看,如果函数注册满了32个之后,还有函数需要注册那么这些函数会放在哪里呢?下面这个程序探究一下这个。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<stdio.h>
#include<stdlib.h>
void my_atexit1(void)
{
printf("This is my_atexit\n");
}

int main()
{
for (int i = 0; i < 35; i++)
{
if (atexit(my_atexit1) !=0)
{
printf("atexit() error\n");
}
}
printf("This program is over\n");
return 0;
}
  • 调试情况如下,当超过32个的时候,再进行函数的注册就会申请堆块用做后续的注册。

image-20250910195402776

  • 而这个堆块的next指针,指向的是initial这个结构体,也就是位于glibc中的exit_function_list。所以从某种程度上说明了后注册的函数先被调用。当只有initial这个结构体存在,还是后被注册的函数先被执行。

image-20250910195639862

exit函数源码

  • 源码如下:首先是一个简单的封装exit()函数
1
2
3
4
5
6
void
exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true, true);
}
libc_hidden_def (exit)
  • 接下来就是__run_exit_handlers()函数
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
void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
bool run_list_atexit, bool run_dtors)
{
/* First, call the TLS destructors. */
#ifndef SHARED
if (&__call_tls_dtors != NULL)
#endif
if (run_dtors)
__call_tls_dtors ();

__libc_lock_lock (__exit_funcs_lock);

/* We do it this way to handle recursive calls to exit () made by
the functions registered with `atexit' and `on_exit'. We call
everyone on the list and use the status value in the last
exit (). */
while (true)
{
struct exit_function_list *cur = *listp;

if (cur == NULL)
{
/* Exit processing complete. We will not allow any more
atexit/on_exit registrations. */
__exit_funcs_done = true;
break;
}

while (cur->idx > 0)
{
struct exit_function *const f = &cur->fns[--cur->idx];
const uint64_t new_exitfn_called = __new_exitfn_called;

switch (f->flavor)
{
void (*atfct) (void);
void (*onfct) (int status, void *arg);
void (*cxafct) (void *arg, int status);
void *arg;

case ef_free:
case ef_us:
break;
case ef_on:
onfct = f->func.on.fn;
arg = f->func.on.arg;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (onfct);
#endif
/* Unlock the list while we call a foreign function. */
__libc_lock_unlock (__exit_funcs_lock);
onfct (status, arg);
__libc_lock_lock (__exit_funcs_lock);
break;
case ef_at:
atfct = f->func.at;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (atfct);
#endif
/* Unlock the list while we call a foreign function. */
__libc_lock_unlock (__exit_funcs_lock);
atfct ();
__libc_lock_lock (__exit_funcs_lock);
break;
case ef_cxa:
/* To avoid dlclose/exit race calling cxafct twice (BZ 22180),
we must mark this function as ef_free. */
f->flavor = ef_free;
cxafct = f->func.cxa.fn;
arg = f->func.cxa.arg;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (cxafct);
#endif
/* Unlock the list while we call a foreign function. */
__libc_lock_unlock (__exit_funcs_lock);
cxafct (arg, status);
__libc_lock_lock (__exit_funcs_lock);
break;
}

if (__glibc_unlikely (new_exitfn_called != __new_exitfn_called))
/* The last exit function, or another thread, has registered
more exit functions. Start the loop over. */
continue;
}

*listp = cur->next;
if (*listp != NULL)
/* Don't free the last element in the chain, this is the statically
allocate element. */
free (cur);
}

__libc_lock_unlock (__exit_funcs_lock);

if (run_list_atexit)
RUN_HOOK (__libc_atexit, ());

_exit (status);
}

__call_tls_dtors

  • 首先会调用一个比较关键的函数__call_tls_dtors (),该函数与线程局部存储有关。
1
2
3
4
5
6
// 先调用TLS析构函数
#ifndef SHARED
if (&__call_tls_dtors != NULL)
#endif
if (run_dtors)
__call_tls_dtors ();

调用注册函数

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
while (true)
{
struct exit_function_list *cur = *listp;

if (cur == NULL)
{
__exit_funcs_done = true;
break;
}

while (cur->idx > 0)
{
struct exit_function *const f = &cur->fns[--cur->idx];
const uint64_t new_exitfn_called = __new_exitfn_called;

switch (f->flavor)
{
void (*atfct) (void);
void (*onfct) (int status, void *arg);
void (*cxafct) (void *arg, int status);
void *arg;

case ef_free:
case ef_us:
break;
case ef_on:
onfct = f->func.on.fn;
arg = f->func.on.arg;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (onfct);
#endif
__libc_lock_unlock (__exit_funcs_lock);
onfct (status, arg);
__libc_lock_lock (__exit_funcs_lock);
break;

case ef_at:
atfct = f->func.at;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (atfct);
#endif
__libc_lock_unlock (__exit_funcs_lock);
atfct ();
__libc_lock_lock (__exit_funcs_lock);
break;

case ef_cxa:
f->flavor = ef_free;
cxafct = f->func.cxa.fn;
arg = f->func.cxa.arg;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (cxafct);
#endif
__libc_lock_unlock (__exit_funcs_lock);
cxafct (arg, status);
__libc_lock_lock (__exit_funcs_lock);
break;
}

if (__glibc_unlikely (new_exitfn_called != __new_exitfn_called))
continue;
}

*listp = cur->next;
if (*listp != NULL)
free (cur);
}

刷新IO缓冲区

  • 最后在_exit(status)执行之间还会有RUN_HOOK (__libc_atexit, ());,我们会在/libio/genops.c中找到__libc_atexit这个函数。这里还出现了_IO_cleanup以及text_set_element宏定义
  • __libc_atexit其实是位于libc中的一个段(即一块内存空间,该空间名为__libc_atexit
1
text_set_element(__libc_atexit, _IO_cleanup);
  • 而这个宏定义出现在glibc-2.35\glibc-2.35\include\libc-symbols.h
  • 这个宏定义其实就是将_IO_cleanup,写入到__libc_atexit
1
2
/* Make SYMBOL, which is in the text segment, an element of SET.  */
#define text_set_element(set, symbol) _elf_set_element(set, symbol)
  • 最后在回到 RUN_HOOK (__libc_atexit, ());这个位置其实就很好理解了。这个代码其实执行的就是_IO_cleanup
  • _IO_cleanup会调用_IO_flush_all_lockp (0);是刷新已经打开的流,并在需要的时候上一个锁机制。还会调用_IO_unbuffer_all ();用于将所有打开的流的缓冲模式变成无缓冲模式,然后将缓冲区进行释放。
  • _IO_unbuffer_all ()、_IO_flush_all_lockp (0)这两个函数都是IO函数,在调试exit()的时候就不需要仔细调试,可以放在调试IO的时候仔细调试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int
_IO_cleanup (void)
{
/* We do *not* want locking. Some threads might use streams but
that is their problem, we flush them underneath them. */
int result = _IO_flush_all_lockp (0);

/* We currently don't have a reliable mechanism for making sure that
C++ static destructors are executed in the correct order.
So it is possible that other static destructors might want to
write to cout - and they're supposed to be able to do so.

The following will make the standard streambufs be unbuffered,
which forces any output from late destructors to be written out. */
_IO_unbuffer_all ();

return result;
}
  • 下面这个是_IO_flush_all_lockp的代码。
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
int _IO_flush_all_lockp (int do_lock)
{
int result = 0;
FILE *fp;

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

for (fp = (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;
}
  • 下面这个就是_IO_unbuffer_all ();的源码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
static void _IO_unbuffer_all (void)
{
FILE *fp;

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

for (fp = (FILE *) _IO_list_all; fp; fp = fp->_chain)
{
int legacy = 0;

#if SHLIB_COMPAT (libc, GLIBC_2_0, GLIBC_2_1)
if (__glibc_unlikely (_IO_vtable_offset (fp) != 0))
legacy = 1;
#endif

if (! (fp->_flags & _IO_UNBUFFERED)
&& (legacy || 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
__sched_yield ();
#endif

if (! legacy && ! 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 (! legacy && 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
}
if (! legacy)
fp->_mode = -1;
}

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

exit函数调试

exit函数执行流程(开始时)

  • exit函数在开始的时候并没有执行,而是进行动态链接,将exit()函数的地址绑定到起来。但是这一绑定的过程其实是比较容易利用的。

总结

  • 对于exit函数的利用其实就是好几个:

    • 利用__call_tls_dtors这个TLS析构函数,该函数会触发一个函数指针。
    • 利用注册函数,但是这个函数通常会被fs:0x30加密,如果想要修改注册函数,就必须先泄露fs:0x30或者先修改fs:0x30
    • IO函数刷新缓冲区,这个其实就是_IO_FILE的利用,house of apple就与这个相关了,进行这个利用其实是最经常用的了。
    • 劫持rtdl_fini()中的函数指针,劫持l_info伪造fini_array节,使用fini_array进行ROP,以及劫持fini
  • 为了加深对exit()函数的执行过程,其实对应IO利用的exit()函数这里暂时不拿例题。只拿exit()函数其他两个利用的例题。

题目1——