本讲座将带着学员重新探索函数呼叫背后的原理,从程序语言和计算机结构的发展简史谈起,让学员自电脑软硬件演化过程去掌握 calling convention 的考量,伴随着 stack 和 heap 的操作,再探讨 C 程序如何处理函数呼叫、跨越函数间的跳跃 (如 setjmp 和 longjmp),再来思索资讯安全和执行效率的议题。着重在计算机架构对应的支援和行为分析。
原文地址function prototype
Very early C compilers and language一个小故事,可以解释 C 语言的一些设计理念,例如 switch-case 中每个 case 都需要 break
The Development of the C LanguageDennis M. Ritchie 讲述 C 语言漫长的发展史,并搭配程式码来说明当初为何如此设计、取舍考量。了解这些历史背景可以让我们成为更专业的 C 语言 Programmer
Rationale for International Standard – Programming Languages – C讲述 C 语言标准的变更,并搭配程式码解释变更的原理和考量
在早期的 C 语言中,并不需要 function prototype,因为当编译器发现一个函数名出现在表达式并且后面跟着左括号 (,例如 a = func(...),就会将该函数解读为:返回值类型预设为 int,参数类型和个数由调用者提供来决定,按照这样规则编写程式码,可以在无需事先定义函数即可先写调用函数的逻辑。但是这样设计也会造成潜在问题:程序员在调用函数时需要谨慎处理,需要自己检查调用时的参数类型和个数符合函数定义 (因为当时的编译器无法正确判断调用函数时的参数是否符合预期的类型和个数,当时编译器的能力与先前提到的规则是一体两面),并且返回值类型预设为 int (当时还没有 void 类型),所以对于函数返回值,也需要谨慎处理。
显然 function prototype 的缺失导致程式码编写极其容易出错,所以从 C99 开始就规范了 function prototype,这个规范除了可以降低 programmer 心智负担之外,还可以提高程序效能。编译器的最佳化阶段 (optimizer) 可以通过 function prototype 来得知内存空间的使用情形,从而允许编译器在函数调用表达式的上下文进行激进的最佳化策略,例如 const 的使用可以让编译器知道只会读取内存数据而不会修改内存数据,从而没有 side effect,可以进行激进的最优化。
1
2
3
4
5
6
7
8
int compare(const char *string1, const char *string2);
void func2(int x) {
char *str1, *str2;
// ...
x = compare(str1, str2);
// ...
}Rust 的不可变引用也是编译器可以进行更激进的最优化处理的一个例子
注意为什么早期的 C 语言没有 function prototype 呢?因为早期的 C 语言,不管有多少个源程序文件,都是先通过 cat 合并成一个单元文件,在进行编译链接生成目标文件。这样就导致了就算写了 function prototye,使用 cat 合并时,这些 prototype 不一定会出现在我们期望的程序开始处,即无法利用 prototype 对于函数调用进行检查,所以干脆不写 prototype。
在 preprocessor 出现后,通过 #include 这类语法并搭配 preprocessor 可以保证对于每个源文件,都可以通过 function prototype 对函数调用进行参数个数、类型检查,因为 #include 语句位于源文件起始处,并且此时 C 语言程序的编译过程改变了: 对单一源文件进行预处理、编译,然后再对得到的目标文件进行链接。所以此时透过 preprocessor 可以保证 function prototype 位于函数调用之前,可以进行严格地检查。
编程语言的 function
C 语言不允许 nested function 以简化编译器的设计 (当然现在的 gcc 提供 nested funtion 的扩展),即 C 语言的 function 是一等公民,位于语法最顶层 (top-level),因为支持 nested function 需要 staic link 机制来确认外层函数。
编程语言中的函数,与数学的函数不完全一致,编程语言的函数隐含了状态机的转换过程 (即有 side effect),只有拥有 Referential Transparency 特性的函数,才能和数学上的函数等价。
Process 与 C 程序
程序存放在磁盘时叫 Program,加载到内存后叫 “Process”
Wikipedia: Application binary interfaceIn computer software, an application binary interface (ABI) is an interface between two binary program modules. Often, one of these modules is a library or operating system facility, and the other is a program that is being run by a user.
在 Intel x86 架构中,当返回值可以放在寄存器时就放在寄存器中返回,以提高效能,如果放不下,则将返回值的起始地址放在寄存器中返回。
stack
Layout
System V Application Binary Interface AMD64 Architecture Processor Supplement [PDF]
PEDA
实验需要使用到 GDB 的 PEDA 扩展:
Enhance the display of gdb: colorize and display disassembly codes, registers, memory information during debugging.
1
2
$ git clone https://github.com/longld/peda.git ~/peda
$ echo "source ~/peda/peda.py" >> ~/.gdbinit技巧动态追踪 Stack 实验的 call funcA 可以通过 GDB 指令 stepi 或 si 来实现从递归观察函数调用
1
2
3
4
5
6
7
8
9
int func(int x) {
static int count = 0;
int y = x; // local var
return ++count && func(x++);
}
int main() {
return func(0);
}func 函数在调用时,一个栈帧的内容包括: x (parameter), y (local variable), return address。这些数据的类型都是 int,即占据空间相同,这也是为什么计时器 count 的变化大致呈现 $x : \frac{x}{2} : \frac{x}{3}$ 的比例。
stack-based buffer overflow
CVE-2015-7547 / 解说vulnerability in glibc’s DNS client-side resolver that is used to translate human-readable domain names, like google.com, into a network IP address.
Wikipedia: Buffer overflow 1
2
3
4
5
6
7
8
9
10
int evil() {
system("/bin/sh");
}
int main() {
char input[10];
puts("Input:");
gets(input);
puts(input);
}需要向 gcc 指定 -fno-stack-protector 参数来关闭栈内存保护机制,要不然无法实现栈溢出攻击:
1
$ gcc -o bof -fno-stack-protector -g -no-pie bof.c该实验本质上是利用了函数内定义的数组,是存储在 stack 上,并且数组下标和存储地址的对应关系是「小/低 -> 大/高」即下标小的数组元素位于低地址处,所以数组的元素是从低地址往高地址存储的,这和 sp 的方向刚好相反,并且如果使用的是 gets 这种不安全函数,当接收的输入超过定义的数组的长度时,会覆盖不属于定义的数组,并且比数组更高地址部分的内容,这可能会改写当前函数的返回地址,从而导致段错误。
因为可以通过输入来改写当前函数的返回地址,那么就可以构造一个输入使得当前 main 会返回到 evil 函数,这部分根据原文完成实验即可。
ROP
heap
使用 malloc 时操作系统可能会 overcommit,而正因为这个 overcommit 的特性,malloc 返回有效地址也不见得是安全的。除此之外,因为 overcommit,使用 malloc 后立即搭配使用 memset 代价也很高 (因为操作系统 overcommit 可能会先分配一个小空间而不是一下子分配全部,因为它优先重复使用之前已使用过的小块空间),并且如果是设置为 0,则有可能会对原本为 0 的空间进行重复设置,降低效能。此时可以应该善用 calloc,虽然也会 overcommit,但是会保证分配空间的前面都是 0 (因为优先分配的是需要操作系统参与的大块空间),无需使用 memset 这类操作而降低效能。
malloc / free
RAII
setjmp & longjmp
setjmp(3) — Linux manual pageThe functions described on this page are used for performing
“nonlocal gotos”: transferring execution from one function to a
predetermined location in another function. The setjmp()
function dynamically establishes the target to which control will
later be transferred, and longjmp() performs the transfer of
execution.
具体解说可以阅读 lab0-c 的「自動測試程式」部分