在分析代码混淆时,一般的指令是比较好分析的,但对于利用栈进行混淆处理这类代码是比较头痛的,编写对付这类代码的自动分析模块是比较麻烦的,恐怕得追踪记录栈的使用情况。
例如,请分析下面10条代码,并将它的混淆部分去除,得到最简化代码。这是比较常见的一类混淆形式,实际上它只是一个混淆块的一部分:
... ...
11 push esp
12 push 0x78014532
13 push dword ptr [esp+8]
14 push eax
15 mov eax, 4
16 add eax, dword ptr [esp+0ch]
17 xchg eax, [esp]
18 pop dword ptr [esp+4]
19 pop edx
20 mov esp, [esp]
... ...
你能完成吗?能在一分钟之内完成吗?能在30秒内完成吗?一分钟内完成,你不错嘛。30秒内完成,你很优秀了,并且很有经验!
那么,这段代码倒底是干嘛的?实际上,它有用的只有一条指令:
pop edx
如果,你还不能分析出来,大概是没弄清 push 与 pop 指令的操作。那么,先看下面的讲解弄懂后,再来分析。
1. push 与 pop 指令的执行
对于寄存器与立即数操作数理解是没难度的,有困惑的是在于内存操作数上。一般的资料上说:
- push eax: 先将 esp 减 4,然后往栈里写入 eax。
- pop eax: 先从栈里读一个值到 eax,然后将 esp 加 4。
它们对 esp 的操作顺序是不同的。这个观点也不能说错, 但也不能说完全正确!一般情况下还可以(寄存器或者立即数操作数)。但是,如果这样理解,将会是很糟糕的!特别是在混淆代码分析中!
因为,对于下面两条指令很容易糊涂:
- push dword ptr [esp+8]: 这个 esp 值是原值?还是已经减了 4 后的值?
- pop dword ptr [esp+4]: 这个 esp 值是原值?还是已经加了 4 后的值?
实际上,在 CPU 内部的流水线操作的角度上看,push 与 pop 的顺序是完全一样的!
它们的三个步骤是相同的:
- 读源操作数
- 修改栈指针(sp/esp/rsp)
- 写入目标操作数
以 32 位操作数为例,则有:
push imme32/mem32/reg32 :
temp <= source ; 源操作数读入临时保存起来
esp <= esp - 4 ; 将 esp 减去 4
[esp] <= temp ; 往 [esp] 里写入 temp 值
pop mem32/reg32 :
temp <= [esp] ; 将 [esp] 的值读入临时保存起来
esp <= esp + 4 ; 将 esp 加上 4
dest <= temp ; 向目标操作数写入 temp 值
因此,不管是 push 还是 pop 指令,都需要先将源操作数读入保存到 CPU 内部的临时寄存器里。然后再改变栈指针,最后写入目标操作数。
我们可以发现:
- 对于 push 指令:目标操作数是 [esp]
- 对于 pop 指令: 源操作数是 [esp]
那么,对于上面两条指令,我们就很清晰,很正确地理确了
- push dword ptr [esp+8]: 这个 esp 值是原值,也就是减去 4 之前的值。因为 [esp+8] 是源操作数!
- pop dword ptr [esp+4]: 这个 esp 值是加上 4 后的值。因为 [esp+4] 是目标操作数!
2. 逐步分析
弄清楚 push 与 pop 指令后, 再进行下面的分析
11 push esp
12 push 0x78014532
13 push dword ptr [esp+8]
14 push eax
假设,压栈前的 esp 值为 00012d3c,那么, 执行完这 4 条 push 指令后栈的内容如下:
00012d3c: | ... ... |
+----------+
00012d38: | 00012d3c | <- 压入 esp 值
+----------+
00012d34: | 78014532 | <- 压入数值
+----------+
00012d30: | [esp+8] | <- 压入 00012d34+8 内存的值, 也就是 00012d3c 内的值
+----------+
00012d2c: | eax | <- 压入 eax
+----------+
00012d28: | ... ... |
接下来的 3 条指令用来增加栈内 esp 映像值
15 mov eax, 4
16 add eax, dword ptr [esp+0ch]
17 xchg eax, [esp]
add eax, dword ptr [esp+0ch] 这条指令执行时, 当前的 esp 值为 00012d2c, [esp+0ch] 的地址就是 00012d38. 接着将 eax 值与 [esp] 交换, 也就是写入 eax 到当前 [esp],并恢复原 eax 值。
00012d3c: | ... ... |
+----------+
00012d38: | 00012d3c | <- 用这个值加 4 等于 00012d40
+----------+
00012d34: | 78014532 |
+----------+
00012d30: | [esp+8] |
+----------+
00012d2c: | eax | <- 恢复 eax 值,并写入 00012d40
+----------+
00012d28: | ... ... |
接下来执行两条 pop 指令, 栈内容改变为
18 pop dword ptr [esp+4]
19 pop edx
00012d3c: | ... ... |
+----------+
00012d38: | 00012d3c |
+----------+
00012d34: | 78014532 | <--+ 将 00012d40 写入此处
+----------+ |
00012d30: | [esp+8] | <--+--- pop dword ptr [esp+4] 当前 esp 指向此处
+----------+ |
00012d2c: | 00012d40 | ---+
+----------+
00012d28: | ... ... |
pop edx 指令执行后, 栈结构如下:
00012d3c: | ... ... |
+----------+
00012d38: | 00012d3c |
+----------+
00012d34: | 00012d40 | <--- esp 指向此处
+----------+
00012d30: | [esp+8] | ---> 执行 pop edx 指令后, edx 寄存器的值就是 00012d3c 内的值
+----------+
00012d2c: | 00012d40 |
+----------+
00012d28: | ... ... |
最后执行 mov esp, [esp] 指令, 栈结构如下:
00012d44: | ... ... |
+----------+
00012d40: | ... ... | <--- esp 指向此处
+----------+
00012d3c: | ... ... |
+----------+
00012d38: | 00012d3c |
+----------+
00012d34: | 00012d40 | ---> 执行 mov esp, [esp] 指令更新 esp 值
+----------+
00012d30: | [esp+8] |
+----------+
00012d2c: | 00012d40 |
+----------+
00012d28: | ... ... |
3. 总结
在上面的分析中, esp 值由原来的 00012d3c 变为 00012d40, 而 edx 寄存器的值变为 [00012d3c] 内的值。因此, 这个混淆代码块实际上只执行这一条指令: pop edx
如果, 只有少量的这类代码, 那还是比较幸运的。问题是有大量的这类代码,并且不是一成不变的,演化出 N 种变形,那是很头痛的。对于一般代码,很容易通过脚本代码(例如用 python 写一个分析代码)处理,但这类代码要时时刻刻留意 esp 的变化,那是比较难处理的。
例如:
mov eax, 4
lea edx, [edi+eax*4] => 转化为: push dword ptr [edi+10h]
push dword ptr [edx]
通过将 N 条指令转化为一条指令,或少于 N 条指令。有助于降低混淆代码数量, 有助于分析。实际上,混淆代码虽然千变万化,但目的只有一个: 澎胀代码折磨人!