逆向工程核心原理02

逆向工程核心原理02

exdoubled Lv4

小端序标记法

在计算机系统中,数据的存储方式有两种主要的字节序:大端序(Big-endian)和小端序(Little-endian)。小端序是指数据的低位字节存储在内存的低地址处,而高位字节存储在内存的高地址处。

下面是简单的示例

1
2
3
4
BYTE b = 0x12;
WORD w = 0x1234;
DWORD dw = 0x12345678;
char str[] = "abcde";

以上代码中共有4种数据类型,它们的大小不同,可以看看下面同一个数据根据不同字节序保存时的区别:

TYPENameSIZE大端序小端序
BYTEb1[12][12]
WORDw2[12][34][34][12]
DWORDdw4[12][34][56][78][78][56][34][12]
char[]str6[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
2
3
4
5
6
7
8
9
10
11
Basic program execution registers
x87 FPU registers
MMX registers
XMM registers
Control registers
Memory management registers
Debug registers
Memory type range registers
Machine specific registers
Machine check registers
...

为了简化起见,这里介绍基本程序运行寄存器的相关内容,后面陆续介绍有关控制寄存器、内存管理寄存器、调试寄存器等相关寄存器的知识

下面的图来自 IA-32 官方用户手册,描述了基本程序运行的组织架构,它由4类寄存器组成

  • 通用寄存器,32位,8个
  • 段寄存器,16位,6个
  • 程序状态和控制寄存器,32位,1个
  • 指令指针寄存器,32位,1个
633X510/2-1.png

通用寄存器

通用寄存器用于传送和暂存数据,也可参与算术逻辑运算并保存运算结果,根据官方文档的描述,用于存放以下项目:

  • 逻辑运算和算术运算的操作数(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 位)
389X202/2-2.png
  • 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个字节

每个段寄存器指向的段描述符和虚拟内存相结合,形成一个线性地址,借助分页技术,线性地址最终被转换为实际的物理地址(不借助分页技术的操作系统中,线性地址直接变为物理地址)

846X410/2-3.png
841X456/2-4.png
  • 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 这种指令

620X497/2-5.png

这里只介绍 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-R15DR8-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, DHAL, BL, CL, DL, DIL, SIL, BPL, SPL, R8B - R15B
字寄存器AX, BX, CX, DX, DI, SI, BP, SPAX, BX, CX, DX, DI, SI, BP, SP, R8W - R15W
双字寄存器EAX, EBX, ECX, EDX, EDI, ESI, EBP, ESPEAX, 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 会导致未定义行为,因此栈的正确使用需要由程序或调用约定加以保证

看书中这个例子:

在下面这个地方打断点

448X370/2-6.png

push 100h 时,关注栈指针 esp

698X369/2-7.png

此时栈指针为 0019FF74

执行 pop eax 后,关注栈指针 esp

698X369/2-8.png

此时变为 0019FF78

可以看到栈指针增加了 4 个字节(32 位系统下,每次入栈或出栈都是 4 字节)

分析 abex’ crackme#1

这个是需要破解程序到另外一个分支

简单打开是这样的 371X227/2-9.png

确定后是这样

347X227/2-10.png

再确定就退出了

直接用 IDA 打开分析

1637X873/2-11.png

如图,可以加断点运行看到比较时 eaxesi 的值

jz 的意思是如果为0就跳转,cmp 相当于一个减法操作,它执行了 eax-esi,结果放在 eax 中,但不保存结果,只更新标志位也就是标志寄存器 eflags 中的 ZF 标志位

jz 改为 jmpjmp 的意思是直接跳转

可以看到修改后成功了

441X227/2-12.png

栈帧

栈帧就是利用 EBP 寄存器访问局部变量、参数、函数返回地址等的手段

简单用汇编来说就是

在函数开头:

1
2
3
push ebp        ; 保存上一个栈帧的基址
mov ebp, esp ; 设置当前栈帧的基址为当前栈顶
sub esp, XX ; 为局部变量分配空间

在函数结尾

1
2
3
mov esp, ebp   ; 恢复栈顶指针
pop ebp ; 恢复上一个栈帧的基址
ret ; 返回到调用函数

最新的编译器中启用了 -o 优化后,简单的函数将不会产生栈帧

简单来分析一下 stackframe.exe

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
35
36
37
38
39
.text:00401000                 push    ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 8
.text:00401006 mov eax, [ebp+arg_0]
.text:00401009 mov [ebp+var_8], eax
.text:0040100C mov ecx, [ebp+arg_4]
.text:0040100F mov [ebp+var_4], ecx
.text:00401012 mov eax, [ebp+var_8]
.text:00401015 add eax, [ebp+var_4]
.text:00401018 mov esp, ebp
.text:0040101A pop ebp
.text:0040101B retn


.text:00401020 var_8 = dword ptr -8
.text:00401020 var_4 = dword ptr -4
.text:00401020 argc = dword ptr 8
.text:00401020 argv = dword ptr 0Ch
.text:00401020 envp = dword ptr 10h
.text:00401020
.text:00401020 push ebp
.text:00401021 mov ebp, esp
.text:00401023 sub esp, 8
.text:00401026 mov [ebp+var_4], 1
.text:0040102D mov [ebp+var_8], 2
.text:00401034 mov eax, [ebp+var_8]
.text:00401037 push eax
.text:00401038 mov ecx, [ebp+var_4]
.text:0040103B push ecx
.text:0040103C call sub_401000
.text:00401041 add esp, 8
.text:00401044 push eax
.text:00401045 push offset Format ; "%d\n"
.text:0040104A call _printf
.text:0040104F add esp, 8
.text:00401052 xor eax, eax
.text:00401054 mov esp, ebp
.text:00401056 pop ebp
.text:00401057 retn

可以看到函数开头和结尾的栈帧操作,包括 main 函数和 sub_401000 函数,sub_401000 中间有一堆 mov,这是在操作局部变量和参数

[ebp+arg_0] 代表第一个参数,[ebp+arg_4] 代表第二个参数

[ebp+var_8] 代表第一个局部变量,[ebp+var_4] 代表第二个局部变量

可以看到 main 函数中,先给两个局部变量赋值 12,然后把它们作为参数传递给 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)
  • 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语言中的指针)传出

分析

首先打开先随便运行一下

400X442/2-13.png

显然找到密码就行

打开 VB Decompiler 静态分析一下,首先找到 Nope 这个字符串

1310X1310/2-14.png

这里找到了比较的东西,var_44 和 var_34 估计分别是两个值,只比较了这两个怀疑是密码

打开 IDA Pro 调试,断点打在刚刚比较的附近,也就是 0x403332 附近

1410X336/2-15.png

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

597X290/2-16.png

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

778X329/2-17.png

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

518X168/2-18.png
600X348/2-19.png

明显这个 qwer是输入的,95969798 就是真正的密码了

重新试了一下,成功

406X450/2-20.png

值得一提的是栈上的 0800 是 Variant 结构体的类型标记,看到 08 说明是字符串,02 是整型,03 是长整型等

VB 中字符串类型是 BSTR,在栈中存放 Variant 结构体,在堆中存放实际的字符串数据,存放字符串中间的00 是因为 VB6 采用 Unicode 编码,每个字符占两个字节

复盘

其实会注意到如果改变了 Name 的值,密码也随即会变,所以实际上密码是和 Name 相关联的,接下来探讨一下加密的过程

由于 VB Decompiler 的存在,我们不用像书上一样一个一个追踪,直接看反编译的结果

962X956/2-21.png

注意看这一段,中间出现了比较的 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 的生成:

  1. 95
    • 十六进制 0x95 = 十进制 149
    • 算法是 ASCII + 100 = 149
    • 所以 ASCII = 149 - 100 = 49
    • 查 ASCII 表,49 对应的字符是 ‘1’
  2. 96
    • 0x96 = 150
    • ASCII = 150 - 100 = 50
    • 50 对应的字符是 ‘2’
  3. 依此类推:
    • 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
2
3
4
5
6
7
8
9
10
11
; 1. 参数入栈 (从右向左)
push 3 ; 参数 c
push 2 ; 参数 b
push 1 ; 参数 a

; 2. 调用函数
call _add ; EIP 入栈,跳转

; 3. 调用者平栈 (关键特征)
; 3个 int = 12 字节
add esp, 0Ch ; <--- 只有 cdecl 由 Caller 执行此操作

x64 中, cdecl 在 64 位模式下已基本失效

按照 ARMx64 上的约定,自变量将尽可能传入寄存器,后续自变量传递到堆栈中

stdcall

stdcall 调用约定用于调用 Win32 API 函数。 被调用方将清理堆栈

元素实现
参数传递顺序从右到左
参数传递约定按值,除非传递指针或引用类型
堆栈维护职责调用的函数从堆栈中弹出自己的参数
名称修饰约定下划线 (_) 是名称的前缀。 名称后跟后面是自变量列表中的字节数(采用十进制)的符号 (@)。 因此,声明为 int func( int a, double b ) 的函数按如下所示进行修饰:_func@12
大小写转换约定

Windows 选择 stdcall 是因为简化了二进制文件大小:

假设一个函数被调用 1000 次,如果使用 cdecl,每次调用后都需要调用者执行 add esp, X 来平栈,会有 1000 次这样的指令,而 stdcall 清理指令 ret n 只在函数定义出出现一次

调用方:

1
2
3
4
5
6
7
push 3          ; 参数 c
push 2 ; 参数 b
push 1 ; 参数 a
call _func@12 ; 调用函数
; 注意:此处无 add esp, X 指令!
; 所有的平栈操作都在 func 内部完成。
mov [ebp-4], eax ; 获取返回值

被调用方

1
2
3
4
5
6
push ebp
mov ebp, esp
; ... 函数体 ...
pop ebp
ret 0Ch ; 关键特征: ret n
; 相当于: pop eip; add esp, 12 (3个int参数)

在 32位 C++ (MSVC) 中,非静态成员函数通常使用 thiscall, 而不是 stdcall,this 指针通过 ECX 寄存器传递

x64 中, stdcall 在 64 位模式下已失效,Windows x64 ABI 统一了调用约定

fastcall

fastcall 调用约定指定尽可能在寄存器中传递函数的自变量。 此调用约定仅适用于 x86 体系结构

元素实现
参数传递顺序在自变量列表中按从左到右的顺序找到的前两个 DWORD 或更小自变量将在 ECX 和 EDX 寄存器中传递;所有其他自变量在堆栈上从右向左传递
堆栈维护职责已调用函数会弹出显示堆栈中的参数
名称修饰约定at 符号 (@) 是名称的前缀;参数列表中的字节数(在十进制中)前面的 at 符号是名称的后缀
大小写转换约定不执行任何大小写转换
类、结构和并集被视为“多字节”类型(无论大小)并在堆栈上传递
枚举和枚举类如果它们的基础类型是通过寄存器传递的,则通过寄存器传递。 例如,如果基础类型是大小为 8、16 或 32 位的 intunsigned int

调用方

1
2
3
4
5
push 4          ; 参数 d (栈)
push 3 ; 参数 c (栈)
mov edx, 2 ; 参数 b -> EDX
mov ecx, 1 ; 参数 a -> ECX
call @func@16 ; 调用

被调用方

1
2
3
; ... 函数体 ...
ret 8 ; 平栈 8 字节 (只平栈里的参数 c, d)
; 寄存器里的参数无需 ret n 清理

而在 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, R9RDI, RSI, RDX, RCX
第 5, 6 个参数栈 (Stack)R8, R9
更多参数栈 (Stack)栈 (Stack)
浮点参数XMM0 - XMM3XMM0 - XMM7
返回值RAX (整型), XMM0 (浮点)RAX (整型), RAX+RDX (128位), XMM0/1 (浮点)
栈构造 (关键)Shadow Space (32 bytes)Red Zone (128 bytes)
Callee-SavedRBX, RBP, RDI, RSI, R12-R15RBX, 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 - X28Callee-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
2
3
4
5
6
7
8
9
10
11
12
; Caller setup
push h ; 参数 8 (Stack)
push g ; 参数 7 (Stack)
push f ; 参数 6 (Stack)
push e ; 参数 5 (Stack)
sub rsp, 0x20 ; Shadow Space (必须!)
mov r9, d ; 参数 4
mov r8, c ; 参数 3
mov rdx, b ; 参数 2
mov rcx, a ; 参数 1
call func
add rsp, 0x40 ; 平栈 (Shadow 32 + 4 args * 8)

Case 2: x64 Linux

1
2
3
4
5
6
7
8
9
10
11
; Caller setup
push h ; 参数 8 (Stack)
push g ; 参数 7 (Stack)
mov r9, f ; 参数 6
mov r8, e ; 参数 5
mov rcx, d ; 参数 4
mov rdx, c ; 参数 3
mov rsi, b ; 参数 2
mov rdi, a ; 参数 1
call func
add rsp, 0x10 ; 平栈 (2 args * 8)

Case 3: ARM64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; Caller setup
; 仅仅参数 h 需要处理,其他都在寄存器
str x7, [sp, #-16]! ; 假设把第8个参数h压栈 (实际可能更复杂,需对齐)
mov x7, h ; 错误:这里演示逻辑,实际上 h 应该在栈上,X7 是参数 h 吗?
; 修正:X0-X7 对应 a-h。
mov x0, a
mov x1, b
mov x2, c
mov x3, d
mov x4, e
mov x5, f
mov x6, g
mov x7, h ; ARM64 刚好 8 个寄存器!
; 如果有第 9 个参数 i:
; str x_i, [sp]
bl func

crackme

先打开看一看

615X251/2-22.png

要求去掉 Nag Screen 并且找到正确的 register code

使用 VB Decompiler 打开

1370X993/2-23.png

发现 register code 硬编码了,那这就简单了,只需要去掉 Nag Screen 就行

找到 Nag Screen 的代码位置

1185X268/2-24.png

打开 IDA Pro 找到地址 402C17

1637X1108/2-25.png

一大串不知道干什么的,这个是 stdcall 调用方式,试试直接返回,但是要注意调整堆栈

Online x86 and x64 Intel Instruction Assembler

使用 ret 4

转换工具可知机器码对应:C2 04 00

push ebp 打断点调试

1275X836/2-26.png

继续调试可以看到修改成功了

接着输入 register code

781X565/2-27.png

成功

小结

本节简单介绍了 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.
Comments