之前偶尔写一些逆向的时候总会看到 IDA 反汇编出来的函数列表里面有 _start 这样的奇奇怪怪的函数,在调试的时候 main 函数 return 之后也会到一些奇奇怪怪的地方去。感觉是应该去把这些东西给理一下了。

# 程序的启动和 execve

我们在 shell 中执行一个程序的时候,Linux 内核会去装载这个程序并且去执行。

按照以前所学的 shell 的实现,当在终端中输入了一条命令之后,shell 会进行 fork() 调用创建一个新的进程,并在新的进程中执行这条命令。

void run_command(char* command, char* args[], int args_cnt){
    //background 指定命令是否在后台运行
    int background = 0;
    if (strcmp(args[args_cnt - 1], "&") == 0){
        // 以 & amp; 参数结尾的命令,在后台中运行
        args[args_cnt - 1] = NULL;
        background = 1;
    }
    if (run_inner_command(command, args)){ // 排除掉 shell 的内置指令
        //shell 会 fork 一个新的进程
        pid_t pid = fork();
        if (pid < 0){
            //fork 失败,报错
            printf("Unable to fork child process!\n");
        }
        else
            // 既然 pid 是 0,说明是子进程
            if (pid == 0){
                fflush(stdout);
                // 调用 execvp 去执行命令,这个 execvp 下面再说
                if (execvp(command, args) == -1)
                    // 返回值 - 1 说明执行失败
                    printf("Error when executing command %s\n", command);
                // 结束子进程的执行
                exit(0);
            }
            else {
                //pid 不是 0,是父进程
                if (!background)
                    // 如果命令不是后台执行那么就得等待子进程结束了
                    waitpid(pid, NULL, 0);
                else {
                    fflush(stdout);
                }
            }
    }
}

前面提到了 execvp(const char* file, char* const argv[]) ,这个函数定义在 unistd.h 中。这个头文件中还定义了其他的一系列函数:

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

应该说这些函数实际上都是通过调用 execve 来实现的。这些函数定义在 glibc 中,通过一个系统调用来实现。

glibc 中的 execve 产生的系统调用会被 Linux 内核接收并处理,Linux 内核会读取对应的文件并装载入内存进行执行。内核中对这个过程的实现包括了一系列的函数,在这里就不多说了。

# 入口点和 _start

我们写一个简单的程序。

int main(){
}

这仅仅是一个空的函数体,只能勉强称之为 “程序”。编译之后,通过 readelf 读取文件头信息:

ELF 头:
  Magic:  7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  类别:                              ELF32
  数据:                              2 补码,小端序 (little endian)
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI 版本:                          0
  类型:                              DYN (Position-Independent Executable file)
  系统架构:                          Intel 80386
  版本:                              0x1
  入口点地址:              0x1050
  程序头起点:              52 (bytes into file)
  Start of section headers:          14180 (bytes into file)
  标志:             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         12
  Size of section headers:           40 (bytes)
  Number of section headers:         36
  Section header string table index: 35

可以看到程序的入口点是 0x1050 。我们通过 objdump 反汇编查看对应的入口点:

00001050 <_start>:
    1050:       f3 0f 1e fb             endbr32 
    1054:       31 ed                   xor    ebp,ebp
    1056:       5e                      pop    esi
    1057:       89 e1                   mov    ecx,esp
    1059:       83 e4 f0                and    esp,0xfffffff0
    105c:       50                      push   eax
    105d:       54                      push   esp
    105e:       52                      push   edx
    105f:       e8 18 00 00 00          call   107c <_start+0x2c>
    1064:       81 c3 9c 2f 00 00       add    ebx,0x2f9c
    106a:       6a 00                   push   0x0
    106c:       6a 00                   push   0x0
    106e:       51                      push   ecx
    106f:       56                      push   esi
    1070:       ff b3 f8 ff ff ff       push   DWORD PTR [ebx-0x8]
    1076:       e8 c5 ff ff ff          call   1040 <__libc_start_main@plt>
    107b:       f4                      hlt    
    107c:       8b 1c 24                mov    ebx,DWORD PTR [esp]
    107f:       c3                      ret    

和常识并不相符 —— 程序的入口点实际上对应的是这个 _start 函数,而不是 main 函数。

如果用 GDB 进行调试,实际上程序在最开始会从 _start 跳转进入 _dl_start 函数进行操作,这个函数是 glibc 中的一个函数,Linux 系统中在执行 execve (实际上是 do_execveat_common )时调用它,主要是用来进行动态链接。在程序执行结束之后,也有一个类似的函数用来解除动态链接。

最开始的 endbr32 (或者 endbr64 )指令的作用是用作跳转的目的地址,跳转的目的地址位置上的指令是 endbr32 说明这是这个跳转有效,这个指令此外并没有作用。

首先这里执行了 xor ebp, ebp ,将 ebp 的值置为 0。接下来, pop esi 指令将栈顶的数据弹出到 esi 中。

图片是在执行 pop esi 的上一步操作,此时 esi 的值是之前 dl_start 调用留下的,指向栈上的一个位置。而 EBP 的值已经被设置为了 0x0。

在执行完 pop esi 之后,我们观察程序的栈:

先前在栈顶的位置上是 argc (0x1),被弹出之后,现在剩下 argvenvp

mov ecx, espesp 的值放入 ecx 中,也就是将 ecx 指向目前栈顶的位置,这个位置存放的实际上就是 argv

接下来的操作 and esp, 0xfffffff0esp 的最低 4 位置为了 0。这一步显然会让栈顶向上移动,但是移动的距离是不固定的,取决于执行这条命令之前栈顶的位置。这样操作的目的在于将栈顶和 4 字节对齐。

接下来的一段代码是这些:

push    eax
push    esp
push    edx
call    _start+0x2c

在执行 call _start+0x2c 之前,程序的寄存器和栈分别是:

 EAX  0xf7ffda20 —▸ 0x56555000 ◂— 0x464c457f
 EBX  0xf7ffcfd4 (_GLOBAL_OFFSET_TABLE_) ◂— 0x37f2c
 ECX  0xffffd034 —▸ 0xffffd210 ◂— '/home/syml/Temp/test'
 EDX  0xf7fcaa30 (_dl_fini) ◂— endbr32 
 EDI  0x56556050 (_start) ◂— endbr32 
 ESI  0x1
 EBP  0x0
*ESP  0xffffd024 —▸ 0xf7fcaa30 (_dl_fini) ◂— endbr32 
*EIP  0x5655605f (_start+15) ◂— call   0x5655607c
----------------------------------------------------------------
00:0000│ esp 0xffffd024 —▸ 0xf7fcaa30 (_dl_fini) ◂— endbr32 
01:0004│     0xffffd028 —▸ 0xffffd02c —▸ 0xf7ffda20 —▸ 0x56555000 ◂— 0x464c457f
02:0008│     0xffffd02c —▸ 0xf7ffda20 —▸ 0x56555000 ◂— 0x464c457f
03:000c│     0xffffd030 ◂— 0x1
04:0010│ ecx 0xffffd034 —▸ 0xffffd210 ◂— '/home/syml/Temp/test'
05:0014│     0xffffd038 ◂— 0x0
06:0018│     0xffffd03c —▸ 0xffffd225 ◂— 'SHELL_SESSION_ID=7d181dfe24dd4f97af595a6b18695b24'
07:001c│     0xffffd040 —▸ 0xffffd257 ◂— 'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus'

这一段代码接下来跳转到下面的部分:

mov     ebx, dword ptr [esp]
ret

第一条指令将栈顶的数据放入 ebx 中。经过上一步的 call 指令操作,栈顶现在的值为 call 的指令返回地址,也就是 call 的下一条指令所在的地方。

接下来执行 ret ,程序返回 esp 所指位置的值对应的地址,也就是 call 下一条指令的位置。(将栈顶的元素弹出到 eip

下面的指令是 add ebx, 0x2148

在执行之后,各个寄存器的值:

在这个时候, ebx 指向了 _GLOBAL_OFFSET_TABLE_

接下来的指令序列分别是:

push    0
push    0
push    ecx ; argc
push    esi
push    dword ptr [ebx - 8] ; 
call    __libc_start_main@plt

执行完前面的 push 指令序列之后,栈上的情况是:

这里 dword ptr [ebx - 8] 指针实际对应的位置就是 main 函数的入口点。

查看 __libc_start_main 的定义:

int __libc_start_main(
    int (*main) (int, char * *, char * *), 
    int argc, 
    char * * ubp_av, // argv
    void (*init) (void), 
    void (*fini) (void), 
    void (*rtld_fini) (void), 
    void (* stack_end)
);

我们可以将参数和栈上的数据进行对应:

参数作用栈上位置
int (*main)(int, char**, char**)程序的 main 函数ESP0x5655617d (main)
int argc参数 argv 的个数ESP+40x1
char** ubp_av参数 argvESP+80xffffd034->0xffffd210
void (*init)(void)_libc_csu_init 函数ESP+CNULL(0)
void (*fini)(void)_libc_csu_fini 函数ESP+10NULL(0)
void (*rtld_fini)(void)动态链接器的析构函数ESP+140xf7fcaa30(_dl_fini)
void (*stack_end)栈指针ESP+18ESP

下面执行调用,程序进入 __libc_start_main 中。注意这里跳转到的实际上是 plt 表中对应的表项,这是一个到内存中的索引。实际上还需要另外一次跳转。

# __libc_start_main

当程序进入了 glibc 的代码部分之后,常规人类的思维就逐渐难以跟上了。

函数 __libc_start_main 实际在 glibc 下的源码 csu/libc-start.c 中,对应了函数:

STATIC int LIBC_START_MAIN(int (*main)(int, char **, char **MAIN_AUXVEC_DECL),
                           int argc, char **argv,
#ifdef LIBC_START_MAIN_AUXVEC_ARG
                           ElfW(auxv_t) * auxvec,
#endif
                           __typeof(main) init, void (*fini)(void),
                           void (*rtld_fini)(void), void *stack_end)
    __attribute__((noreturn));

这里的具体内容暂且不说,里面做了包括了这样几件事情:

重定位,PIE

_dl_relocate_static_pie();

canary

/* Set up the pointer guard value.  */
  uintptr_t pointer_chk_guard =
      _dl_setup_pointer_guard(_dl_random, stack_chk_guard);

调用了 init

/* Call the initializer of the program, if any.  */
#ifdef SHARED
  if (__builtin_expect(GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS, 0))
    GLRO(dl_debug_printf)("\ninitialize program: %s\n\n", argv[0]);
#endif
  if (init)
    (*init)(argc, argv, __environ MAIN_AUXVEC_PARAM);

调用主函数,同时将参数传递进去:

char **ev = &argv[argc + 1];
    ......
  /* Nothing fancy, just call the function.  */
  result = main(argc, argv, __environ MAIN_AUXVEC_PARAM);

处理最终的结果:

exit(result);

# __libc_csu_init

在许多情况下, init 指向的是 glibc 的 __libc_csu_init 函数,这个函数定义在了 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_array_start [i]) (argc, argv, envp);
  }
#endif
#ifndef NO_INITFINI
  _init ();
#endif
  const size_t size = __init_array_end - __init_array_start;
  for (size_t i = 0; i < size; i++)
      (*__init_array_start [i]) (argc, argv, envp);
}

去除掉其中暂时用不着的宏定义部分,这个函数做的是这些:

_init();
  const size_t size = __init_array_end - __init_array_start;
  for (size_t i = 0; i < size; i++)
      (*__init_array_start [i]) (argc, argv, envp);

这个函数实际上是整个程序的 “构造函数”。首先它调用了函数 _init()

/* These function symbols are provided for the .init/.fini section entry
   points automagically by the linker.  */
extern void _init (void);
extern void _fini (void);

# _init

这边并没有给出定义,只有一个简单的声明。我们对程序进行反汇编来看看 _init() 到底做了什么。

00001000 <_init>:
    1000:       f3 0f 1e fb             endbr32 
    1004:       53                      push   ebx
    1005:       83 ec 08                sub    esp,0x8
    1008:       e8 73 00 00 00          call   1080 <__x86.get_pc_thunk.bx>
    100d:       81 c3 9f 21 00 00       add    ebx,0x219f
    1013:       8b 83 f4 ff ff ff       mov    eax,DWORD PTR [ebx-0xc]
    1019:       85 c0                   test   eax,eax
    101b:       74 02                   je     101f <_init+0x1f>
    101d:       ff d0                   call   eax
    101f:       83 c4 08                add    esp,0x8
    1022:       5b                      pop    ebx
    1023:       c3                      ret    

get_pc_thunk 这个函数是用来处理 Global Offset Table (GOT 表),这和地址无关代码有关。这个函数可以获取当前指令的位置,这样加上一个偏移就可以访问一些资源。

00001080 <__x86.get_pc_thunk.bx>:
    1080:       8b 1c 24                mov    ebx,DWORD PTR [esp]
    1083:       c3                      ret    
    1084:       66 90                   xchg   ax,ax ; 在x86中对应nop
    1086:       66 90                   xchg   ax,ax
    1088:       66 90                   xchg   ax,ax
    108a:       66 90                   xchg   ax,ax
    108c:       66 90                   xchg   ax,ax
    108e:       66 90                   xchg   ax,ax

这里有趣的是,在执行调用语句 call <__x86.get_pc_thunk.bx> 的时候,栈顶被压入了下一条指令也就是 add ebx,0x219f 的地址。而这个时候 mov ebx,DWORD PTR [esp] 实际上放入 ebx 的也就是这条指令的地址。

接下来执行的 add 语句中 0x219f 就是当前代码相较于位置无关代码的偏移。接下来就是将对应地址中的数据读取到寄存器 eax 中,如果对应的地址有效那么便去执行其中的代码 ( gmon_start / frame_dummy 之类的?),否则继续也就是结束这个函数的执行。

我看有些教程里面提到了有函数 _do_global_ctors_aux ,但是我没有在这里找到从 _init 开始的调用。头疼……

# _do_global_ctors_aux

这个函数理论上使用来执行构造函数的,也就是 “存放了全局 C++ 对象的构造函数”。

不过麻烦的是程序编译了之后好像没有这个东西……

要是啥时候知道了再补上……

在执行了上面的部分之后(?), _init 函数也就到了最后结尾,接下来程序返回到 __libc_start_main 中。

# 回到 __libc_csu_init

接下来,我们执行到了这个代码段:

const size_t size = __init_array_end - __init_array_start;
  for (size_t i = 0; i < size; i++)
      (*__init_array_start [i]) (argc, argv, envp);

这一段也就是在执行一系列的初始化函数:

// test init
#include <stdio.h>
int main(){
    printf("%s\n", __FUNCTION__);
}
void init(int argc, char **argv, char **envp){
    printf("%s\n", __FUNCTION__);
}
__attribute__((section(".init_array"))) typeof(init) *__init = init;

程序执行的结果也就是这样的:

# syml @ SYMLArch in ~/Temp 0 [18:57:21]
$ ./test 
init
main

在执行结束这一系列的初始化函数之后, __libc_csu_init 函数结束。接下来程序返回到 __libc_start_main 中,并接着进入 main 函数,在执行完成之后,结果会返回给 exit 函数进行处理。

# 总结

最后那这张网上的图来说明一下整个流程,一个程序运行的流程也就是把这棵树先序遍历了一下:

总的来看整个流程至少是梳理了一遍…… 但是说实话不少地方还是有点痛苦的,比如说 glibc 的源码实在是太晦涩了,包括部分函数实际上是自动生成的,并没有对应的源码能够查阅。

这一篇写下来填了一些之前留下的坑,又留了更多的坑……

# 参考资料

[1] 程序员的自我修养 —— 链接、装载与库

[2] http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html

[3] 深入理解 Linux 内核

[4] glibc 源码

[5] Linux 源码

此文章已被阅读次数:正在加载...更新于