2、软件逆向工程基础:机器码与汇编语言基础、寄存器与栈模型、x86/x64架构核心差异、Hello World逆向分析。
好,咱们正式开始逆向工程的第一课。说实话,很多新手一上来就想着怎么破解、怎么搞动态调试,结果连最基础的机器码都看不懂。我当年也犯过这个错——拿到一个二进制文件,直接扔进IDA,看着满屏的汇编代码一脸懵。后来我才明白,逆向工程的核心,其实就是理解“机器码→汇编→高级语言”这条翻译链。
这一章,咱们就把地基打牢。我会带你从最底层的机器码开始,一步步走到能看懂一个完整的Hello World程序在CPU眼里是什么样子。
2.1 机器码与汇编语言:CPU到底在吃什么?
CPU不认识C语言,也不认识Python。它只吃一种东西——机器码。机器码就是一串二进制数,比如10110000 01100001。这玩意儿人看着头疼,所以有了汇编语言——用助记符代替二进制,比如MOV AL, 61h。
你想想看,汇编语言其实就是机器码的“文本别名”。每条汇编指令,都对应一条或几条机器码指令。逆向工程里,我们看到的反汇编代码,就是工具(比如IDA、Ghidra)帮我们把机器码“翻译”回汇编的结果。
举个例子,x86架构下,MOV EAX, 1 这条指令的机器码是 B8 01 00 00 00。其中 B8 是操作码(告诉CPU“我要把数据放进EAX”),后面的 01 00 00 00 是立即数1(小端序存储)。
; 汇编指令
MOV EAX, 1
; 对应的机器码(十六进制)
B8 01 00 00 00
; 二进制形式
10111000 00000001 00000000 00000000 00000000
嗯,这里要注意:不同架构的机器码完全不同。ARM的 MOV R0, #1 和x86的 MOV EAX, 1 长得完全不一样。所以逆向之前,先搞清楚目标文件是给哪个架构吃的。
2.2 寄存器与栈模型:CPU的“手边工具”和“临时记事本”
寄存器,说白了就是CPU内部的高速缓存。它比内存快得多,但数量极少。x86架构下,常用的通用寄存器有8个(EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP)。x64下扩展到了16个(RAX、RBX...R15)。
我个人习惯把寄存器想象成CPU的“手边工具”——EAX是常用计算器,ECX是计数器,ESP是栈顶指针,EBP是栈底指针。每个寄存器都有它的“潜规则”用途,但现代编译器并不严格遵循,逆向时别太死板。
| 寄存器 | 32位名称 | 64位名称 | 常见用途 |
|---|---|---|---|
| 累加器 | EAX | RAX | 函数返回值、算术运算 |
| 基址寄存器 | EBX | RBX | 数据指针(很少用) |
| 计数器 | ECX | RCX | 循环计数、移位操作 |
| 数据寄存器 | EDX | RDX | I/O操作、乘除辅助 |
| 源变址 | ESI | RSI | 字符串操作源指针 |
| 目的变址 | EDI | RDI | 字符串操作目的指针 |
| 栈顶指针 | ESP | RSP | 指向当前栈顶 |
| 栈底指针 | EBP | RBP | 指向当前栈帧基址 |
再来说栈模型。栈是内存中的一块区域,遵循“后进先出”(LIFO)原则。函数调用时,参数、局部变量、返回地址都会被压入栈中。我见过不少新手把栈和堆搞混——栈是自动管理的,函数返回就释放;堆是手动管理的,malloc/free 那一套。
栈的操作就两条指令:PUSH(压栈)和 POP(出栈)。PUSH EAX 会把EAX的值复制到栈顶,同时ESP减4(32位)或8(64位)。POP EAX 则相反。
; 栈操作示例
PUSH EAX ; 将EAX的值压入栈顶,ESP -= 4
PUSH EBX ; 将EBX的值压入栈顶,ESP -= 4
CALL MyFunc ; 调用函数,自动压入返回地址
...
POP EBX ; 从栈顶恢复EBX,ESP += 4
POP EAX ; 从栈顶恢复EAX,ESP += 4
2.3 x86 vs x64:不只是位数多了点
很多人觉得x64就是x86的“加长版”,寄存器从32位变成64位。其实没那么简单。我当年从x86转向x64逆向时,踩了不少坑。核心差异有这几个:
- 寄存器数量翻倍: x86有8个通用寄存器,x64有16个。多了R8-R15,编译器有了更多“手边工具”,函数调用时参数传递更少依赖栈。
- 调用约定变了: x86下Windows常用
stdcall(参数从右向左压栈,被调用者清理栈),x64下Windows统一用fastcall(前4个参数用RCX、RDX、R8、R9传递,多余的才压栈)。Linux x64则用System V ABI(前6个参数用RDI、RSI、RDX、RCX、R8、R9)。 - 地址空间大了: x86只有4GB虚拟地址空间,x64有16EB(实际硬件限制在48位或57位)。这意味着x64程序里,指针是8字节,而不是4字节。
- 指令编码更高效: x64新增了RIP相对寻址,可以更方便地访问全局变量和字符串。x86下访问全局变量通常需要硬编码地址,x64下可以用
LEA RAX, [RIP+offset]。
| 特性 | x86 (32位) | x64 (64位) |
|---|---|---|
| 通用寄存器 | 8个(EAX~EDI) | 16个(RAX~R15) |
| 寄存器宽度 | 32位 | 64位 |
| 指针大小 | 4字节 | 8字节 |
| 调用约定(Windows) | stdcall / cdecl / thiscall | fastcall(统一) |
| 参数传递 | 全部压栈 | 前4个用寄存器,其余压栈 |
| 栈对齐 | 4字节 | 16字节(必须对齐) |
| 相对寻址 | 不支持RIP相对 | 支持RIP相对寻址 |
PUSH EBP; MOV EBP, ESP这种标准序言,因为编译器更喜欢用RSP直接操作。你看到的可能是SUB RSP, 40h这种直接分配栈空间的方式。
2.4 Hello World逆向分析:从源码到机器码的完整旅程
理论说完了,咱们来点实战。下面是一个最简单的C语言Hello World程序,我会带你一步步看它在x64 Linux下编译后长什么样。
// hello.c
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
用gcc编译(不开优化):gcc -O0 -o hello hello.c。然后用objdump反汇编:objdump -d hello。你会看到类似这样的输出:
0000000000001149 <main>:
1149: 55 push %rbp
114a: 48 89 e5 mov %rsp,%rbp
114d: 48 83 ec 10 sub $0x10,%rsp
1151: b8 00 00 00 00 mov $0x0,%eax
1156: 48 8d 05 a7 0e 00 00 lea 0xea7(%rip),%rax # 2004 <_IO_stdin_used>
115d: 48 89 c7 mov %rax,%rdi
1160: e8 eb fe ff ff callq 1050 <printf@plt>
1165: b8 00 00 00 00 mov $0x0,%eax
116a: c9 leaveq
116b: c3 retq
咱们逐行分析:
push %rbp:保存调用者的栈帧基址。这是x64下函数序言的常见操作,虽然不像x86那么强制,但不开优化时编译器还是会这么做。mov %rsp,%rbp:设置当前函数的栈帧基址。RBP指向当前栈帧的底部。sub $0x10,%rsp:在栈上分配16字节空间。为什么是16?因为x64要求栈对齐到16字节。这16字节里可能放局部变量,也可能只是用来对齐。mov $0x0,%eax:把EAX清零。这是为返回值做准备,main函数返回0。lea 0xea7(%rip),%rax:这是x64特有的RIP相对寻址。它把当前指令地址(0x115d)加上偏移0xea7,得到字符串"Hello, World!\n"的地址,存入RAX。这个字符串在只读数据段(.rodata)里。mov %rax,%rdi:把字符串地址传给RDI。根据System V ABI,第一个参数用RDI传递。callq 1050 <printf@plt>:调用printf函数。注意这里用的是PLT(过程链接表),因为printf是动态链接的。实际调用会跳转到PLT桩,再由动态链接器解析真正的printf地址。mov $0x0,%eax:再次清零EAX,作为main函数的返回值。leaveq:等价于mov %rbp, %rsp; pop %rbp,恢复调用者的栈帧。retq:从栈顶弹出返回地址,跳转回去。
lea 0xea7(%rip),%rax时,完全没搞懂它在干嘛。后来才明白,这是x64下访问全局数据的标准方式。因为x64程序是位置无关的(PIE),不能硬编码地址,只能用RIP相对寻址。逆向时看到这种指令,基本就是在加载某个全局变量或字符串的地址。
咱们再画个图,把整个调用过程可视化一下:
这张图展示了从高级语言到CPU执行的完整链路。逆向工程,说白了就是沿着这条链路反向走——从机器码反推回汇编,再从汇编理解程序的行为逻辑。
嗯,这一章的内容就到这里。记住,逆向工程没有捷径,但理解底层原理能让你少走很多弯路。下一章咱们会深入PE/ELF文件格式,看看这些二进制文件到底是怎么组织的。
公众号:蓝海资料掘金营,微信deep3321