抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

一些逆向中的基础知识。个人总结,暂时还没写完……

前言

虽然网上有很多大佬的教程认为写汉化补丁不需要熟练汇编语言,但是实际上手反汇编的时候就会发现,如果没有足够多的汇编知识,很多地方都会一头雾水,结果只能是弄明白教程中提供的游戏样品。当然,我们做汉化补丁的程序员不需要掌握全部的汇编知识,比如说《汇编语言(第4版)》后面大篇幅提及的dos中断,对于Windows程序来说已经不适用了。因此我建议看一看《汇编语言(第4版)》以及《Windows环境下32位汇编语言程序设计(典藏版)》

本文档属于个人的经验,不一定准确,如果有错误欢迎及时指出。

常用的汇编指令

本部分主要带着读者回顾一下常用的x86汇编指令,完整内容请参考《汇编语言》和《Win32程序设计》
尽管《汇编语言》介绍的是16位汇编,但其思想与用法与32位汇编很相近。 ## 常用寄存器 段寄存器CS DS ES SS:
由于WIN32程序的特性,程序能直接寻址4GB内存空间,故没有必要再使用dos模式下的段寄存器寻址。相对于Windows来说,它们现在有别的作用,值都一样,而且是被Windows锁定的。

累加寄存器EAX:
32位的EAX寄存器的低16位为寄存器AX。其中AX寄存器的高8位为AH,低8位为AL寄存器。
EAX最常见的用途是保存函数的返回值,或者是指向函数返回值的指针(函数返回值的地址)。因此很多CALL指令之后都会带着保存EAX的指令或者判断EAX的值。

基址寄存器EBX:
32位的EBX寄存器的低16位为寄存器BX。其中BX寄存器的高8位为BH,低8位为BL寄存器。
EBX常作为某一数据的指针,经常以[EBX]的形式获取内存数据。

计数寄存器ECX:
32位的ECX寄存器的低16位为寄存器CX。其中CX寄存器的高8位为CH,低8位为CL寄存器。
ECX寄存器常与loop指令或者rep指令搭配,记录某一代码体循环次数。

数据寄存器EDX:
32位的EDX寄存器的低16位为寄存器DX。其中DX寄存器的高8位为DH,低8位为DL寄存器。
EDX通常用于寻址,以及存储某一变量的指针。

来源索引寄存器ESI和目的索引寄存器EDI:
顾名思义,在用movsb movsw movsd这些串传送指令中,esi充当来源地址,edi充当目标地址(串传送指令会在“基本汇编指令”中详细描述一下)

栈顶指针寄存器ESP:
指向栈顶的指针。主要用于栈操作指令push pop中。

基址指针寄存器EBP:
经常用于访问栈中任意地址的数据,经常用于访问调用函数时推进栈中的传入参数以及函数体定义的局部变量。(在“函数的调用约定”中会再次提及)

指令指针寄存器EIP:
指向当前指令的地址,不可使用MOV指令改变,也不可以使用MOV指令获取EIP的值。
注意:在x64dbg中汇编mov eax,eip时,x64dbg会将此指令翻译成mov eax,汇编该指令时的eip值,造成了EIP的值可以传送的假象! 1-1-1 之后 1-1-2

标志寄存器EFLAGS:
这个非常难记住,用的时候会百度查就行……
EFLAGS常与cmp指令、串操作指令以及算数指令搭配使用
EFLAGS 中的 32 位被分成 0-31 个二进制位分别使用; 第 0、2、4、6、7、11 位是状态标志位; 第 10 位是字符串操作控制标志位; 其他标志位一般不用或无权使用

0 CF 进位(Carry)标志 目标无法容纳无符号算术运算的结果, 需要进位或借位时被设置; 可用 STC 指令设置, CLC 指令取消.
1
2 PF 奇偶(Parity)标志 低 8 位中有偶数个 1 时被设置
3
4 AF 辅助(Auxiliary)标志 使用 BCD 码运算导致 3 位到 4 位产生进位时被设置
5
6 ZF 零(Zero)标志 运算结果为 0 时被设置
7 SF 符号(Sign)标志 运算结果为负数时被设置
8
9
10 DF 方向(Direction)标志 字符串操作是从高位到低位时被设置; 可用 STD 指令设置, CLD 指令取消.
11 OF 溢出(Overflow)标志 因有符号运算的结果太宽而导致数据丢失时被设置

数据单位、数制、原码与补码(符号数)、数据类型以及大小端

在计算机中,一个二进制数据为1bit,由8个二进制数据组成的数据为1byte,由16bit组成1word,由32bit组成dword,由64bit组成qword。WIN32汇编中不支持定义qword数据类型。1dword=2word=4byte=32bit

在汇编语言中,数据类型均可以笼统的看为byte word dword类型,与高级语言不同,没有复杂的数据类型转换。高级语言的结构体在反汇编的视角中也可以看作是按照规律排列的一些byte等数据。

数制:masm汇编中一些指令可以利用常量,若数字后什么都不加,该常量即为十进制数据;在数字后方加入 H 则定义为16进制数据;后方加入 B 即为2进制数据,注意16进制数据不能以字母开头,若该数据以字母开头,则在前方加上一个 0 。在x64dbg中,则以"0x" "0b"作为前缀。
例如:

1
2
3
4
5
6
7
8
9
; masm
mov eax,0FFFFH
mov eax,1101010B
mov eax,13

; x64dbg
mov eax,0xFFFBA
mov eax,0b110110
mov eax,15
原码、补码相关知识可以自行百度,我过几天整理……

大小端(Big-Endian/Little-Endian):对于word和dword类型数据的存储,大端模式是把数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中;小端模式是把数据的低字节保存在内存的低地址中,而数据的高字节保存在内存的高地址中。从内存中读取数据时也是一样。
例如有以下内存空间,从左到右内存的地址不断增大: 2-1
执行指令 mov eax,[000DFF74H]
若以BE模式,则eax的值会被赋值:F900BF76
若以LE模式赋值,则eax=76BF00F9
执行指令 mov dword ptr [000DFF84H],12345678H
若以BE模式,则该部分内存空间数据变成: 12 34 56 78
若以LE模式,则该部分内存数据为: 78 56 34 12
需要记住的是,无论是大端还是小端,其参照的基本单位是byte而不是bit。
Intel的x86处理器仅支持小端存储,故WIN32汇编的读写内存均遵循LE模式。
(一些软件的中文翻译把UTF16 LE翻译成了UTF16对齐,这个特有迷惑性的翻译是怎么做到的呢……)

基本汇编指令(32位)

本章节仅提及一些常见的汇编指令的含义以及简要用法。一些不常见的遇见查百度就行了,但以下这些基本指令是需要明白的。
### 传送类指令:MOV MOVZX XCHG LEA PUSH POP PUSHF PUSHFD PUSHAD PUSHA

MOV 目标,来源
其中当目标与来源均为寄存器时,位数要一致;将立即数赋予内存空间时,要表明立即数的位数。
例如:

1
2
3
4
5
6
7
mov eax,1   ;将eax的值付成1
mov ebx,eax ;将ebx的值传给eax
mov [ebx],eax ;将寄存器eax的值复制到ebx所指向的dword数据的内存空间,注意内存是否可写
mov eax,[ebx] ;将ebx所指向的dword数据的内存空间的数据传送给寄存器eax
mov eax,ds:[ebx] ;寻址时可以在内存空间前加上段寄存器(虽然在WIN32汇编中没什么大用)
mov ax,2 ;将ax赋值为2,eax的高16位不发生任何变化
mov word ptr [edx],0F233H ;将十六进制数字F233以word数据传到edx指向的内存空间中

MOVZX 大目标,小来源
将位数较小的寄存器的值复制到大寄存器的低位中,大寄存器的高位全归0,例如:

1
2
3
4
5
6
7
8
; 假设eax值为FF12ABCDH,bx为1234H
movzx eax,bx
; 此时eax值为00001234H
mov eax,FF12ABCDH
movzx ax,bl
; 此时eax值为FF120034H
movzx eax,al
; 此时eax值为00000034H

XCHG 目标1,目标2
将两个寄存器或寄存器与内存空间的值进行互换,但不能用于两个内存空间直接互换,以及两个寄存器需要位数一致。例如:

1
2
xchg eax,ecx
xchg eax,[ebx]

LEA 寄存器,内存空间
将某一内存空间的地址传给寄存器,内存空间可以使用标号,例如:

1
2
lea eax,label   ;将标签label处的地址传给寄存器eax
label: nop

PUSH 寄存器或内存空间或立即数
在8086汇编中,push不能接立即数,故很多16位汇编教程会着重强调这一点。不过在win32汇编中,该指令是合法的。
将某一数据推入栈中,若数据为16位则esp-2,若数据为32位则esp-4。推荐尽可能使用32位数据。用于函数调用时的传参和保存某一寄存器的值。(详见“堆与栈”)例如:

1
2
3
4
5
push ebp    ;将ebp的值保存到栈中,同时栈顶esp-4
push ax ;将ax的值保存到栈中,同时栈顶esp-2
push 1 ;将数据1保存到栈中,同时栈顶esp-4
push word ptr [ebx]
push dword ptr [edx]

POP 寄存器或内存空间
将存在栈中的word或dword数据取出来赋值给寄存器或某一内存空间,并将栈指针esp+2或esp+4。至于是去word还是dword取决于寄存器或内存空间的位数,计算机是不知道在栈顶的数据到底是dword还是word,因此在使用pop指令时,一定一定要确定压入栈中和从栈中取出的数据类型相同,否则会严重影响程序的正常运行。例如:

1
2
3
pop ax
pop ebx
pop dword ptr [ebx]

PUSHFD与POPFD
将标志寄存器保存到栈中,常用于Hook某一代码体时标志位的保存。PUSHF是保存16位标志寄存器,PUSHFD保存32位标志寄存器。推荐使用PUSHFD,尽管浪费了1字节。
POPF则为从栈中取出16位保存到标志寄存器中,POPFD则取出32位保存到标志寄存器中,一定要注意push和pop的平衡。

1
2
pushfd
popfd

PUSHAD与POPAD
PUSHAD将寄存器EAX,ECX,EDX,EBX,ESP(初始值),EBP,ESI,EDI依次传入栈中,PUSHA则为相对应的16位寄存器。
在Hook某一应用内函数,常与PUSHFD使用,用于保存当前的寄存器的状态,结束Hook代码后再用POPAD,则可以让应用程序对执行的多余代码一无所知。

1
2
pushad
popad

算数运算类指令:ADD INC SUB DEC MUL DIV

算数运算类指令会影响标志寄存器的状态,通常使用算数指令之后要检查各标志寄存器状态,如是否出现溢出,结果是否为0或是否为负数。

加法指令:ADD 目标,源
将后一操作数与前一操作数相见,将结果存储到前一操作数中。目标可以为寄存器或内存空间,源操作数可为寄存器或内存空间或立即数,但不能同时为内存空间。例如:

1
2
3
4
add dword ptr [ebx],233     ;dword[ebx]=dword[ebx]+233
add word ptr [esi],233 ;word[esi]=word[esi]+233
add eax,2 ;eax=eax+2
add ebx,eax ;ebx=eax+ebx
自增指令:INC 目标
将目标寄存器或内存空间的数值进行加一。相当于add 目标,1
1
2
3
4
inc eax
inc ax
inc al
inc byte ptr [ebx]

减法指令:SUB 目标,源
自减指令:DEC 目标
用法和加法指令一样

1
2
3
4
5
6
sub dword ptr [edx],233
sub word ptr [esi],233
sub eax,2
sub ax,al
dec al
dec ebx

乘法指令:MUL 源操作数
源操作数可以为寄存器或内存空间。使用8位、16位、32位乘法取决于源操作数。
除法指令:DIV 源操作数

位操作指令:AND OR XOR NOT SHR SHL

计算机最基本数据单位是bit,位操作指令就是对二进制位进行操作的。

逻辑与的运算规则:运算的双方都同时为1时,则结果为1,否则结果为0

1
2
01000101 and 00110001 = 00000001
11111111 and 11011111 = 11011111
第二个例子可以看出,逻辑与可用在给指定的第几位数字改成0,而其他位的数字不发生改变。
汇编指令:AND 目标操作数,源操作数,将运算结果存在目标操作数中
1
2
and eax,ebx ;eax=eax&ebx
and al,11110111B ;将ax的第三位(从右向左)设置为0
用中学的串联电路可以形象地理解逻辑与。(书上给的那集成电路图看不懂啊……)将开关断开的状态设置成0,闭合的状态设置成1,灯泡亮起来设置成1,则有:
3-1-1

逻辑或:进行逻辑运算的两位都是0时则结果为0,任意一方为1是则结果为1。因此,逻辑或可用于给指定的第几位数字赋值为1而不改变其他位数的原始值。

1
2
01000101 or 00110001 = 01110101
01110101 or 10000000 = 11110101
对应的汇编指令为:OR 目标,源
1
2
or eax,ebx  ;eax=eax|ebx
or al,10000000B ;将最高一位置1
对应的电路图为: 3-1-2

逻辑非:将原来二进制数据0位全部变成1,将1位全部变成0

1
not 11110100 = 00001011
对应的汇编指令为:NOT 目标操作数
1
2
not eax
not bx
对应电路图为: 3-1-4

逻辑异或:进行逻辑异或的两位相同,则结果为0;两位不同则结果为1。

1
2
1001 xor 1100 = 0101
1001 xor 1001 = 0000
因此,可以利用这个性质将某一寄存器自身异或达到清0的效果,同时CF=OF=0 对应的汇编指令:XOR 目标,源
1
2
xor eax,eax
xor bl,10110001B
等效电路,这个其实是异或非(异或的结果再取逻辑非)的等效电路。为了方便理解,就用这个吧,就假定该电路图中灯泡不亮时为1即可。R1阻止与R2阻值要相等。 3-1-3

左移位指令SHL和右移位指令SHR: 左移位指令可将一个二进制数据最低位加0,最高位进入CF。右移位指令将二进制数据的最高位加0,最低位进入CF。

1
2
SHL 11001010 = 10010100,CF=1
SHR 11001010 = 01100101,CF=0
在结果为CF=0的时候,相当于对数据进行乘除2,速度和简洁度高于mul指令。
汇编指令为:SHL 寄存器或内存空间,立即数。SHR 寄存器或内存空间,立即数。立即数表示移位的次数。内存空间必须指明数据类型是byte/word/dword。
1
2
3
4
5
6
mov al,11001010B
shr al,3 ;al=00011001
mov al,11001010B
shl al,2 ;al=00101000
shr byte ptr [ebx],4
shl dword ptr [edx],2

(4)程序流程指令 CALL RET JMP JE JNE JA JAE JB JBE LOOP CMP LOOPE LOOPNE

(5)特别重要的几个特殊指令 LEAVE NOP REP REPZ MOVSB CMPSB CPUID CLD STD

堆Heap与栈Stack

函数的调用约定与函数的局部变量

汇编语言的调用函数call指令,不像高级语言那样能直接在函数体后面加括号,依次输入传递给函数的参数,因此使用调试器反汇编时看到的调用函数过程就不像高级语言那样看起来顺眼。为了能与函数传参,函数体和调用者就要遵守相同的获取参数的规则,这个规则就是函数的调用约定。一般常见的调用约定有stdcall cdecall fastcall thiscall。

绝大多数的WINAPI都遵循stdcall,即按照从右向左的顺序将参数依次推入栈中,函数返回前通过调整esp的值舍弃传入的参数来平衡栈。调用者无需考虑栈的问题。

1
void __stdcall Function(dword A,dword B,dword C,dword D)
反汇编之后为:
1
2
3
4
5
6
7
8
9
push D
push C
push B
push A
call Function
;........
Function:
;Todo
ret 10H
例如,用C语言调用MessageBox
1
2
3
4
5
6
include <windows.h>
int main (void)
{
MessageBoxA(hWnd,"Hello World!","VLSMB",34);
return 0;
}
反汇编为:
1
2
3
4
5
push 001002ECH
push 00449730H ;字符串"VLSMB"的地址
push 00449720H ;字符串"Hello World!"的地址
push 34H
call MessageBoxA
刚刚进入函数体的第一条语句时(通常是push esp),此时dword[esp]为函数返回地址,dword[esp+4]则为最左边的参数(最后传进来的参数),因此理论上来说可以通过[esp+x]来访问传进来的参数。(实际上是用ebp访问参数,接下来会在“局部变量”处提及)

C/C++ 默认的函数调用约定为cdecall(除了类的非静态成员函数),参数的入栈顺序与stdcall相同即从右向左,与stdcall不同的是,cdecall函数体返回时不会帮助调用者调整esp指针,需要调用者返回函数时清除传进来的参数。

1
2
void __cdecall Function(dword A,dword B,dword C,dword D);   // 在C/C++里写不写cdecall都可以
Function(A,B,C,D);
1
2
3
4
5
6
push D
push C
push B
push A
call Function
add esp,10H
通过用户舍弃压进栈的值,cdecall常用于函数体参数个数不确定的调用。WINAPI里的wsprintf就是一个cdecall函数。

fastcall是使用ECX和EDX寄存器传送前两个DWORD或更小的参数,其他参数依次从右至左入栈,由函数体返回时清除栈中的参数。

1
2
void __fastcall Function(dword A,dword B,dword C,dword D);
Functuin(A,B,C,D);
1
2
3
4
5
6
7
8
9
mov ecx,A
mov edx,B
push D
push C
call Function
;.................
Function:
; ToDo
ret 8
因此,在Hook为fastcall的函数时,一定要注意保存ecx和edx的值。

C++类成员函数的调用约定为thiscall,参数依然为从右到左入栈,参数个数不确定时由调用者处理栈,参数个数确定时则由成员函数本身处理栈;如果参数个数确定,this指针通过ecx寄存器传给成员函数,否则在所有参数入栈后,this指针入栈传给成员函数。

1
2
3
4
5
6
7
8
9
class TestClass
{
public:
void Function1(dword A,dword B);
void Function2(dword A,...);
}
TestClass* test = new TestClass();
test->Function1(A,B);
test->Function2(A,B,C,D);
1
2
3
4
5
6
7
8
9
10
11
12
; 仅展示调用函数的过程
push B
push A
mov ecx,this
call Function1
push D
push C
push B
push A
push this
call Function2
add esp,14H
通常情况下,函数体内部需要定义一些临时变量来处理临时任务,这样的临时变量称为局部变量。局部变量要在栈中申请,因此你会看到大多数函数反汇编开头都长这个模样:
1
2
3
push ebp
mov ebp,esp
sub esp,8 ;x为一个常数,也可以为add esp,-x,-x用补码表示
此时栈的情况如图所示,ebp指向了在栈中存储ebp的内存空间,esp指向了新的栈顶,留出了8字节未使用的空间,因此这8字节空间可以留给局部变量使用。
5-1-2

局部变量一共需要多大的字节数,就在函数开头esp减掉该数值。由于函数体中途也需要使用栈保存临时数据或调用函数,用esp访问传入参数和局部变量的任务就交给了ebp身上。[ebp+8]为参数A,[ebp+12]为参数B,[ebp-4]为一个局部变量,[ebp-8]为另一个局部变量。可以利用此规律,HookApi时改变栈中传给函数的参数值。
函数返回前,要用以下语句恢复ebp的值以及让esp指向函数的返回地址:

1
2
3
mov esp,ebp
pop ebp
ret
在80386中,可以使用leave指令执行以上两条指令,最后再使用ret指令返回函数,函数的任务就完成了。
1
2
leave
ret
例如,定义一个stdcall函数实现3个数字相加:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SUM proc
push ebp
mov ebp,esp
sub esp,4
mov dword ptr [ebp-4],0
mov eax,[ebp+8]
add [ebp-4],eax
mov eax,[ebp+12]
add [ebp-4],eax
mov eax,[ebp+16]
add [ebp-4],eax
mov eax,[ebp-4]
leave
ret 12
SUM endp
start:
push 3
push 2
push 1
call SUM
end start
对于有返回值的函数,一般使用eax存储返回值,或将返回值数组的首地址存在eax中,具体要看函数的定义。