GDB 源码分析 03:函数前导代码分析(下)

在上一次分析中,我们基本上通读了对前导代码分析实现通用的支持的代码,在这里,我们将针对多种不同架构的代码进行分析。

这篇文章将不可避免地要求对一些架构中汇编指令有一定的熟悉程度,但基本上只需要了解汇编指令的含义即可,可以参考相关的开发者指南。我们将首先从相对简单的 RISC-V 架构的分析开始。

RISC-V 架构的前导代码分析

文件:riscv-tdep.c

在 RISC-V 架构下的前导代码分析只被用来在没有充足的 DWARF 调试信息的情况下跳过函数的前导代码1,因此它确实是比较简单的。但是,它仍然有保留栈帧缓存(frame cache)等相关功能,这使得它的代码还是不太简单。

核心函数定义如下:

1
static CORE_ADDR riscv_scan_prologue(struct gdbarch *gdbarch, CORE_ADDR start_pc, CORE_ADDR end_pc, struct riscv_unwind_cache *cache);

这里面的大部分结构体应该都是在之前的分析中曾经见过的。唯一可能有点陌生的就是最后一个关于栈帧缓存的结构体,其形态如下:

1
2
3
4
5
6
7
struct riscv_unwind_cache {
int frame_base_reg;
int frame_base_offset;
trad_frame_saved_reg *regs;
struct frame_id this_id;
CORE_ADDR frame_base;
}

其中前两个参数表明栈基地址寄存器和实际栈基地址到栈基地址寄存器的偏移量,后两个参数标识栈帧中存储的寄存器和栈帧的 id,在后面栈帧分析相关代码我们会仔细讨论;最后一个参数保存在进入此栈帧时的堆栈指针。

在进入函数时,首先进行了一系列初始化:

1
2
CORE_ADDR cur_pc, next_pc, after_prologue_pc;
CORE_ADDR end_prologue_addr = 0;

然后试图使用调试信息找到前导代码的上限,具体方法留待分析符号表的时候再讨论:

1
2
3
4
5
6
7
after_prologue_pc = skip_prologue_using_sal (gdbarch, start_pc);
if (after_prologue_pc == 0) {
after_prologue_pc = start_pc + 100;
}
if (after_prologue_pc < end_pc) {
end_pc = after_prologue_pc;
}

如果不能利用调试信息完成跳过,则我们给我们的代码起点加上一个足够大的数假装我们得到了一个合理的预测值;然后,如果这个预测值比原先传入的预测值更加精确,也就是说更小,那就采用这个预测值。

然后初始化寄存器并进行分析:

1
2
3
4
5
pv_t regs[RISCV_NUM_INTEGER_REGS];
for (int regno = 0; regno < RISCV_NUM_INTEGER_REGS; regno++) {
regs[regno] = pv_register(regno, 0);
}
pv_area stack(RISCV_SP_REGNUM, gdbarch_addr_bit(gdbarch));

然后我们根据 start_pcend_pc 开始正式进行前导代码分析,这是一个大循环:

1
2
3
for (next_pc = cur_pc = start_pc; cur_pc < end_pc; cur_pc = next_pc) {
/* Do something*/
}

首先对代码进行解码,这一步是非常简单的,被隐藏的实现因为并不重要所以不多做分析,唯一需要说明的是指令长度不大于零表明解码失败:

1
2
3
4
struct riscv_insn insn;
insn.decode(gdbarch, cur_pc);
gdb_assert(insn.length() > 0);
next_pc = cur_pc + insn.length();

接下来我们需要寻找用来调整栈的指令,这些指令大概有以下几种:

  • addiaddiw 引起的,其目标寄存器和源寄存器都是 sp,这是用来调整栈的基地址的;
  • swsd 引起的,其源寄存器为 fpsp,这是用来向栈中保存寄存器的;
  • addi 引起的,源寄存器为 sp 而目标寄存器为 fp 的以及 addaddw 引起的,源寄存器为 spzero,目标寄存器为 fp的,这是用来设置栈帧的;

其代码因为结构很简单但是较长所以不在此展示了,基本上就是利用中篇写好的各种工具,改寄存器的动数组,改内存的动 stack,除此之外做一下寄存器序号边界的检验即可。

接下来是对一些其他指令的处理,包括 auipcluiaddiaddldlwmv,如果这一串代码到了无法再分析的地方,那就终止。终止时的 end_prologue_addr 设置为当前的 pc 值,即 curr_pc

再往下就需要处理有 cache 的情形,这些会在我们讨论栈帧分析的时候再做讨论。

注意到,这里前导代码的含义似乎已经与最开始大相径庭。前导代码似乎包含了很多单纯的算术运算,对栈和寄存器的操作基本上都被置于其中。这一方面是因为更充分地分析函数运行时的数据流能够有助于后续的分析,另一方面是因为日渐复杂的编译器使得前导代码本身不再简单,因此必须以这种复杂的方式被加以分析——哪怕分析的内容是过度的,它也是“安全”的,这也是保守估计思想的一环。

i386 前导代码分析

文件:i386-tdep.c

前面已经讲过,对于 RISC-V 架构代码的前导代码分析是相对简单的,哪怕循环中的结构看起来很长,它还是易懂且亲切的。但是对于 i386 架构代码来说,它的代码复杂度几乎让人望而却步。接下来,我们走出摇篮,开始直面这个不可名状的巨物,首先从第一根触手——啊不,第一个函数开始:

1
2
3
4
5
6
7
8
9
10
11
static CORE_ADDR
i386_analyze_prologue (struct gdbarch *gdbarch, CORE_ADDR pc, CORE_ADDR current_pc, struct i386_frame_cache *cache) {
pc = i386_skip_endbr(pc);
pc = i386_skip_noop(pc);
pc = i386_follow_jump(gdbarch, pc);
pc = i386_analyze_struct_return(pc, current_pc, cache);
pc = i386_skip_probe(pc);
pc = i386_analyze_stack_align(pc, current_pc, cache);
pc = i386_analyze_frame_setup(gdbarch, pc, current_pc, cache);
return i386_analyze_register_saves(pc, current_pc, cache);
}

啊哈,确实有点吓人不是吗?别急,我们慢慢来,你可以喝一杯茶,或者先读读 Intel 的开发者手册,或者看看 CTFWiki 中关于 ROP 的段落……如果准备好了,那我们直接开始。

第一个函数是对 endbr 的处理——明白为什么要读读 ROP 的相关内容了嘛?如果你没读,那也没事,在这里简单解释一下花不了多少时间。当我们面对一个程序且需要绕过它的部分功能,比如注册机时,我们会操作函数的返回地址,也就是说,面向返回地址编程(return-oriented programming,ROP),而 endbr 就是 Intel 引入的 CET 技术的结果:

当一个跳转发生时,CPU 的状态机切换到 WAIT_FOR_ENDBRANCH 状态,它会确保跳转发生后的下一条指令一定是 endbr 指令。但是,这个指令本身没有意义,它不执行,只起到了缩小攻击面的作用。

所以,明白我们要做什么了吗?没错,直接跳过它。因为它是函数入口必不可少的东西,但是对我们来说,不能说是举足轻重,也可以说是毫无意义了。因此,这个函数就是这样:

1
2
3
4
5
6
7
8
9
10
11
static CORE_ADDR i386_skip_endbr(CORE_ADDR pc) {
static const gdb_byte endbr32[] = {0xf3, 0x0f, 0x1e, 0xfb};
gdb_byte buf[sizeof(endbr32)];
if (target_read_code(pc, buf, sizeof(endbr32))) {
return pc;
}
if (memcmp(buf, endbr32, sizeof(endbr32))) {
return pc;
}
return pc + sizeof(endbr32);
}

如果读不出指令,终止;如果不是 endbr32,终止——这是因为 CET 是可选开启的,可以调整为不需要 endbr 也能正常执行;如果是,跳过它。

i386_skip_noop 函数当然也是类似的:跳过 nop 指令以及一些功能类似的指令,它的框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
static CORE_ADDR i386_skip_noop(CORE_ADDR pc) {
gdb_byte op;
int check = 1;
if (target_read_code(pc, &op, 1)) {
return pc;
}
while (check) {
check = 0;
/* Do something*/
}
return pc;
}

也是一样的,如果不能成功读取指令,那就直接终止,接下来的 nop 指令,有几个算几个,全都跳过。

首先检查 nop 指令本体,这很简单:

1
2
3
4
5
6
7
if (op == 0x90) {
pc += 1;
if (target_read_code(pc, &op, 1)) {
return pc;
}
check = 1;
} else // ...

先在这里停一下,稍微注意一下,i386 的非定长特征赤裸裸地暴露在我们眼前了。nop 指令只有 1 个字节长,而 endbr32 则是 4 个字节,这使得很多处理方式不够优雅,而不太优雅的情形还不止于此,下面的操作,则是一个更加不优雅但有用的案例:

1
2
3
4
5
6
7
8
9
10
11
12
/* cont. */ if (op == 0x8b) {
if (target_read_code(pc + 1, &op, 1)) {
return pc;
}
if (op == 0xff) {
pc += 2;
if (target_read_code(pc + 1, &op, 1)) {
return pc;
}
check = 1;
}
}

哦,忘了说了,0x8bff 是一条 mov edi, edi 指令。事实上,0x89ff 也是——这是因为这两种编码都是成立的。当然,这里只检查了前一种情况,这是因为……

因为 Windows 的系统动态链接库用到了这条指令!每个函数都会以五个 0x8bff 开头,在执行的时候,CPU 自然而然地将其解析为 nop 的等价指令,当然也就不会发生什么。而这五个指令可以被填充成五个短程跳转指令,这就使得热更新变得可能:只需要改变这几个空位,就可以改变函数的一些功能。

那么,在直面了这里的险恶之后,我们就要被迫思考这可能带来什么结果了。还记得吗,在前面的分析中,我们从来不考虑跳转这回事,但是这时候我们不得不考虑了——如果发生了跳转,它还是在函数最开头,在分析都没有开始的地方,而且对于 Windows 动态链接库函数这可能是普遍的,那么放弃就显得有些过于仓促了。所以,下一个函数就是要解决这个问题:

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
static CORE_ADDR i386_follow_jump(struct) {
enum bfd_endian byte_order = gdbarch_byte_order(gdbarch);
gdb_byte op;
long delta = 0;
int data16 = 0;
if (target_read_code(pc, &op, 1)) {
return pc;
}
if (op == 0x66) {
data16 = 1;
op = read_code_unsigned_integer(pc + 1, 1, byte_order);
}
switch (op) {
case 0xe9:
if (data16) {
delta = read_memory_integer(pc + 2, 2, byte_order);
delta += 4;
} else {
delta = read_memory_integer(pc + 1, 4, byte_order);
delta += 5;
}
break;
case 0xeb:
delta = read_memory_integer(pc + data16 + 1, 1, byte_order);
delta += data16 + 2;
break;
}
return pc + delta;
}

如果对 i386 不熟悉的话,想必这时候已经有点晕了。这段代码事实上就是把 pc 跳到跳转的目标地址,支持的是 jmp 指令,也就是 0xe90xeb,它的编码自己查手册应当不难。需要注意的是 0x66 前缀,如果直接查或许查不到,它的意思是 data16,也就是说偏移量按 16 bit 解读,在我们这里,它事实上没有带来任何本质上的不同。

下一个函数的名字可能并不太容易让人理解它在表达什么,首先要考虑的是一个问题,一个函数如何返回结构体或指针?很显然,它将不得不在栈中开辟空间用以填充这个指针,这是一段额外的代码:

1
2
3
popl %eax             0x58
xchgl %eax, (%esp) 0x87 0x04 0x24
or xchgl %eax, 0(%esp) 0x87 0x44 0x24 0x00

这个函数就是用来跳过这种原型的,经历了上面几个函数,我想或许不需要附上代码也能大致猜出它长什么样子了,不过保险起见,代码如下:

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
static CORE_ADDR i386_analyze_struct_return(CORE_ADDR pc, CORE_ADDR current_pc, struct i386_frame_cache cache) {
static gdb_byte_proto1[3] = {0x87, 0x04, 0x24};
static gdb_byte_proto2[4] = {0x87, 0x44, 0x24, 0x00};
gdb_byte buf[4];
gdb_byte op;
if (current_pc <= pc) {
return pc;
}
if (target_read_code(pc, &op, 1)) {
return pc;
}
if (op != 0x58) {
return pc;
}
if (target_read_code(pc + 1, buf, 4)) {
return pc;
}
if (memcmp(buf, proto1, 3) != 0 && memcmp(buf, proto2, 4 != 0)) {
return pc;
}
if (current_pc == pc) {
cache->sp_offset += 4;
return current_pc;
}
if (current_pc == pc + 1) {
cache->pc_in_eax = 1;
return current_pc;
}
if (buf[1] == proto1[1]) {
return pc + 4;
} else {
return pc + 5;
}
}

里面当然涉及到一些 cache 的处理,今天也不做分析——再分析下去这篇文章就太长了,鉴于我们已经花了太多笔墨来考虑这些具体的乏味的代码,接下来我们要快进了。

i386_skip_probe,跳过最开始的一次对 _probe 函数的调用:

1
2
3
pushl constant
call _probe
addl esp, 4

i386_analyze_stack_align 跳过用来对齐栈的代码:

1
2
3
leal reg, esp[4]
andl esp, -16(or -256)
pushl reg[-4]

或者

1
2
3
4
pushl reg
leal reg, esp[8]
andl esp, -16(or -256)
pushl reg[-4]

i386_analyze_frame_setup 跳过建立栈帧的代码,即这个系列最开始介绍的那一段代码,当然,中间可能插入了别的东西;

i386_analyze_register_saves 跳过存储寄存器的代码,也就是一开始的一连串 push

好,快进结束。反思一下,这个代码和分析 RISC-V 的部分显得极为不同。分析 RISC-V 时,我们根据的是抽象解读和逐一代码的分析,而分析 i386 时我们将其分块,因为编译器生成的代码的形式是完全一致的。新老架构在这里如此不同,我们或许可以窥见技术世界中年迈者的从容和无奈……

1. 关于 DWARF 调试信息的内容,在以后读到相关代码时将详细解释,在这里,只需要理解它是一种用来描述整个程序功能的信息文件即可。