汇编指令精讲(一):数据传送与算术运算
各位同学,今天我们正式进入汇编指令的实战环节。说实话,很多初学者一看到汇编指令就头大,觉得这玩意儿又枯燥又难记。我当年刚入行时也这么想,直到有一次在逆向一个恶意软件时,发现对方用了几条巧妙的LEA指令就把整个控制流给藏起来了——那一刻我才真正意识到,指令集就是二进制世界的语法,不懂它,你连代码在干什么都看不懂。
今天我们先啃两块硬骨头:数据传送指令和算术运算指令。这两类指令是汇编代码里出现频率最高的,你随便打开一个二进制文件,里面至少一半都是它们。
核心要点:数据传送指令负责搬数据,算术运算指令负责算数据。两者配合,构成了程序的基本计算单元。
一、数据传送指令:搬砖的艺术
1.1 MOV:最基础的搬运工
MOV指令,说白了就是把数据从一个地方复制到另一个地方。它的格式很简单:MOV 目标操作数, 源操作数。注意顺序——目标在前,源在后,跟Intel语法保持一致。
; 基本用法
MOV EAX, 0x12345678 ; 将立即数送入EAX
MOV EBX, EAX ; 将EAX的值复制到EBX
MOV ECX, [0x400000] ; 将内存0x400000处的值送入ECX
MOV [EBP-4], EDX ; 将EDX的值存入栈中局部变量位置
注意:MOV指令不能直接从内存搬到内存!比如 MOV [addr1], [addr2] 是非法的。你必须通过寄存器中转。我见过不少新手在这里栽跟头,编译直接报错。
我个人习惯在逆向分析时,先快速扫一遍代码里的MOV指令,看看哪些常量被频繁加载。这往往能帮你快速定位关键数据。比如一个程序里反复出现MOV EAX, 0x401000,那这个地址八成是个关键函数入口。
1.2 PUSH 和 POP:栈操作双雄
栈是程序运行时的临时存储区,遵循后进先出(LIFO)原则。PUSH把数据压入栈顶,POP从栈顶弹出数据。这两个指令在函数调用、上下文切换时用得特别多。
; 栈操作示例
PUSH EAX ; 将EAX压栈,ESP -= 4
PUSH 0x100 ; 将立即数压栈
POP EBX ; 弹出栈顶到EBX,ESP += 4
POP ECX ; 继续弹出
嗯,这里要注意:PUSH和POP操作的是ESP寄存器。每次PUSH,ESP减4(32位下);每次POP,ESP加4。我曾经在调试一个栈溢出漏洞时,发现攻击者就是通过精心构造的PUSH序列覆盖了返回地址——所以理解栈的变化对安全分析至关重要。
小技巧:在逆向分析时,看到连续的PUSH后面跟着CALL,这通常是在传递函数参数。x86下参数是通过栈传递的,顺序是从右向左压栈。
1.3 LEA:取地址的利器
LEA(Load Effective Address)是我个人非常喜欢的一条指令。它不访问内存,只计算有效地址并存入寄存器。很多人把它跟MOV搞混,其实区别很明显:MOV取值,LEA取地址。
; LEA vs MOV 对比
MOV EAX, [EBP+8] ; 取EBP+8处的值
LEA EAX, [EBP+8] ; 取EBP+8这个地址本身
; LEA的妙用:快速算术
LEA EAX, [ECX+ECX*4] ; EAX = ECX * 5
LEA EDX, [EAX+EAX*8] ; EDX = EAX * 9
你看,LEA还能用来做乘法运算,而且比MUL快得多。我在优化一个图像处理算法时,就用LEA替代了多次乘法,性能提升了将近20%。
1.4 XCHG:交换数据
XCHG指令交换两个操作数的值。虽然用得不多,但在某些同步机制里能看到它的身影。
XCHG EAX, EBX ; 交换EAX和EBX的值
XCHG EAX, [MEM_ADDR] ; 交换寄存器和内存的值
实战经验:XCHG指令在原子操作中很常见。比如实现自旋锁时,会用XCHG配合LOCK前缀来保证操作的原子性。不过日常逆向中遇到它的频率不高,了解即可。
二、算术运算指令:让数据动起来
2.1 ADD 和 SUB:加减法
ADD和SUB是最基本的算术指令。它们会影响标志寄存器(EFLAGS)中的ZF(零标志)、SF(符号标志)、CF(进位标志)和OF(溢出标志)。
; 加法示例
ADD EAX, 10 ; EAX += 10
ADD EBX, ECX ; EBX += ECX
ADD EAX, [0x400000] ; EAX += 内存中的值
; 减法示例
SUB EAX, 5 ; EAX -= 5
SUB ECX, EDX ; ECX -= EDX
为什么我要强调标志位?因为在逆向分析条件跳转时,你经常需要根据ADD/SUB之后的标志位来判断程序走向。比如SUB之后紧跟着JZ(Jump if Zero),那八成是在判断两个数是否相等。
避坑指南:我曾经在分析一个加密算法时,看到ADD之后没有检查溢出,结果算出来的值完全不对。后来才发现是整数溢出导致的问题。所以在做安全分析时,一定要留意算术运算是否可能产生溢出。
2.2 MUL 和 DIV:乘除法
乘除法比加减法复杂一些,因为它们涉及的操作数长度和结果存放位置有特殊规定。
MUL(无符号乘法):
- 单操作数:
MUL EBX→EAX * EBX,结果存于EDX:EAX(高32位在EDX,低32位在EAX) - 双操作数:
IMUL EAX, EBX→EAX *= EBX(有符号乘法)
DIV(无符号除法):
DIV EBX→EDX:EAX / EBX,商存于EAX,余数存于EDX- 如果除数为0,会触发
#DE异常(除零异常)
; 乘法示例
MOV EAX, 100
MOV EBX, 200
MUL EBX ; EDX:EAX = 100 * 200 = 20000
; 除法示例
MOV EAX, 1000
XOR EDX, EDX ; 清空EDX,因为被除数是EDX:EAX
MOV ECX, 7
DIV ECX ; EAX = 142 (商), EDX = 6 (余数)
个人经验:做除法前一定要先清空EDX!我见过太多人忘记这一步,结果EDX里残留了之前的值,导致计算结果完全错误。用XOR EDX, EDX或者MOV EDX, 0都行。
2.3 INC 和 DEC:自增自减
INC和DEC分别对操作数加1和减1。它们比ADD/SUB效率更高,而且不影响CF标志位(只影响ZF、SF、OF)。
INC EAX ; EAX++
DEC ECX ; ECX--
在循环中你经常能看到INC或DEC配合JNZ(Jump if Not Zero)使用,实现循环计数。比如:
MOV ECX, 10
loop_start:
; 循环体代码
DEC ECX
JNZ loop_start ; ECX != 0 时继续循环
2.4 NEG:取补码
NEG指令对操作数取补码,相当于0 - 操作数。它会影响CF、ZF、SF、OF标志位。
MOV EAX, 5
NEG EAX ; EAX = -5 (0xFFFFFFFB)
你想想看,NEG在逆向分析中有什么用?我遇到过一些混淆代码,它们用NEG配合ADD来实现减法,故意绕开SUB指令,增加分析难度。这时候如果你不了解NEG的行为,很容易被绕晕。
三、综合实战:一段简单的汇编代码分析
好了,理论讲完了,我们来看一段真实的汇编代码。假设你在逆向一个计算器程序,遇到了下面这段:
; 计算 (a + b) * c - d / e
MOV EAX, [a] ; 加载a
ADD EAX, [b] ; EAX = a + b
MOV ECX, [c]
MUL ECX ; EDX:EAX = (a+b) * c
PUSH EAX ; 暂存结果到栈
MOV EAX, [d]
XOR EDX, EDX ; 清空EDX
MOV ECX, [e]
DIV ECX ; EAX = d / e, EDX = 余数
POP EBX ; 恢复之前的乘积
SUB EBX, EAX ; EBX = (a+b)*c - d/e
MOV [result], EBX ; 保存结果
这段代码虽然简单,但包含了我们今天讲的大部分指令:MOV、ADD、MUL、DIV、PUSH、POP、SUB。你能看出它计算的是什么吗?
分析思路:先看数据流向,再看运算顺序。注意PUSH和POP的配对使用,以及除法前对EDX的清零操作——这些都是实战中必须注意的细节。
四、总结与避坑清单
今天的内容就到这里。我整理了一份避坑清单,都是我在项目中踩过的坑:
| 指令 | 常见错误 | 正确做法 |
|---|---|---|
| MOV | 试图内存到内存直接传送 | 用寄存器中转 |
| PUSH/POP | 忘记栈平衡(PUSH和POP数量不匹配) | 确保成对出现 |
| LEA | 误以为LEA会访问内存 | LEA只计算地址,不取值 |
| MUL | 忽略EDX:EAX的高32位 | 注意结果可能溢出到EDX |
| DIV | 忘记清空EDX | 用XOR EDX, EDX清零 |
| NEG | 混淆NEG和NOT | NEG取补码,NOT取反码 |
记住,汇编指令是二进制世界的基石。你越熟悉它们,逆向分析时就越得心应手。下次当你面对一堆十六进制字节时,能一眼认出这些指令,那才叫真正的入门了。
最后一个小建议:找几个简单的C程序,编译成汇编代码,对照着看。这是最快的学习方式。我当年就是这么过来的——每天看几十页汇编输出,慢慢就练出了「汇编眼」。
公众号:蓝海资料掘金营,微信deep3321