第二章:计算机基础与汇编入门
说实话,很多刚入行的朋友问我:“逆向工程是不是就是对着二进制看?”我通常会反问一句:“你知道你写的代码在CPU眼里长什么样吗?”
这一章,我们就来聊聊计算机最底层的那些事。CPU怎么干活、内存怎么布局、寄存器是干嘛的。这些东西,说白了就是逆向工程的“内功心法”。
核心观点:不懂汇编的逆向工程师,就像不懂语法的翻译官。你可以靠猜,但永远成不了高手。
2.1 计算机体系结构:CPU、内存、寄存器
先看一张图,这是我个人习惯用来给学生讲“计算机怎么跑起来”的框架。
这张图我每次讲课都会画一遍。你想想看,CPU从内存拿指令,放到寄存器里算,算完再写回内存。就这么简单的一个循环,支撑起了整个计算机世界。
2.1.1 CPU:大脑
CPU就是计算机的大脑。它负责取指令、解码、执行。我在做漏洞分析时,经常需要单步跟踪CPU的执行流,看它到底走了哪条分支。
CPU内部有三个关键部件:
- 控制单元:负责取指令、解码、调度。说白了就是“指挥官”。
- ALU(算术逻辑单元):负责加减乘除、与或非等运算。这是“干活的人”。
- 寄存器:CPU内部的高速缓存。容量极小,但速度极快。
我的经验:调试恶意软件时,我经常盯着EIP(指令指针寄存器)看。它指向哪,CPU就执行哪。一旦EIP被劫持,程序就失控了。这就是缓冲区溢出攻击的核心原理。
2.1.2 内存:仓库
内存是程序的“工作台”。程序运行时的代码、数据、栈、堆全在内存里。x86架构下,内存是线性排列的字节序列,每个字节都有一个唯一的地址。
内存布局大致如下:
| 区域 | 用途 | 特点 |
|---|---|---|
| 代码段(.text) | 存放机器指令 | 只读,不可写 |
| 数据段(.data/.bss) | 全局变量、静态变量 | 可读写 |
| 堆(Heap) | 动态分配的内存 | 手动管理,增长方向向上 |
| 栈(Stack) | 局部变量、函数调用 | 自动管理,增长方向向下 |
嗯,这里要注意:栈是向下增长的。每次调用函数,栈顶指针(ESP)会减小。我第一次看反汇编时,看到ESP不断减小,还以为程序出bug了。后来才明白,这就是栈的工作原理。
2.1.3 寄存器:CPU的便签纸
寄存器是CPU内部的小块存储。你可以把它想象成你手边的便签纸——随手记、随手用,比翻笔记本(内存)快得多。
x86架构下,主要的通用寄存器有8个:
| 寄存器 | 32位名 | 16位名 | 8位名 | 常见用途 |
|---|---|---|---|---|
| 累加器 | EAX | AX | AL/AH | 算术运算、函数返回值 |
| 基址寄存器 | EBX | BX | BL/BH | 数据寻址 |
| 计数器 | ECX | CX | CL/CH | 循环计数、移位 |
| 数据寄存器 | EDX | DX | DL/DH | I/O操作、乘除辅助 |
| 源变址 | ESI | SI | - | 字符串操作源地址 |
| 目的变址 | EDI | DI | - | 字符串操作目标地址 |
| 栈指针 | ESP | SP | - | 指向栈顶 |
| 基址指针 | EBP | BP | - | 指向栈帧基址 |
避坑指南:我曾经在分析一个加壳程序时,发现EAX的值总是不对。后来才意识到,壳代码修改了EAX作为解密密钥。如果你在调试时发现寄存器值“莫名其妙”,先想想是不是有代码在背后动了手脚。
2.2 x86/x64汇编基础
汇编语言,说白了就是机器指令的“助记符”。每条汇编指令都对应一条机器码。逆向工程的核心工作之一,就是把汇编代码“翻译”回高级语言的逻辑。
2.2.1 常见指令
我整理了一份“逆向工程必会指令表”,这些指令你几乎每天都会遇到:
| 指令 | 格式 | 作用 | 示例 |
|---|---|---|---|
| MOV | MOV dst, src | 数据传送 | MOV EAX, 0x10 |
| ADD/SUB | ADD dst, src | 加减运算 | ADD EAX, EBX |
| PUSH/POP | PUSH src / POP dst | 压栈/出栈 | PUSH EAX |
| CALL/RET | CALL addr / RET | 函数调用/返回 | CALL 0x401000 |
| JMP/Jcc | JMP addr / JE addr | 无条件/条件跳转 | JE 0x401020 |
| CMP/TEST | CMP a, b / TEST a, b | 比较/测试 | CMP EAX, 0 |
| LEA | LEA dst, [addr] | 加载有效地址 | LEA EAX, [EBP-4] |
我个人习惯把指令分成三类:数据搬运、算术逻辑、控制流。你只要掌握了这三类,大部分汇编代码都能看懂。
2.2.2 栈操作:函数调用的幕后英雄
栈是逆向工程中最重要的数据结构之一。每次函数调用,都会在栈上创建一个“栈帧”。
一个典型的函数调用过程是这样的:
- 调用者把参数压栈(PUSH)
- 调用CALL指令,把返回地址压栈
- 被调用函数保存EBP(PUSH EBP)
- 设置EBP = ESP(MOV EBP, ESP)
- 分配局部变量空间(SUB ESP, N)
- ... 执行函数体 ...
- 恢复ESP,恢复EBP,执行RET
; 一个典型的函数序言(Prologue)
push ebp ; 保存调用者的EBP
mov ebp, esp ; 设置当前函数的栈帧基址
sub esp, 0x40 ; 分配64字节局部变量空间
; 函数体
mov dword [ebp-4], 0x1234 ; 局部变量赋值
; 函数尾声(Epilogue)
mov esp, ebp ; 恢复ESP
pop ebp ; 恢复EBP
ret ; 返回
我的经验:分析恶意软件时,我经常在函数入口处下断点。看它PUSH了哪些参数,就能猜出它要调用什么API。比如看到PUSH 0; PUSH 0; CALL [MessageBoxA],就知道这是个弹窗。
2.3 汇编与高级语言的对应关系
这一节,我们来点“翻译练习”。把高级语言代码和汇编代码对照着看,你会发现很多规律。
2.3.1 if-else 语句
C语言代码:
if (x > 0) {
y = 1;
} else {
y = -1;
}
对应的汇编(简化版):
cmp dword [x], 0 ; 比较 x 和 0
jle .else_branch ; 如果 x <= 0,跳转到else
mov dword [y], 1 ; y = 1
jmp .end_if
.else_branch:
mov dword [y], -1 ; y = -1
.end_if:
看到了吗?高级语言里的if-else,在汇编里就是CMP + 条件跳转。你想想看,所有的分支逻辑,底层都是这么干的。
2.3.2 循环语句
C语言代码:
for (int i = 0; i < 10; i++) {
sum += i;
}
对应的汇编:
mov dword [i], 0 ; i = 0
.loop_start:
cmp dword [i], 10 ; 比较 i 和 10
jge .loop_end ; 如果 i >= 10,跳出循环
mov eax, dword [sum]
add eax, dword [i] ; sum += i
mov dword [sum], eax
inc dword [i] ; i++
jmp .loop_start
.loop_end:
循环的本质就是:初始化、条件判断、循环体、增量、跳回。逆向分析时,看到这种模式,基本就能确定是个循环。
2.3.3 函数调用
C语言代码:
int add(int a, int b) {
return a + b;
}
int result = add(3, 5);
对应的汇编:
; 调用者
push 5 ; 压入第二个参数
push 3 ; 压入第一个参数
call add ; 调用函数
add esp, 8 ; 清理参数(调用者清理)
mov dword [result], eax ; 返回值在EAX中
; 被调用函数 add
push ebp
mov ebp, esp
mov eax, dword [ebp+8] ; 取第一个参数 a
add eax, dword [ebp+12] ; 加第二个参数 b
pop ebp
ret
关键点:函数返回值通常放在EAX中。参数通过栈传递(x86)或寄存器传递(x64 fastcall)。这是逆向分析时识别函数调用的重要线索。
2.4 本章小结
这一章的内容,说白了就是逆向工程的“识字课”。你得先认识CPU、内存、寄存器这些“零件”,再学会汇编指令这个“语言”,最后才能看懂程序在“说什么”。
我个人建议:学汇编不要死记硬背。打开调试器(比如x64dbg或OllyDbg),随便找个程序单步跟踪一下。看ESP怎么变、EIP怎么跳、EAX怎么算。看多了,自然就熟了。
我曾经带过一个学生,他花了一周时间,把notepad.exe从头到尾单步了一遍。虽然什么都没改,但他说:“老师,我现在看汇编就像看中文一样自然。”这就是最好的状态。