前言
做了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中的漏洞
-