CTF

“程序员的自我修养之入口函数和程序初始化【11章】”

二进制程序怎么开始?

Posted by 许大仙 on December 10, 2019

前言

做了pwn手这么久,居然程序的加载过程还一脸懵逼。找了一些可参考的书籍和blog留下些什么吧。【其实是做题的时候,不知道.fini和.fini.array是怎么样被使用的】

一直知道是__start做了一些初始化工作,然后调用了main,但是不知道__libc_start_main是在哪里被调用,又有什么功能?在程序进入main之前,做了什么处理?

入口函数(Entry Point)

一个程序的大致运行步骤:

  • OS创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中某个入口函数
  • 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等
  • 入口函数在完成初始化后,调用main函数,正式开始执行程序主体部分
  • main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。

静态glibc用于可执行文件的情况

__start

glibc的程序入口为__start(这个入口是由ld链接器默认的链接脚本所指定的,也可以通过相关参数设定自己的入口)。

__start由汇编实现,和平台相关,以下是i386的__start实现(省略了不重要的部分):

# libc\sysdeps\i386\elf\Start.S
_start:

xorl %ebp, %ebp
popl   %esi
movl  $esp, %ecx
 
pushl          %esp                                     # sp
pushl          %edx                                     # rtld_fini
pushl          $__libc_csu_fini                         # fini
pushl          $__libc_csu_init                         # init
pushl          %ecx                                     # ubp_av
pushl          %esi                                     # argc
pushl          main                                     # main
call           __libc_start_main

hlt

最开始的7个压栈指令用于给函数提供参数,并且最终调用了__libc_start_main。

最开始的3条指令的作用:

  • xorl %ebp, %ebp:清空%ebp寄存器,$ebp=0可以表明当前这个函数是程序的最外层函数

  • popl %esi和movl $esp, %ecx:

    • 在调用__start之前,装载器ld会把用户参数和环境变量压入栈中,因此在调用__start调用之前,栈上的情况为:

    • 回顾int main(int argc,char* argv[], __environ)可知,这里是将环境变量数组、argv和argc压入栈中,因此对于当前栈顶(old esp,虚线箭头),执行popl %esi之后,将argc存入%esi,并且栈顶变为实线箭头esp。
    • 而movl $esp, %ecx将此时的栈顶地址(argv和env的起始地址)传给%ecx

综上最初的3条指令完成了:1.%ebp寄存器清零;2.%esi为用户参数个数argc;3.%ecx指向栈顶;

故__start可以写成一下伪代码:

注意以上argv除了指向参数表【char * argv[]】以外,还隐含着环境变量表,但是在__libc_start_main将环境变量从argv中提取出来。

通过以上分析我们可以得出,__start实际执行代码的函数是__libc_start_main。

__libc_start_main

接下来我们分段看,首先看__libc_start_main的函数头部,包含了很多很多的参数列表,一共有7个参数【和__start函数push压栈参数的顺序一致,可以回看Start.S的代码】,其中main由第一个参数传入,紧接着是argc(即%esi)和argv(即%ecx,这里由于包含了环境变量和argv,故合称为udp_av)。除了main的函数指针之外,外部还要传入3个函数指针,分别是:

1)init:main调用前的初始化工作。

2)fini:main结束后的收尾工作

3)rtld_fini:和动态加载有关的收尾工作,rtld是runtime loader的缩写。

最后的stack_end标明了栈底的地址【是初始的$esp值】,即最高的栈地址。

注:GCC在2003年之前支持bounded类型指针,这种指针占有3个指针的空间(普通指针用__unbounded标出),分别为__ptrvalue存储原指针的值,__ptrlow存储下限值,__ptrhigh存储上限值,这样是为了方便检测内存越界错误。但是由于现在废除了,故在讨论libc代码时都不使用bounded指针(即不定义__BOUNDED_POINTERS__)。

接下来的代码为:

  //根据当前栈的布局获取紧跟在argv数组之后的环境变量的地址
  char **udp_ev = &udp_av[argc + 1];
  __environ = udp_ev;  //这里原来是位于libc/sysdeps/generic/bp-start.h的宏INIT_ARGV_and_ENVIRON,展开后就是让__environ指针指向原来紧跟argv数组之后的环境变量数组。【注意argc只是记录了argv[]的数组大小】
  /* Store the lowest stack address.  This is done in ld.so if this is he code for the DSO.  */
  __libc_stack_end = stack_end; //将栈底地址存储在一个全局变量中,留作他用。

接下来的代码片段:

      /* 
      Starting from binutils-2.23, the linker will define the magic symbol __ehdr_start to point to our own ELF header if it is visible in a segment that also includes the phdrs. So we can set up _dl_phdr and _dl_phnum even without any information from auxv.  
      获取程序入口点地址
      */
      extern const ElfW(Ehdr) __ehdr_start
    __attribute__ ((weak, visibility ("hidden")));
      if (&__ehdr_start != NULL)
        {
          assert (__ehdr_start.e_phentsize == sizeof *GL(dl_phdr));
          GL(dl_phdr) = (const void *) &__ehdr_start + __ehdr_start.e_phoff;
          GL(dl_phnum) = __ehdr_start.e_phnum;
        }
    }
//这是用于检测操作系统版本的宏
# ifdef DL_SYSDEP_OSCHECK
  if (!__libc_multiple_libcs)
    {
      /* This needs to run to initiliaze _dl_osversion before TLS
     setup might check it.  */
      DL_SYSDEP_OSCHECK (__libc_fatal);
    }
# endif

此后的代码片段很繁杂,只留下一些关键片段分析:

这一部分执行了一连串的函数调用,注意到__cxa_atexit函数是glibc的内部函数【等同于atexit】,用于由参数指定的函数指针在main结束之后调用。所以以参数传入的fini和rtld_fini均是用于main结束之后调用的。

并且这里还调用了__libc_init_first和init函数。

注意:init为__libc_csu_init函数指针,fini为__libc_csu_fini函数指针,rtld_fini为运行库加载收尾函数指针。

最后在__libc_start_main的末尾:

	result = main (argc, argv, __environ); //main函数终于被调用了
	exit (result);//根据main函数的返回值,执行exit
}

然后我们来看看exit的实现:

其中的_exit_funcs是存储由_cxa_atexit和atexit注册的函数的链表,而这里的这个while环则遍历该链表并逐个调用这些注册的函数。

在_exit调用后,进程就会直接结束。

一个二进制程序的调用链

综上可以明确:

一个二进制程序的调用链为:_start -> __libc_start_main -> __libc_csu_init->_init -> main ->__libc_csu_fini -> _fini.

_init, _fini函数功能主要负责完成C++程序全局/静态对象的构造与析构

最后_exit(status);函数由汇编实现,且与平台相关,在i386平台下:

可见_exit的作用仅仅是通过传递exit的系统调用号到%eax,进行了int 0x80系统调用。也就是说,_exit调用后,进程就会直接结束。程序正常结束有两种情况,一种是main函数的正常返回,一种是程序中用exit退出。在__libc_start_main里我们可以看到,即使main返回了,exit也会被调用。exit是进程正常退出的必经之路,因此把调用用atexit注册的函数的任务交给exit来完成可以说万无一失。

注意:

  • _start和_exit的末尾都有一个hlt指令。
  • 这是因为在linux中,进程必须使用exit系统调用结束。一旦exit被调用,程序的运行就会终止,因此实际上_exit末尾的hlt不会执行,从而__libc_start_main永远不会返回【exit(status)的最后一步,这一步完成程序就结束了】,以至_start末尾的hlt指令也不会执行。
  • _exit里的hlt指令是为了检测exit系统调用是否成功,如果失败,程序就不会终止,hlt指令就可以发挥作用,强行把程序停止下来。而_start里的hlt的作用也是如此,是为了预防某种没有调用exit就回到了_start的情况(例如有人误删了__libc_main_start末尾的exit,hlt还可以守住最后一条防线)。
  • 为了预防某种没有调用exit就回到了_start的情况(例如被误删了__libc_main_start末尾的exit)

补充点

.init段和.finit段

往往我们可以看到IDA的function窗口中是以下情况:

_start,_init,_fini这三个函数不是动态链接进来的,在libc.so.6中找不到。这是因为GNU把这三个作为了程序启动和结束的最基本运行库函数,分别放在crt1.o,crti.o,crtn.o这三个object文件中供程序链接时使用【相当于默认是每个程序都要静态链接进来的】。

.init段和.finit段,这是与初始化相关的两个段,这两个段分别用于在main函数之前执行全局/静态对象构造和在main函数之后执行全局/静态对象析构。

链接器在链接的时候会把所有输入文件中的”.init”和“.finit”按顺序收集起来,把它们合并成输出文件中的”.init”和”.finit”,而最终输出文件中的这两个段实际分别包含了_init()和_finit()两个函数。而要启动这两个段的指令还需要一些辅助代码,于是就引入了两个静态文件ctri.o和ctrn.o。

atexit

接受一个函数指针作为参数,并在程序正常退出(从main里返回或调用exit函数)时,这个函数指针会被调用。

.init_array和.fini_array数组

IDA的 view -> open subviews -> segments可以看到如下四个段:

  • .init
  • .init_array
  • .fini
  • .fini_array

点进去即可看到.init和.fini是可执行的段,是代码,是函数。而.init_array和.fini_array是数组,里面存着函数的地址,这两个数组里的函数由谁来执行呢?

其实就是:__libc_csu_fini和__libc_csu_init。

__libc_csu_init,__libc_csu_fini函数可以在glibc源码的csu/elf-init.c中找到:

void
__libc_csu_init (int argc, char **argv, char **envp)
{
  /* For dynamically linked executables the preinit array is executed by
     the dynamic linker (before initializing any shared object.  */

#ifndef LIBC_NONSHARED
  /* For static executables, preinit happens right before init.  */
  {
    const size_t size = __preinit_array_end - __preinit_array_start;
    size_t i;
    for (i = 0; i < size; i++) //还有preinit
      (*__preinit_array_start [i]) (argc, argv, envp);
  }
#endif
  _init (); //调用_init
  const size_t size = __init_array_end - __init_array_start;
  for (size_t i = 0; i < size; i++)//这里遍历了.init_array函数指针,并调用
      (*__init_array_start [i]) (argc, argv, envp);
}
/* This function should not be used anymore.  We run the executable's
   destructor now just like any other.  We cannot remove the function,
   though.  */
void
__libc_csu_fini (void)
{
#ifndef LIBC_NONSHARED
  size_t i = __fini_array_end - __fini_array_start;
  while (i-- > 0)//这里遍历了.finit_array函数指针,并调用
    (*__fini_array_start [i]) ();

  _fini (); //调用_fini
#endif
}

也就是说:

  • __libc_csu_init执行.init【_init()】和.init_array
  • __libc_csu_fini执行.fini【_finit()】和.fini_array
更细致的说整个程序执行顺序如下
  • __start
  • __libc_start_main
  • __libc_csu_init
  • .init
  • .init_array[0]
  • .init_array[1]
  • .init_array[n]
  • main
  • __libc_csu_fini
  • .fini_array[n]
  • .fini_array[1]
  • .fini_array[0]
  • .fini
.fini_array数组中的函数是倒着调用的

.fini_array数组和exit()代码片段相关,前面提到过:

在进行最后的_exit系统调用之前,会先遍历注册的函数。

通过__cxa_atexit注册的过程其实就是建立一个exit函数指针链表栈,按栈的规则是后进先出,因此先注册的后调用,后注册的先调用。

由于之前在__libc_start_main注册过fini函数指针【实际就是__libc_csu_fini】,如下:

因此会调用__libc_csu_fini,而在__libc_csu_fini中会遍历.fini_array函数指针数组【也一样是倒着调用,FILO】并调用,最终才调用_fini(),然后返回到exit(status)中执行_exit。

覆写.fini_array

通过查看.fini_array段,我们可以明确一个二进制程序由多少个.fini_array数组元素。

如果能够通过fastbin attack在.fini_array段伪造一个chunk,对其进行写入。那么就可以实现在程序退出时,自动调用该函数。

例如,对于.fini_array有两个函数的情况,可以知道函数的执行顺序如下:

+—————–+ +———————+ +———————+ +———————+ | | | | | | | | | main | +—-> | __libc_csu_fini | +——>| .fini_array[1] | +—–>| .fini_array[0] | | | | | | | | | +—————–+ +———————+ +———————+ +———————+

可以有多种覆写方式

  • 把fini_array[1]覆盖成任意代码的地址,就可以成功劫持%eip了。

    • 如果直接有后门函数或system(“/bin/sh”)或shellcode或onegadget,那么直接修改 .fini_array[1]就可以getshell
  • 对于需要多次利用漏洞的情况,考虑把.fini_array[1]覆盖成main,把 .fini_array[0]覆盖成 __libc_csu_fini

    • 那么调用顺序就变为:

    • +—————–+ +———————+ +———————+ +———————+ | | | | | | | | | main | +—-> | __libc_csu_fini | +——>| .fini_array[1] | +—–>| .fini_array[0] | | | | | | main | | __libc_csu_fini | +—————–+ +———————+ +———————+ +———————+

      ​ ^ +

       

      ​ +———————————-+

    • 这可以样就可以一直循环调用main函数了,从而可以多次复用main中的漏洞

参考链接