exit

重要成员

void
exit (int status)
{
    __run_exit_handlers (status, &__exit_funcs, true, true);
}

首先要关注这个handlers的第二个参数,这里留下一个疑问:为什么这个函数带一个handler

struct exit_function_list *__exit_funcs = &initial; __exit_funcs被定义为一个指向 exit_function_list 结构体的指针,并将其初始化为 initial 的地址,然后看看struct exit_function_list

struct exit_function_list
{
    struct exit_function_list *next;
    size_t idx;
    struct exit_function fns[32];
};

然后就是这个exit_function类型的结构体数组(数组中每一个元素都是结构体成员,不是结构体指针奥)

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

glibc-2.31/sysdeps/unix/sysv/linux/_exit.c,调用INLINE_SYSCALL就直接退出了

run_exit_handlers

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

void attribute_hidden __run_exit_handlers(int status, struct exit_function_list **listp, bool run_list_atexit)
{
//首先释放线程局部储存, 即TLS
__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;   //这两种类型不调用

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

   //---------------------on类型的参数为注册时设定的参数
  case ef_on:
    onfct = f->func.on.fn; //设置函数指针
#ifdef PTR_DEMANGLE
    PTR_DEMANGLE(onfct);
#endif
    onfct(status, f->func.on.arg);
    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系统调用,用户层面的析构完后就属于内存层面的析构了(也就是exit系统调用)
}

这里直接是调用注册好的函数,还得再看看怎么注册函数的

atexit

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

/* 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/共享库被卸载时调用的函数,只会被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链表上注册析构函数的位置, 然后进行写入

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

重点关注下func 析构函数指针arg 参数指针,把传进来的函数和参数写到__exit_funcs上就算注册完毕了

然后就是__new_exitfn函数,主要就是分配空间的,类似分配器,__internal_atexit就类似构造器

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中找到了空闲的exit_function位置, 停止链表遍历
      break;
    l->idx = 0; //初始化析构函数数量
  }

  if (l == NULL || i == sizeof(l->fns) / sizeof(l->fns[0])) //没有找到空闲位置,一般来说i就等于数组的长度
  {
    /*
    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;
}

大概流程就是:尝试在__exit_funcs中找到一个exit_function类型的ef_free的位置, ef_free代表着此位置空闲 如果没找到, 就新建一个exit_function节点, 使用头插法插入__exit_funcs链表, 使用新节点的第一个位置作为分配到的exit_function结构体 设置找到的exit_function的类型为ef_us, 表示正在使用中, 并返回

程序启动流程

然后再看run_exit_handlers的流程,是在调用析构函数之后才注册新的exit_function,所以一开始调用的函数实在之前就注册了的,那就要从程序的启动开始看看

新程序的启动往往是通过libc中exe()系列函数进行的, exe系列函数最终都可以归纳为execve这个系统调用 在系统层面,kernel会检查这个文件的类型,确定是elf之后会为新进程分配页表, 文件描述符, task描述符等各种资源,然后解析这个elf文件, 把text data bss等段都映射到内存中,然后jmp到elf的入口点, 从而开始执行。

一般情况下是jmp到text段的首地址开始执行,但是现代ELF一般都使用了运行时重定位机制,text段还有些地址位置还没有确定(比如各种libc库函数的地址,编译时并不知道libc会被mmap到哪里),此时内核会转而jmp到这个elf指定的动态链接器(也就是常用的ld.so.2),由ld去重定位elf中相关地址后再jmp到elf的入口点。ld并不是直接执行main()函数, 因为有析构函数就必定有构造函数, 在进入main之前还需要进行程序构造操作比如参数设置, 申请流缓冲区等操作,实际上ld会跳转到elf中的_start标号处, 这才是elf中第一个被执行的指令地址

_start标号处的程序由汇编编写, 对应libc中start.S文件, _start做的工作很少, 只会为__libc_start_main()设置好参数, 然后调用它,_start()会在编译的时候被链接入ELF文件中,而libc_start_main()定义在libc中, _start()通过PLT+GOT调用到libc_start_main(),这个才是程序真正的构造函数

.text:0000000000001360                               ; void __fastcall __noreturn start(__int64, __int64, void (*)(void))
.text:0000000000001360                               public start
.text:0000000000001360                               start proc near                         ; DATA XREF: LOAD:0000000000000018o
.text:0000000000001360                               ; __unwind {
.text:0000000000001360 F3 0F 1E FA                   endbr64
.text:0000000000001364 31 ED                         xor     ebp, ebp
.text:0000000000001366 49 89 D1                      mov     r9, rdx                         ; rtld_fini
.text:0000000000001369 5E                            pop     rsi                             ; argc
.text:000000000000136A 48 89 E2                      mov     rdx, rsp                        ; ubp_av
.text:000000000000136D 48 83 E4 F0                   and     rsp, 0FFFFFFFFFFFFFFF0h
.text:0000000000001371 50                            push    rax
.text:0000000000001372 54                            push    rsp                             ; stack_end
.text:0000000000001373 45 31 C0                      xor     r8d, r8d                        ; fini
.text:0000000000001376 31 C9                         xor     ecx, ecx                        ; init
.text:0000000000001378 48 8D 3D CA 00 00 00          lea     rdi, main                       ; main
.text:000000000000137F FF 15 53 3C 00 00             call    cs:__libc_start_main_ptr

ida比源码好看(),需要的参数都被符号标出来了。所以注册析构函数的关键点就在__libc_start_main()中,这个函数很复杂(所以才放到libc),我们只看关键部分

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保存一些关于main的参数,比如__environ…,通过atexit()注册fini 与 rtld_fini 这两个参数,调用init为main()进行构造操作,然后调用main()函数 我们主要关注这些

static int __libc_start_main(...)
{
/* 注册动态链接器(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把所有的构造函数的指针放在一个段: .init_array中, 所有的析构函数的指针放在一个段 .fini_array中(只有fini与init的话, ELF只能有一个构造/ 析构函数,那么ELF应对的场景就很单一)

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

void __fastcall _libc_csu_init(unsigned int a1, __int64 a2, __int64 a3)
{
  signed __int64 v4; // rbp
  __int64 i; // rbx

  init_proc();
  v4 = &_do_global_dtors_aux_fini_array_entry - &_frame_dummy_init_array_entry;
  if ( v4 )
  {
    for ( i = 0LL; i != v4; ++i )
      ((void (__fastcall *)(_QWORD, __int64, __int64))*(&_frame_dummy_init_array_entry + i))(a1, a2, a3); //调用构造函数
  }
}

也就是常用的ret2csu的这里。ELF不负责fini(), 在ELF里它就是一个空函数, 那么析构函数只能由rtdl_fini来负责。rtdl_fini实际指向_dl_fini()函数, 源码在dl-fini.c文件中, 会被编译到ld.so.2中

这又是一个很复杂的函数,为了不在源码中迷失, 首先要知道_dl_fini()的功能就是调用进程空间中所有模块的析构函数。进程空间中的一个单独文件称之为模块(标记,这里不是很懂)

重点就是这个所谓的模块的结构了,它保存在rtld_global这个结构体中,这同样是一个非常复杂的结构体,我们只看与模块相关的部分

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

        struct r_debug _ns_debug;    //调试信息
    } _dl_ns[DL_NNS]; //一个数组的元素代表一个命名空间,代表一个link_namespace结构体

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

    //...;
}

一些缩写的含义:ns代表着NameSpace,nns代表着Num of NameSpace,rtld_global先以命名空间为单位建立了一个数组 _dl_ns[DL_NNS],在每个命名空间内部加载的模块以双向链表组织, 通过_ns_loaded索引 同时每个命名空间内部又有一个符号表_ns_unique_sym_table, 记录着所有模块导出的符号集合。namespace就是一个进程的集合, 这个进程集合中可以看到相同的全局资源, 并与其他命名空间独立

_dl_ns里重要的就是_ns_loaded也就是link_map指针。ELF文件都是通过节的组织的, ld自然也延续了这样的思路

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节中有多少描述符  */

    //...;
}

l_info中的指针都指向ELF中Dyn节中的描述符, Dyn中节描述符类型是ElfW(Dyn)

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的任务就显而易见了:1 遍历rtld_global中所有的命名空间, 2 遍历每个命名空间中的所有模块, 3 找到每个模块的fini_array段, 并调用其中的所有函数指针, 4 找到这个模块的函数指针然后调用,源码在dl-fini.c文件中

利用点

劫持rtld_global中的函数指针

劫持fini_array/ fini