NJUOS-16-什么是可执行文件

本文最后更新于:1 年前

本次课回答的问题

  • Q: 可执行文件到底是什么?
  • Q: 可执行文件是如何在操作系统上被执行的?

本次课主要内容

  • 可执行文件
  • 解析可执行文件
  • 链接和加载
    • 今天假设只有静态链接

可执行文件

RTFM

本次课涉及的手册


(不用听了,可以下课了)

  • 课堂要回答的问题
    • 为什么 f**king manual 是 f**king 的?
    • 应该怎么读手册?

可执行文件:状态机的描述

image-20230101114109588

image-20230101114312244

可执行文件:描述了一个状态机。状态?寄存器+内存(地址空间)

操作系统 “为程序 (状态机) 提供执行环境”

  • 可执行文件 (状态机的描述) 是最重要的操作系统对象!

一个描述了状态机的初始状态 + 迁移的数据结构 -> 可执行文件就是一个数据结构!!!描述了状态机!!!

  • 寄存器
    • 大部分由 ABI 规定,操作系统负责设置
    • 例如初始的 PC
  • 地址空间
    • 二进制文件 + ABI 共同决定
    • 例如 argv 和 envp (和其他信息) 的存储
  • 其他有用的信息 (例如便于调试和 core dump 的信息)

例子:操作系统上的可执行文件

需要满足若干条件

  • 具有执行 (x) 权限
  • 加载器能识别的可执行文件
1
2
3
4
5
6
7
8
$ ./a.c
bash: ./a.c: Permission denied
$ chmod -x a.out && ./a.out
fish: The file “./a.out” is not executable by this user
$ chmod +x a.c && ./a.c
Failed to execute process './a.c'. Reason:
exec: Exec format error
The file './a.c' is marked as an executable but could not be run by the operating system.

是谁决定了一个文件能不能执行?

操作系统代码 (execve) 决定的。

动手试一试

  • strace ./a.c
  • 你可以看到失败的 execve!
    • 没有执行权限的 a.c: execve = -1, EACCESS
    • 有执行权限的 a.c: execve = -1, ENOEXEC

image-20230101115931052

先玩一玩,有个基本的认识!!!

  • 再读一遍 execve (2) 的手册
    • 读手册的方法:先理解主干行为、再查漏补缺
    • “ERRORS” 规定了什么时候不能执行

image-20230101120104346

手册读不下去了 -> 玩过一些小例子,慢慢理解,就慢慢能读懂,慢慢能深入啦!!!

常见的可执行文件

img

就是操作系统里的一个普通对象

  • 绿导师原谅你了.avi

  • Windows 95/NT+, UEFI

    • PE (Portable Executable), since Windows 95/NT+
  • UNIX/Linux

    • a.out (deprecated)
    • ELF (Executable Linkable Format)
    • She-bang
      • 我们可以试着 She-bang 一个自己的可执行文件!
      • She-bang 其实是一个 “偷换参数” 的 execve
  • She-bang:

image-20230101120438676

image-20230101120451282

二进制文件,本质上就是一个可以被execve接受,并且翻译成状态机状态的数据结构嘛!!!

image-20230101121013350

image-20230101120746971

偷换参数!!!

#!会被在执行execve的时候,偷偷替换掉!!!

image-20230101120853338

image-20230101121058361

偷偷把argv换掉,再去执行execve。(这里只有一个argv[1]哈,历史遗留问题!!!)

比如#! xxx python3,本质上状态机会被变为python3, python3会去拿出来这个文件,解析并且进行执行昂!

  • 再来看手册:

image-20230101121349234

解析可执行文件

Binutils - Binary Utilities

GNU binutils

  • 生成可执行文件
    • ld (linker), as (assembler)
    • ar, ranlib
  • 分析可执行文件
    • objcopy/objdump/readelf (计算机系统基础)
    • addr2line, size, nm

可执行文件的运行时状态

segfault.c - 为什么 gdb 知道出错的位置?

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stddef.h>

void bar() {
*(int *)NULL = 1;
}

void foo() {
bar();
}

int main() {
foo();
}
  • gcc (-static -g)
1
2
3
4
5
6
7
8
9
10
11
12
$ ./a.out
Segmentation fault (core dumped)
(gdb) r
Starting program: /tmp/demo/a.out

Program received signal SIGSEGV, Segmentation fault.
bar () at segfault.c:4
4 *(int *)NULL = 1;
(gdb) bt
#0 bar () at segfault.c:4
#1 0x0000000000401d0d in foo () at segfault.c:8
#2 0x0000000000401d22 in main () at segfault.c:12

gdb调试!!!gdb为什么可以打印程序的backtrace?

应用程序的二进制文件里面,有一些额外信息,帮助我们的debugger解析运行时的状态,addr2line。

调试信息

编译器会在编译过程中,自动帮助我们完成addr2line的映射!!!

gcc -static segfault.c -g -S,编译器会在汇编里面,自动帮助我们生成很多调试信息!!!

img

将一个 assembly (机器) 状态映射到 “C 世界” 状态的函数

image-20230102100852128

在对应执行的内存中,通过我们自定义的Turing Complete,就可以打印出来内存中的一些信息(便于调试)!!!

image-20230102101104483

相当于做了一个,编译状态到C状态的映射。Debug Info就是一个函数,帮我们把汇编机器下的状态,映射为C语言里面的状态!!!但是可以遇见的是,由于中间的翻译和优化,上下要对应起来,十分困难 -> 不完美!!!

  • 但非常不完美
    • 对现代语言支持有限 (C++ templates)
      • 还是因为编程语言太复杂了
    • 编译器也没法保证就做得对(摆烂了orz)
      • 各种令人生气的 <optimized out>
      • 各种不正确的调试信息

例子:寄存器分配

popcount.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

__attribute__((noinline))
int popcount(int x) {
int s = 0;
int b0 = (x >> 0) & 1;
s += b0;
int b1 = (x >> 1) & 1;
s += b1;
int b2 = (x >> 2) & 1;
s += b2;
int b3 = (x >> 3) & 1;
s += b3;
return s;
}

int main() {
printf("%d\n", popcount(0b1101));
}

  • 优化的编译器 (-O2)

image-20230102102721433

优化之后的值都和原来的值对不上了……

  • 使用工具查看
    • -g -S 查看嵌入汇编的 debug info
    • readelf -w 查看调试信息
  • gdb 调试
    • s=<optimized out>
    • 呃……离谱,这看个🔨orz……

例子:Stack Unwinding

unwind.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <stdlib.h>

const char *binary;

struct frame {
struct frame *next; // push %rbp
void *addr; // call f (pushed retaddr)
};

void backtrace() {
struct frame *f;
char cmd[1024];
extern char end;

// rbp寄存器的值赋值给f,我们就可以类似于链表,进行遍历了昂!!!
asm volatile ("movq %%rbp, %0" : "=g"(f));
for (; f->addr < (void *)&end; f = f->next) {
printf("%016lx ", (long)f->addr); fflush(stdout);
// 对于合法的地址,我们调用addr2line把对应的行打出来就好了昂!!!
sprintf(cmd, "addr2line -e %s %p", binary, f->addr);
system(cmd);
}
}

void bar() {
backtrace();
}

void foo() {
bar();
}

int main(int argc, char *argv[]) {
binary = argv[0];
foo();
}

  • 思路:

image-20230102103310104

函数调用无非就是栈帧嘛!!!我们顺着存栈帧的指针,把对应的数据结构struct frame取出来,然后像链表遍历一样,从目前遍历到一开始,就能够backtrace()出来最终的结果哇!

  • 需要的编译选项
    • -g (生成调试信息)
    • -static (静态链接)
    • -fno-omit-frame-pointer (总是生成 frame pointer)
    • 可以尝试不同的 optimization level
      • 再试试 gdb

image-20230102103753905

image-20230102104114550

O2状态下产生了内联呢,O0就是正常的,没有啥优化。

因此调试逻辑错误都是用O0的昂!!!

这里也看到,就算是O2,gdb也能找出调用信息。本质上栈帧优化之后,是找不到调用了。但是GDB可以根据这些Debug Info,给“虚构”出来调用的链路,这事儿没我们想的那么容易昂!!!


没有 frame pointer 的时候呢?

逆向工程 (Reverse Engineering)

得到 “不希望” 你看到的商业软件代码 (然后就可以分析漏洞啥了)

  • 调试信息 (代码) 是绝对不可能了
  • 连符号表都没有 (stripped)
  • 看起来就是一串指令序列 (可以执行)

img

编译和链接

从 C 代码到二进制文件

被《计算机系统基础》支配的恐惧?

1
2
3
4
5
6
7
8
9
10
11
12
13
// main.c
void hello();

int main() {
hello();
}

// hello.c
#include <stdio.h>

void hello() {
printf("Hello World\n");
}
1
2
3
4
gcc -c main.c
gcc -c hello.c
gcc -static main.o hello.o
./a.out
  • 从main函数里面调用一个它不知道的hello,main.c编译的时候,声明了hello的存在。编译main的时候,还没有解析hello,不知道在哪儿,在链接的时候再决定昂!但是编译可以正常完成,这样就可以在我们的一个文件不知道其他部分的情况下,完成编译。 -> 大的项目拆小

image-20230102105827374

其实不难

gcc -S -c main.c:

image-20230102110813260

可以看到底层就是编译成了一条call指令而已捏!!!

image-20230102111850597

编译器生成文本汇编代码 → 汇编器生成二进制指令序列

1
2
3
4
5
6
7
8
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 48 83 ec 08 sub $0x8,%rsp
8: 31 c0 xor %eax,%eax
a: e8 00 00 00 00 callq ????????
f: 31 c0 xor %eax,%eax
11: 48 83 c4 08 add $0x8,%rsp
15: c3 retq

但有些地址编译的时候不知道啊 (比如 hello) -> 目标文件执行不了,必须经过链接,才能够生成可执行文件!!!绝大多数代码都生成完成了,但是call的是hello,不知道hello是啥呀,因此摆烂了,全是0……

image-20230102112549952

image-20230102112801916

这里是成立的!!!

前面还好。这里我不明白为什么要+offset。这个offset的值是多少? -> offset是从地址p读出来的一个偏移(因为马上要函数调用了,要跳转到别的地方,这里是相对地址),判断函数调用之后的结果,是不是hello,就是酱!

main.o

  • 就先填个 0 吧

重定位 (Relocation)

但这 4-bytes 最终是需要被填上的(被真正的hello填上!),使得 assertion 被满足:

1
2
3
4
5
assert(
(char *)hello ==
(char *)main + 0xf + // call hello 的 next PC
*(int32_t *)((uintptr_t)main + 0xb) // call 指令中的 offset
);

这个要求也要被写在文件里

  • ELF 文件:部分状态机的 “容器”

    1
    2
      Offset            Type      Sym. Name + Addend
    00000000000b R_X86_64_PLT32 hello - 4
    • 重填 32-bit value 为 “S + A - P” (P = main + 0xb)
    • 如何理解?考虑 call “S + A - P” 的行为 -> S+A-P就是(char *)main + 0xf + *(int32_t *)((uintptr_t)main + 0xb)

S+A-P=S-(P-A) P-A就是现在的PC值,就是跳转的目标地址减去现在的PC。这边感觉确实讲的复杂了些,其实就是求hello的第一条指令的起始地址和call指令的下一条指令的起始地址的偏移,就是一个跳转时的偏移量,如果用汇编来写的话就很简单了,call hello;1:;hel

image-20230102113007331

重新理解编译、链接流程

编译器 (gcc)

  • High-level semantics (C 状态机) → low-level semantics (汇编)

汇编器 (as)

  • Low-level semantics → Binary semantics (状态机容器)
    • “一一对应” 地翻译成二进制代码
      • sections, symbols, debug info
    • 不能决定的要留下 “之后怎么办” 的信息
      • relocations

链接器 (ld)

  • 合并所有容器,得到 “一个完整的状态机”
    • ldscript (-Wl,--verbose); 和 C Runtime Objects (CRT) 链接
    • missing/duplicate symbol 会出错

image-20230102113649576

image-20230102113726326

奇怪,我们完全没有讲 ELF 的细节?

ELF 就是一个 “容器数据结构”,包含了必要的信息

  • 你完全可以试着自己定义二进制文件格式 (dump it to disk)!
1
2
3
4
5
6
7
8
9
struct executable {
uint32_t entry;
struct segment *segments;
struct reloc *relocs;
struct symbol *symbols;
};
struct segment { uint32_t flags, size; char data[0]; }
struct reloc { uint32_t S, A, P; char name[32]; };
struct symbol { uint32_t off; char name[32]; };
  • 当然,这有很多缺陷

    • “名字” 其实应该集中存储 (const char * 而不是 char[])
    • 慢慢理解了 ELF 里的各种设计 (例如 memsz 和 filesz 不一样大) -> 例如symbol里面的name,如果每个都分配32个字节,太大了!!!可能有一个buffer,里面装了全部的字符串常量,存储的时候,就存一个便宜量。诶嘿,方便小巧,取出来的时候再构成上面的数据结构嘛!!!
  • 如何理解:

    • a.out就是一个数据结构,描述的状态机的初始状态和转换。

image-20230102114544265

References

  1. System V ABI: System V Application Binary Interface (AMD64 Architecture Processor Supplement) (repo),应用和二进制的Interface。
  2. 和更多 refspecs
  3. GNU binutils -> 二进制文件的工具集合(ld啊,readelf啊,objdump啊之类的昂!!!) -> 本质上就是二进制文件这种数据结构的查看 or 修改的工具哇!!!
  4. file指令,可以查看elf文件的类型和基本信息之类的哇!!!
  5. readelf可以查看elf文件(最后可执行的二进制文件格式)
  6. CSAPP很重要,链接章节再看一遍,会有额外收获昂!!!

NJUOS-16-什么是可执行文件
https://alexanderliu-creator.github.io/2023/01/01/njuos-16-shi-me-shi-ke-zhi-xing-wen-jian/
作者
Alexander Liu
发布于
2023年1月1日
许可协议