逆向工程核心原理02
小端序标记法
在计算机系统中,数据的存储方式有两种主要的字节序:大端序(Big-endian)和小端序(Little-endian)。小端序是指数据的低位字节存储在内存的低地址处,而高位字节存储在内存的高地址处。
下面是简单的示例
1 | BYTE b = 0x12; |
以上代码中共有4种数据类型,它们的大小不同,可以看看下面同一个数据根据不同字节序保存时的区别:
| TYPE | Name | SIZE | 大端序 | 小端序 |
|---|---|---|---|---|
| BYTE | b | 1 | [12] | [12] |
| WORD | w | 2 | [12][34] | [34][12] |
| DWORD | dw | 4 | [12][34][56][78] | [78][56][34][12] |
| char[] | str | 6 | [61][62][63][64][65][00] | [61][62][63][64][65][00] |
可以看到,除了字符串,其他数据类型在大端序和小端序下的存储顺序是相反的
数据为单一字节时,无论采用大端序还是小端序,字节存储顺序都是一样的,只有储存多字节数据时,才会出现差异
由于x86架构的处理器采用小端序存储数据,因此在进行逆向工程时,理解小端序的概念非常重要,在此后的分析过程中,将默认使用小端序进行数据解释
可以自己编写程序,观察不同字节序下数据的存储方式
寄存器
寄存器是 CPU 内部用来存放数据的一小块存储区域,汇编语言中的大部分操作都是在寄存器之间进行的。寄存器的访问速度远快于内存,因此它们在程序执行过程中起着至关重要的作用。
PC 端常见的架构有 x64(amd64)和 x86(IA-32),分别对应 64 位和 32 位处理器
移动端和嵌入式常见的架构有 ARM 和 ARM64(AArch64),分别对应 32 位和 64 位处理器
下面介绍 x86 和 x64 架构下的寄存器
这里给出官方的手册,供参考:
Intel® 64 and IA-32 Architectures Software Developer’s Manual
AMD64 Architecture Programmer’s Manual
当然,为了速查,这里给出一个网站:x86 and amd64 instruction reference
IA-32 寄存器(x86)
IA-32 是 Intel 推出的 32 位的复杂指令集架构,IA-32 支持的寄存器类型如下:
1 | Basic program execution registers |
为了简化起见,这里介绍基本程序运行寄存器的相关内容,后面陆续介绍有关控制寄存器、内存管理寄存器、调试寄存器等相关寄存器的知识
下面的图来自 IA-32 官方用户手册,描述了基本程序运行的组织架构,它由4类寄存器组成
- 通用寄存器,32位,8个
- 段寄存器,16位,6个
- 程序状态和控制寄存器,32位,1个
- 指令指针寄存器,32位,1个

通用寄存器
通用寄存器用于传送和暂存数据,也可参与算术逻辑运算并保存运算结果,根据官方文档的描述,用于存放以下项目:
- 逻辑运算和算术运算的操作数(Operands)
- 地址计算的操作数
- 内存指针(Memory pointers)
一般来说,ESP 寄存器保存着栈指针(Stack Pointer),作为一条通常原则,不应将其挪作他用
许多指令会指定特定的寄存器来存放操作数。例如,字符串指令(String instructions)使用 ECX, ESI 和 EDI 寄存器的内容作为操作数
在使用分段内存模型(Segmented memory model时,某些指令会假设特定寄存器中的指针是相对于特定段(Segment)的。例如,某些指令假设 EBX 寄存器中的指针是指向 DS 段(数据段)中的内存位置
下面简单说一下各个寄存器的特殊用途的摘要:
- EAX — 操作数和结果数据的累加器 (Accumulator)。 (注:函数返回值通常也放在这)
- EBX — 指向 DS 段中数据的指针 (基址寄存器)。
- ECX — 字符串和循环操作的计数器 (Counter)。 (注:Loop循环次数放这)
- EDX — I/O 指针。 (注:也常用于配合 EAX 存放 64 位乘除法结果)
- ESI — 指向由 DS 寄存器所指段中数据的指针;字符串操作的源指针 (Source Index)。
- EDI — 指向由 ES 寄存器所指段中数据(或目的地)的指针;字符串操作的目的指针 (Destination Index)。
- ESP — 栈指针 (Stack Pointer)(位于 SS 段中)。
- EBP — 指向栈上数据的指针(基址指针,Base Pointer)(位于 SS 段中)。 (注:常用于定位函数参数和局部变量)
通用寄存器的低 16 位直接映射到 8086 和 Intel 286 处理器(16 位 CPU)中的寄存器集,并且可以通过名称 AX, BX, CX, DX, BP, SI, DI 和 SP 来引用 EAX, EBX, ECX 和 EDX 寄存器的低两个字节(Low two bytes)中的每一个都可以被单独引用:
- AH, BH, CH, DH 代表高字节(High bytes,即第 8-15 位)
- AL, BL, CL, DL 代表低字节(Low bytes,即第 0-7 位)

- EAX:(针对操作数和结果数据的)累加器
- EBX:(DS段中的数据指针)基址寄存器
- ECX:(字符串和循环操作的)计数器
- EDX:(I/O指针)数据寄存器
这四个寄存器主要用在算术运算(ADD、SUB、XOR、OR等)指令中,常常用来保存常量和变量的值
某些汇编指令(MUL、DIV、LODS等)直接用来操作特定寄存器,执行这些命令后,只改变特定寄存器中的值
ECX 在循环命令(LOOP) 中用来循环计数,每执行一次 LOOP 指令,ECX 的值就减 1,直到 ECX 的值为 0 时,循环结束
EAX 一般用在函数的返回值中,所有的 Win32 API 函数都会先把返回值保存到 EAX 寄存器中再返回
- EBP:(SS段中栈内数据指针)扩展基址指针寄存器
- ESI:(字符串操作源指针)源变址寄存器
- EDI:(字符串操作目标指针) 目标变址寄存器
- ESP:(SS段中栈指针)堆栈指针寄存器
ESP 指示栈区域的栈顶地址,某些指令(PUSH、POP、CALL、RET等)可以直接用来操作 ESP
EBP 表示栈区域的基地址,函数被调用时保存 ESP 的值,函数返回时再把值返回 ESP,保证栈不会崩溃,这被称为栈帧技术,将在后面介绍
ESI 和 EDI 与特定指令(LODS、STOS、REP、MOVS等)一起使用,主要用于内存复制
段寄存器
段寄存器的理解可能比较难,初学者可以不需要完全掌握,后面用到了再来学习即可
IA-32 的保护模式中,段是一种内存保护技术,它把内存划为多个区段,并给每个区段赋予起始地址、范围、访问权限等,以保护内存
此外,它还和分页技术一起用于虚拟内存变更为实际物理内存,段内存记录在 SDT(Segment Descriptor Table)中,而段寄存器就持有这些 SDT 的索引
下面两张图是官方手册中的示意图:段寄存器总共由6中寄存器组成,每个寄存器的大小为16位,即2个字节
每个段寄存器指向的段描述符和虚拟内存相结合,形成一个线性地址,借助分页技术,线性地址最终被转换为实际的物理地址(不借助分页技术的操作系统中,线性地址直接变为物理地址)


- CS(Code Segment):代码段寄存器,指向当前执行代码所在的段
- SS(Stack Segment):栈段寄存器,指向当前栈所在的段
- DS(Data Segment):数据段寄存器,指向默认的数据段
- ES(Extra Segment):附加(数据)段寄存器,通常用于字符串操作
- FS 和 GS:数据段寄存器,通常用于操作系统或线程特定的数据
CPU 永远是从 CS:EIP 指向的地址拿代码执行
- 在 16 位模式下,物理地址 = CS * 16 + IP
- 在 32 位/64 位保护模式下,CS 不再是物理基址,而是指向一个“描述符表”,通常基址为 0
CS 有只读属性:不能写代码 MOV CS, AX。这是非法的,改变 CS(即跳转到很远的代码段),必须用 JMP FAR、CALL FAR 或者 RETF,或者触发中断
在现代 Windows/Linux (x86/x64) 操作系统中,操作系统使用“平坦内存模型”(Flat Memory Model),DS, ES, SS 通常都指向同一个范围(基址为 0,大小为 4GB/全空间)
所以汇编通常不需要显式写段前缀(比如 mov eax, [ebx] 默认就是 ds:[ebx]),因为 DS 基址是 0,逻辑地址等于线性地址
x86 (32位)中,FS 寄存器通常指向 TEB (线程环境块)
FS:[0] 指向 SEH 链表(异常处理结构)
FS:[30h] 指向 PEB (进程环境块)。
SS 寄存器可以被显式地加载,这允许应用程序设置多个堆栈并在它们之间进行切换,所有涉及 PUSH, POP, CALL, RET 以及使用 ESP/RSP, EBP/RBP 的指令,默认都是基于 SS 段的
虽然可以修改 SS(比如 MOV SS, AX),但通常由操作系统内核在任务切换时完成
程序调试过程中经常用到 FS 寄存器,如上,它和 SEH(Structured Exception Handling,结构化异常处理)、TEB(Thread Environment Block,线程环境块)、PEB(Process Environment Block,进程环境块)等概念密切相关,后续会专门介绍这些内容
程序状态和控制寄存器
- EFLAGS:Flag Register,标志寄存器
这个寄存器的每一位都有意义,值 0/1 代表 Off/On 或者 False/True
其中有些位由系统直接决定,有些位根据程序命令的执行结果设置
没有任何指令允许直接检查或修改整个 EFLAGS 寄存器
没有 MOV EFLAGS, EAX 这种指令

这里只介绍 4 个状态标志,它们在程序控制流中非常重要:
- ZF (Zero Flag):结果为 0 时置 1
- SF (Sign Flag):结果为负时置 1
- OF (Overflow Flag):有符号数溢出时置1,此外,MSB 被改变时也置 1
- CF (Carry Flag):无符号数进位/借位
其余标志位的详细介绍可以参考官方手册
指令指针寄存器
- EIP:Instruction Pointer,指令指针寄存器
EIP 保存着 CPU 要执行的指令地址,大小为 32 位
程序运行时,CPU 会读取 EIP 中一条指令的地址,传送指令到指令缓冲区后, EIP 寄存器的值自动增加,增加的大小即读取指令的字节大小,这样 CPU 每次执行完一条指令后通过 EIP 自动指向下一条指令
不能直接修改 EIP 的值,只能通过其他指令间接修改,比如 JMP, CALL, RET, Jcc 等指令,此外,还可以通过中断或异常来修改 EIP 的值
Intel 64 / AMD64 寄存器 (x64)
x64 是 x86 架构的 64 位扩展(也称为 x86-64 或 AMD64),它在保持向下兼容的同时,极大地扩展了寄存器的数量和位宽
同样的,这里也只介绍基本程序运行寄存器的相关内容,x64 的寄存器组主要由以下 4 类组成:
- 通用寄存器,64位,16个 (比 IA-32 多了 8 个)
- 段寄存器,16位,6个 (主要为了兼容,实际作用被弱化)
- 程序状态和控制寄存器,64位,1个
- 指令指针寄存器 ,64位,1个
通用寄存器
在 64 位模式下,有 16 个通用寄存器,默认的操作数大小是 32 位
通用寄存器能够处理 32 位或 64 位操作数
- 如果指定 32 位操作数大小:可以使用 EAX, EBX, ECX, EDX, EDI, ESI, EBP, ESP, R8D - R15D。
- 如果指定 64 位操作数大小:可以使用 RAX, RBX, RCX, RDX, RDI, RSI, RBP, RSP, R8 - R15。
R8D-R15D 和 R8-R15 代表八个新的通用寄存器 所有这些寄存器都可以在字节 (byte)、字 (word)、双字 (dword) 和 四字 (qword) 级别上进行访问
使用 REX 前缀 可以生成 64 位操作数大小或引用 R8-R15 寄存器
仅在 64 位模式下可用的寄存器(R8-R15 和 XMM8-XMM15)在从“64 位模式”进入“兼容模式”再切回“64 位模式”的过程中,其值会被保留 然而,如果从“64 位模式”经由“兼容模式”进入“传统模式 (legacy mode)”或“实模式 (real mode)”,然后再经由“兼容模式”切回“64 位模式”,R8-R15 和 XMM8-XMM15 的值则是未定义的
| 寄存器类型 | 未使用 REX 前缀 (Without REX) | 使用 REX 前缀 (With REX) |
|---|---|---|
| 字节寄存器 | AL, BL, CL, DL, AH, BH, CH, DH | AL, BL, CL, DL, DIL, SIL, BPL, SPL, R8B - R15B |
| 字寄存器 | AX, BX, CX, DX, DI, SI, BP, SP | AX, BX, CX, DX, DI, SI, BP, SP, R8W - R15W |
| 双字寄存器 | EAX, EBX, ECX, EDX, EDI, ESI, EBP, ESP | EAX, EBX, ECX, EDX, EDI, ESI, EBP, ESP, R8D - R15D |
| 四字寄存器 | N.A. (不适用) | RAX, RBX, RCX, RDX, RDI, RSI, RBP, RSP, R8 - R15 |
在 64 位模式下,访问字节寄存器存在限制。一条指令不能同时引用传统的高字节寄存器(例如:AH, BH, CH, DH)和新的字节寄存器(例如:RAX 寄存器的低字节 [注: 指需 REX 访问的字节])
但指令可以同时引用传统的低字节(例如:AL, BL, CL 或 DL)和新的字节寄存器(例如:R8 寄存器的低字节,或 RBP 的低字节)
对于使用 REX 前缀的指令,将高字节引用(AH, BH, CH, DH)更改为低字节引用(BPL, SPL, DIL, SIL:即 RBP, RSP, RDI, RSI 的低 8 位)
在 64 位模式下,操作数的大小决定了目标通用寄存器中有效位的数量:
- 64 位操作数:在目标通用寄存器中生成 64 位结果
- 32 位操作数:在目标通用寄存器中生成 32 位结果,并零扩展 (zero-extended) 为 64 位结果
- 8 位和 16 位操作数:生成 8 位或 16 位结果。目标通用寄存器的高 56 位或 48 位不会被该操作修改。如果想要将 8 位或 16 位操作的结果用于 64 位地址计算,必须显式地将寄存器符号扩展 (sign-extend) 到完整的 64 位
64 位通用寄存器的高 32 位在 32 位模式下是未定义的,所以当从 64 位模式切换到 32 位模式(保护模式或兼容模式)时,任何通用寄存器的高 32 位都不会被保留。软件不能依赖这些位在 64 位到 32 位模式切换后保持数值
段寄存器
在 64 位模式下:CS, DS, ES, SS 被视为每个段的基址(Base)都是 0,无论它们关联的段描述符中的基址值实际上是多少。这就为代码、数据和堆栈创建了一个平坦的地址空间(Flat address space)
但是,FS 和 GS 段寄存器 仍可用作线性地址计算中的额外基址寄存器(用于寻址局部数据和某些操作系统数据结构)
尽管分段机制(Segmentation)通常已被禁用,但加载段寄存器(例如使用 MOV DS, AX 指令)仍可能导致处理器执行段访问辅助操作
在这些活动期间,处于启用状态的处理器仍然会对加载的值执行大多数传统检查(Legacy checks),即使这些检查在 64 位模式下可能并不适用。需要这些检查的原因是,在 64 位模式下加载的段寄存器可能会被运行在兼容模式(Compatibility mode,即 32 位程序运行在 64 位系统上)下的应用程序所使用
CS, DS, ES, SS, FS 和 GS 的段限长检查(Limit checks)在 64 位模式下被禁用
程序状态和控制寄存器
- RFLAGS:64 位标志寄存器
低 32 位一样,高 32 位保留未用
指令指针寄存器
- RIP:64 位指令指针寄存器
RIP 始终指向下一条要执行的指令的地址,相比于 EIP,RIP 可以表示更大的地址空间,并且加入了相对寻址模式(RIP-relative addressing)
在 IA-32 中,访问全局变量通常需要绝对地址。但在 x64 中,为了支持位置无关代码(PIC),指令通常使用“相对于当前 RIP 的偏移量”来访问数据例如 LEA RAX, [RIP + 0x200]
这种机制使得代码加载到内存的任何位置都能正常运行
同样,不能直接修改 RIP 的值,只能通过 JMP, CALL, RET 等指令间接修改
由于x64 架构相对于 x86 架构有较大变化,也变得更加复杂,后面涉及到 64 位系统再详细阐述
栈
栈用于存储局部变量、传递函数参数、保存函数返回地址等
是一种数据结构,按照 FILO (First In Last Out,先进后出) 原则进行数据存取
在 x86 体系结构中,栈是一种向低地址方向增长的数据结构
栈指针寄存器 ESP 始终指向当前栈顶位置
执行 PUSH 指令时,ESP 先减小(指向更低的地址),再将数据写入新的栈顶位置 执行 POP 指令时,从当前 ESP 所指向的栈顶位置读取数据,然后 ESP 增大
CPU 本身并不维护栈是否为空的状态,对空栈执行 POP 会导致未定义行为,因此栈的正确使用需要由程序或调用约定加以保证
看书中这个例子:
在下面这个地方打断点

push 100h 时,关注栈指针 esp

此时栈指针为 0019FF74
执行 pop eax 后,关注栈指针 esp

此时变为 0019FF78
可以看到栈指针增加了 4 个字节(32 位系统下,每次入栈或出栈都是 4 字节)
分析 abex’ crackme#1
这个是需要破解程序到另外一个分支
简单打开是这样的 
确定后是这样

再确定就退出了
直接用 IDA 打开分析

如图,可以加断点运行看到比较时 eax 和 esi 的值
jz 的意思是如果为0就跳转,cmp 相当于一个减法操作,它执行了 eax-esi,结果放在 eax 中,但不保存结果,只更新标志位也就是标志寄存器 eflags 中的 ZF 标志位
把 jz 改为 jmp,jmp 的意思是直接跳转
可以看到修改后成功了

栈帧
栈帧就是利用 EBP 寄存器访问局部变量、参数、函数返回地址等的手段
简单用汇编来说就是
在函数开头:
1 | push ebp ; 保存上一个栈帧的基址 |
在函数结尾
1 | mov esp, ebp ; 恢复栈顶指针 |
最新的编译器中启用了
-o优化后,简单的函数将不会产生栈帧
简单来分析一下 stackframe.exe
1 | .text:00401000 push ebp |
可以看到函数开头和结尾的栈帧操作,包括 main 函数和 sub_401000 函数,sub_401000 中间有一堆 mov,这是在操作局部变量和参数
[ebp+arg_0] 代表第一个参数,[ebp+arg_4] 代表第二个参数
[ebp+var_8] 代表第一个局部变量,[ebp+var_4] 代表第二个局部变量
可以看到 main 函数中,先给两个局部变量赋值 1 和 2,然后把它们作为参数传递给 sub_401000 函数
sub_401000 函数中,把参数分别存放到局部变量中,然后把它们相加,最后返回结果
可以看到栈帧的使用,使得函数内的变量和参数有了清晰的组织结构,便于访问和管理
IDA Pro 比较好的一点是可以看到后面调用了 _printf 函数,不用再去分析这个函数是什么了
注意最后的 xor eax, eax,这是把 eax 寄存器清零的意思,等同于 mov eax, 0,但是更高效一些
xor 是异或操作,任何数和自己异或的结果都是 0,所以 xor eax, eax 会把 eax 清零,同时不会影响其他寄存器的状态
分析 abex’ crackme#2
很明显这是一个 Visual Basic 编译的程序
与 C/C++ 不同,首先要了解一下 VB 文件
VB 文件
两种编译模式
- Native Code:
- 特征:编译成标准的 x86 汇编指令(MOV, ADD, PUSH)
- 虽然是汇编,但它不直接调用 Windows API(如
lstrcmp),而是调用 VB 运行库函数(如__vbaStrCmp) - IDA 表现:看起来像正常的 C 代码,但充满
call
- P-Code:
- 特征:编译成一种由 VB 虚拟机解释执行的字节码
- 知识点:这不是 CPU 能运行的代码。例如
Add操作可能只是一个字节0x0C - IDA 表现:IDA 识别为乱码。通常必须依赖 VB Decompiler 或 P-Code 专用解析器
数据结构
VB 是弱类型语言,底层全靠复杂的数据结构支撑
- Variant
- 结构:16字节结构体,常见的类型标记:
02(Integer),03(Long),08(String),0B(Boolean)
- 结构:16字节结构体,常见的类型标记:
- BSTR (Basic String):
- 结构:
[长度前缀 4字节] + [Unicode字符串] + [00 00 结尾]。
- 结构:
- SAFEARRAY (安全数组):
- VB 的数组有一个描述符(Descriptor),包含维度、元素大小、锁计数等。不像 C 语言数组只是一个指针
导出库函数
VB6 程序运行时依赖大量的运行库函数,会导出到一个 DLL 文件中
VB 程序没有 main() 函数,程序的执行流是非线性的
VB 的事件本质上是 COM 接口的回调,VB 的对象在底层都是 COM 对象
VB 的函数返回值通常放在 EAX 里,但这不是结果,而是一个状态码 ,比如 S_OK (0) 表示成功
真正的结果通常是通过引用参数(类比C语言中的指针)传出
分析
首先打开先随便运行一下

显然找到密码就行
打开 VB Decompiler 静态分析一下,首先找到 Nope 这个字符串

这里找到了比较的东西,var_44 和 var_34 估计分别是两个值,只比较了这两个怀疑是密码
打开 IDA Pro 调试,断点打在刚刚比较的附近,也就是 0x403332 附近

可以看到这里的比较函数有一个 __imp___vbaVarTstEq,判断为 VB 的比较函数,那前面 push 进的两个寄存器 edx 和 eax 肯定就是这两个比较变量了,看此处寄存器里面的值

寄存器上面指向了栈上的两个地址,去栈上看一下

栈上面是 8 ,往下看可以看到一个 0019F1E4,这实际上是保存的 ebp,VB 中的字符串和 C++ 中的一样,实际是动态分配的字符串缓存地址,所以下面的这个 007CBC7C 和 007CC394 就是这两个实际的地址,去看一眼


明显这个 qwer是输入的,95969798 就是真正的密码了
重新试了一下,成功

值得一提的是栈上的 0800 是 Variant 结构体的类型标记,看到 08 说明是字符串,02 是整型,03 是长整型等
VB 中字符串类型是 BSTR,在栈中存放 Variant 结构体,在堆中存放实际的字符串数据,存放字符串中间的00 是因为 VB6 采用 Unicode 编码,每个字符占两个字节
复盘
其实会注意到如果改变了 Name 的值,密码也随即会变,所以实际上密码是和 Name 相关联的,接下来探讨一下加密的过程
由于 VB Decompiler 的存在,我们不用像书上一样一个一个追踪,直接看反编译的结果

注意看这一段,中间出现了比较的 var_44,这个肯定就是密码了,前面还判断了 var_8008 的真假,那么 var_74 就是 Name
var_24 最大到 4,说明只处理前4个字符
Mid(var_74, var_24, 1):从第 var_24 位取 1 个字符
Asc(...):获取这个字符的 ASCII 码数值
储存到 var_8010 中
把 var_8010 加上 100 取 16进制放在 var_44 后面(注:和 C++ 不同,& 在VB中不是按位与,而是拼接)
最后就生成了密码
返回来看看 95969798 的生成:
- 95
- 十六进制 0x95 = 十进制 149
- 算法是 ASCII + 100 = 149
- 所以 ASCII = 149 - 100 = 49
- 查 ASCII 表,49 对应的字符是 ‘1’
- 96
- 0x96 = 150
- ASCII = 150 - 100 = 50
- 50 对应的字符是 ‘2’
- 依此类推:
- 97 -> ‘3’
- 98 -> ‘4’
如果有时间也可以试着直接根据汇编推出上述内容
Process Explorer
PE 是 Sysinternals 套件中的一个工具,可以用来查看系统中运行的进程、线程、模块等信息
是 Windows 平台上非常强大的系统监控和管理工具
函数调用工具
函数执行完后,栈中的参数不用管,之后的值自然会被覆盖掉,ESP 恢复到函数调用之前
| 关键字 | 堆栈清理 | 参数传递 |
|---|---|---|
| __cdecl | 调用方 | 在堆栈上按相反顺序推送参数(从右到左) |
| __clrcall | 不适用 | 按顺序将参数加载到 CLR 表达式堆栈上(从左到右) |
| __stdcall | 被调用方 | 在堆栈上按相反顺序推送参数(从右到左) |
| __fastcall | 被调用方 | 存储在寄存器中,然后在堆栈上推送 |
| __thiscall | 被调用方 | 在堆栈上推送;存储在 ECX 中的 this 指针 |
| __vectorcall | 被调用方 | 存储在寄存器中,然后按相反顺序在堆栈上推送(从右到左) |
这里的堆栈清理意思如下:
假设 main 函数调用 printf 函数,那么 main 为调用方 (Caller),printf 为被调用方 (Callee)
堆栈清理可以由调用方 (Caller) 或被调用方 (Callee) 来完成
cdecl
cdecl 是 C 和 C++ 程序的默认调用约定,支持变参函数,比如 printf
| 元素 | 实现 |
|---|---|
| 参数传递顺序 | 从右到左 |
| 堆栈维护职责 | 调用函数从堆栈中弹出自变量 |
| 名称修饰约定 | 下划线字符 (_) 作为名称的前缀,导出使用 C 链接的 __cdecl 函数时除外 |
| 大小写转换约定 | 不执行任何大小写转换 |
编译阶段,被调用函数 printf 无法预知运行时会被传入多少参数,因此无法生成准确的 ret n 来平栈,只有调用者 (main) 明确知道压入了多少参数,所以必须由调用者在 call 指令结束后,通过 add esp, X 手动清理堆栈
1 | ; 1. 参数入栈 (从右向左) |
在 x64 中, cdecl 在 64 位模式下已基本失效
按照 ARM 和 x64 上的约定,自变量将尽可能传入寄存器,后续自变量传递到堆栈中
stdcall
stdcall 调用约定用于调用 Win32 API 函数。 被调用方将清理堆栈
| 元素 | 实现 |
|---|---|
| 参数传递顺序 | 从右到左 |
| 参数传递约定 | 按值,除非传递指针或引用类型 |
| 堆栈维护职责 | 调用的函数从堆栈中弹出自己的参数 |
| 名称修饰约定 | 下划线 (_) 是名称的前缀。 名称后跟后面是自变量列表中的字节数(采用十进制)的符号 (@)。 因此,声明为 int func( int a, double b ) 的函数按如下所示进行修饰:_func@12 |
| 大小写转换约定 | 无 |
Windows 选择 stdcall 是因为简化了二进制文件大小:
假设一个函数被调用 1000 次,如果使用 cdecl,每次调用后都需要调用者执行 add esp, X 来平栈,会有 1000 次这样的指令,而 stdcall 清理指令 ret n 只在函数定义出出现一次
调用方:
1 | push 3 ; 参数 c |
被调用方
1 | push ebp |
在 32位 C++ (MSVC) 中,非静态成员函数通常使用 thiscall, 而不是 stdcall,this 指针通过 ECX 寄存器传递
在 x64 中, stdcall 在 64 位模式下已失效,Windows x64 ABI 统一了调用约定
fastcall
fastcall 调用约定指定尽可能在寄存器中传递函数的自变量。 此调用约定仅适用于 x86 体系结构
| 元素 | 实现 |
|---|---|
| 参数传递顺序 | 在自变量列表中按从左到右的顺序找到的前两个 DWORD 或更小自变量将在 ECX 和 EDX 寄存器中传递;所有其他自变量在堆栈上从右向左传递 |
| 堆栈维护职责 | 已调用函数会弹出显示堆栈中的参数 |
| 名称修饰约定 | at 符号 (@) 是名称的前缀;参数列表中的字节数(在十进制中)前面的 at 符号是名称的后缀 |
| 大小写转换约定 | 不执行任何大小写转换 |
| 类、结构和并集 | 被视为“多字节”类型(无论大小)并在堆栈上传递 |
| 枚举和枚举类 | 如果它们的基础类型是通过寄存器传递的,则通过寄存器传递。 例如,如果基础类型是大小为 8、16 或 32 位的 int 或 unsigned int |
调用方
1 | push 4 ; 参数 d (栈) |
被调用方
1 | ; ... 函数体 ... |
而在 x64 中,x64 ABI 默认都使用寄存器传参,因此不再刻意区分 cdecl / stdcall / fastcall。但两者的寄存器分配不同
x64 (x86-64) 架构
| 特性 | Windows x64 (Microsoft x64 ABI) | Linux/macOS x64 (System V AMD64 ABI) |
|---|---|---|
| 前 4 个参数 | RCX, RDX, R8, R9 | RDI, RSI, RDX, RCX |
| 第 5, 6 个参数 | 栈 (Stack) | R8, R9 |
| 更多参数 | 栈 (Stack) | 栈 (Stack) |
| 浮点参数 | XMM0 - XMM3 | XMM0 - XMM7 |
| 返回值 | RAX (整型), XMM0 (浮点) | RAX (整型), RAX+RDX (128位), XMM0/1 (浮点) |
| 栈构造 (关键) | Shadow Space (32 bytes) | Red Zone (128 bytes) |
| Callee-Saved | RBX, RBP, RDI, RSI, R12-R15 | RBX, RBP, R12-R15 (RDI/RSI 是易失的) |
| 系统调用 (Syscall) | syscall (参数: RCX, RDX, R8, R9) | syscall (参数: RDI, RSI, RDX, R10, R8, R9) |
ARM64:
| 寄存器 | 角色 | 逆向意义 |
|---|---|---|
| X0 - X7 | 参数传递 | 函数的前 8 个参数 |
| X0 | 返回值 | 绝大多数函数结果存放在 X0 |
| X8 | 间接结果地址 | 如果返回值是大结构体(>16 bytes),调用者将接收地址放入 X8 |
| X9 - X15 | 临时寄存器 | 也就是 Caller-saved,函数内部可以随意破坏 |
| X19 - X28 | Callee-saved | 被调用者必须保护(入栈保存,返回前恢复) |
| X29 (FP) | 栈帧指针 | 类似于 x86 的 EBP |
| X30 (LR) | 链接寄存器 | 保存返回地址 |
| SP | 栈指针 | 必须 16 字节对齐 |
假设 C 代码:long func(long a, long b, long c, long d, long e, long f, long g, long h);
Case 1: x64 Windows
1 | ; Caller setup |
Case 2: x64 Linux
1 | ; Caller setup |
Case 3: ARM64
1 | ; Caller setup |
crackme
先打开看一看

要求去掉 Nag Screen 并且找到正确的 register code
使用 VB Decompiler 打开

发现 register code 硬编码了,那这就简单了,只需要去掉 Nag Screen 就行
找到 Nag Screen 的代码位置

打开 IDA Pro 找到地址 402C17

一大串不知道干什么的,这个是 stdcall 调用方式,试试直接返回,但是要注意调整堆栈
Online x86 and x64 Intel Instruction Assembler
使用 ret 4
转换工具可知机器码对应:C2 04 00
在 push ebp 打断点调试

继续调试可以看到修改成功了
接着输入 register code

成功
小结
本节简单介绍了 x86 相关的寄存器、栈、栈帧等基础知识,并且尝试逆向分析了三个小程序,虽然比较简单,但是对理解汇编和逆向分析有很大帮助
- Title: 逆向工程核心原理02
- Author: exdoubled
- Created at : 2026-01-18 10:00:00
- Updated at : 2026-01-23 10:14:41
- Link: https://github.com/exdoubled/exdoubled.github.io.git/reverse/reverse2/
- License: This work is licensed under CC BY-NC-SA 4.0.