复杂的 ACProtect 与强大的 OllyScript
使用工具
- OllyDbg 1.10原版,简称
OD
; OD
汉化
和插件
均来自互联网;- UnPackMe来自互联网,仅供学习使用;
- Dump 工具为 OD 插件 OllyDump,来自互联网;
- IAT 重建工具为 ImportREC,来自互联网;
- 强大的OllyScript,来自互联网;
- 文中特殊数字均是
HEX
,为了书写方便采用DEC
;
操作流程
编写脚本绕过检测硬件断点到达 OEP
将 CM 导入 OD,开始脱壳的第 1 步,寻找 OEP,多次尝试后,发现
最后一次异常法
可以快速到达 OEP;到达 OEP 后发现两点不同之处:
- OEP 处的指令不是 C++ 程序的入口;
- 堆栈数据与 EP 处不同;
通过这两个不同之处,基本可以确定有 Stolen-Code;
为了更方便的到达 OEP,给当前 OEP 设置一个硬件执行断点,设置好断点,重载并运行程序后,发现程序会直接运行并不会中断在 OEP;
重载程序,在 KiUserExceptionDispatcher 以及其内部调用的 ZwContinue 处设置断点:
设置好断点后运行程序;
程序中断在 KiUserExceptionDispatcher 函数的行首,此时,堆栈中 ESP + 4 的单元中存放着 CONTEXT 结构体的指针,在数据窗口中查看:
可以看到当前设置的 OEP 硬件断点,而其它 3 个为空;
F7
单步运行程序,发现在经过一个 CALL 后,断点被清除了:之所以无法用硬件断点到达 OEP,是因为经过异常派发函数后,断点被清除了;
这种情况下想快速到达 OEP 就需要借助 OllyScript 来绕过异常处理,重新设置断点;
方法 1:检测清除断点的 CALL 是否执行;
比如:当 ESP 到达 ZwContinue 时,清除断点的 CALL 肯定运行完毕了,此时就可以恢复硬件断点了;
缺点也很明显,如果在 KiUserExceptionDispatcher 函数中还有清除断点的指令,这种方法就失效了;
方法 2:判断 KiUserExceptionDispatcher 函数是否执行完毕;
获取 KiUserExceptionDispatcher 函数的返回地址并设置断点,当程序中断在返回位置时就表示函数运行完毕,此时就可以恢复硬件断点了;
显然第 2 种方法更好,不过函数的返回地址在哪呢?要返回到哪里呢?
函数的返回地址位于 CONTEXT 结构体 0xB8 偏移处;
根据计算 12FC8C + 0B8 = 12FD44(16 进制数以字母开头时需要前缀 0 或 0x,不然 OD 不认识);
当前的返回地址为 00471395(不够直观时可以切换显示方式);
继续运行程序发现,函数被调用了两次,而最终的返回地址为 00471090;
函数的返回地址为 00471090,在相应区段设置内存访问断点,看看函数要返回到哪里:
通过内存窗口可以看到,程序的代码段位于 00401000 ~ 0044AFFF,返回地址不在其中,返回地址所在的区段应该是由壳创建的,属于壳的区段;
找到了返回地址,就可以开始写 OllyScript 脚本了:
首先,需要在 OEP 处设置硬件执行断点:
1
bphws 004271b5, "x" //OEP 设置硬件执行断点
然后,需要分支来判断中断的位置:
1
2
3
4
5work: //主分支
eob to_process //跳向判断分支
run
to_process: //判断分支如果中断在 KiUserExceptionDispatcher 函数的行首,也就是 7C92E47C,则移除 OEP 处的断点:
1
2
3
4
5
6
7to_process:
cmp eip, 7c92e47c //中断位于 KiUserExceptionDispatcher 函数的行首
je to_clear //跳向清除分支
to_clear: //清除分支
bphwc 004271b5 //清除断点
jmp work //回到主分支,继续判断如果中断在 ZwContinue,也就是 7C92E493,则记录函数的返回地址:
当中断位于 ZwContinue 时,ESP 指向的值就是 CONTEXT 结构体的指针;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19var retaddr //声明记录返回地址的变量
to_process:
cmp eip, 7c92e47c
je to_clear
cmp eip, 7c92e493 //中断位于 ZwContinue
je to_record //跳向记录分支
to_clear:
bphwc 004271b5
jmp work
to_record: //记录分支
mov retaddr, esp //记录当前 esp 的地址
mov retaddr, [retaddr] //获取当前 esp 的值,也就是 CONTEXT 结构体的指针
add retaddr, 0b8 //偏移 0xB8 为返回地址
mov retaddr, [retaddr] //记录返回地址
bp retaddr //返回地址设置 CC 断点
jmp work //回到主分支,继续判断如果中断在返回位置,则需要重新设置 OEP 的硬件执行断点
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
28var retaddr
beginning: //添加标签,减少代码冗余
bphws 004271b5, "x" //OEP 设置硬件执行断点
to_process:
cmp eip, 7c92e47c
je to_clear
cmp eip, 7c92e493
je to_record
cmp eip, retaddr //中断位于返回地址
je to_reset //跳向重新设置分支
to_clear:
bphwc 004271b5
jmp work
to_record:
mov retaddr, esp
mov retaddr, [retaddr]
add retaddr, 0b8
mov retaddr, [retaddr]
bp retaddr //返回地址设置 CC 断点
jmp work
to_reset: //重新设置分支
bc retaddr //取消返回位置的断点
jmp beginning //重新设置 OEP 硬件执行断点最后,如果断点全部没有命中,则说明硬件断点设置完成,弹出消息弹窗:
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
39var retaddr
beginning:
bphws 004271b5, "x"
work:
eob to_process
run
to_process:
cmp eip, 7c92e47c
je to_clear
cmp eip, 7c92e493
je to_record
cmp eip, retaddr
je to_reset
jmp final //断点全部没有命中
to_clear:
bphwc 004271b5
jmp work
to_record:
mov retaddr, esp
mov retaddr, [retaddr]
add retaddr, 0b8
mov retaddr, [retaddr]
bp retaddr
jmp work
to_reset:
bc retaddr
jmp beginning
final:
MSGYN "是否继续?" //包含按钮的弹窗
cmp $RESULT, 1 //继续的值为 1,取消为 0
je beginning
ret这就是脚本的全部逻辑与内容;
脚本写好了,试一下:
需要注意的是,脚本是根据中断位置执行的,在脚本中会自动设置返回位置的 CC 断点,但 KiUserExceptionDispatcher 和 ZwContinue 函数的断点不会自动设置,所以运行前需要手动设置这两个断点;
运行脚本后,成功到达 OEP;
利用 Run 跟踪记录功能定位 Stolen Code
在初次到达 OEP 时,根据栈内容和 OEP 指令就确定这个 CM 有 Stolen-Code;
之前使用的方法是单步执行,寻找第 1 条指令
PUSH EBP
,这种方法适用于简单的加壳程序,但如果花指令有几百上千条,就捉襟见肘了;使用 Run 跟踪记录功能把最接近 OEP 的指令记录下来,然后静态分析,更快捷高效;
给 KiUserExceptionDispatcher 和 ZwContinue 函数设置断点,在最后一次中断于 ZwContinue 时,在返回地址对应的区段设置内存访问断点,然后到达函数返回地址,接着取消内存访问断点,同时给 OEP 设置硬件执行断点,作为 RUN 跟踪的终止条件;
打开 RUN 跟踪面板,选择记录到文件:
确认调试设置异常选项中已经忽略所有异常,防止自动跟踪过程中因为异常而中断:
在调试菜单中启用 RUN 跟踪功能:
可以根据
关闭 RUN 跟踪
按钮是否可用来判断有没有打开 RUN 跟踪功能;按下
Ctrl + F11
或在调试菜单中选择跟踪步入
选项:耐心等待,直到程序的状态由跟踪变为暂停:
使用 EditPlus 打开 RUN 跟踪记录的文件:
文件有 40M+ 大小,执行的指令有 90 多万行(幸亏我 F7 了几百行就放弃了),终止的条件是到达 OEP,这就是给 OEP 设置硬件执行断点的原因;
在 EditPlus 中文件末尾按下
Ctrl + F
,倒序搜索PUSH EBP
:至于为啥要倒序搜索,因为倒序会搜索到离当前 OEP 最近的相匹配的指令,也是最接近原始 OEP 的指令;
可以看到,
PUSH EBP
后,ESP 的值为 12FFC0,紧接着是PUSH -1
,那么 ESP 的值应该是 12FFBC;查看当前 OEP 的栈窗口,栈顶地址为 12FFBC 且值为 -1,完全吻合,这些应该就是原始 OEP 的指令;
将这三行有用的指令复制到 OD 任意位置,查看占用字节:
这三行指令占用 5 个字节,那么,当前 OEP 往前 5 个字节就是原始 OEP,即 4271B5 - 5 = 4271B0;
复制三行指令的机器码,粘贴到原始 OEP,同时撤销查看占用字节时的修改;
用 OllyDump 将修改好的数据转存下来:
需要修改偏移为原始 OEP 的偏移,并取消重建输入表选项;
编写脚本修复 IAT 重定向
Dump 完程序,接下来就是修复 IAT 了,使用脚本到达 OEP 后,在汇编窗口右键选择查找,然后选择所有模块间的调用:
可以看到有一部分有函数名,有一部分没有函数名,说明 IAT 中部分数据被重定向了;
在任意调用上双击或按下 Enter 键或右键选择反汇编窗口中跟随,就可以到达调用位置;
在数据窗口查看所有 IAT 项后发现,虽然被重定向了,但都处于同一区段,也就是壳所在的区段,并没有专门创建新的区段;
复制之前的脚本,重命名为 IAT.txt,以 00460974 为例(第 1 个被重定向的数据):
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
40var retaddr
beginning:
bphws 00460974, "w" //修改地址,类型为写入
work:
eob to_process
run
to_process:
cmp eip, 7c92e47c
je to_clear
cmp eip, 7c92e493
je to_record
cmp eip, retaddr
je to_reset
jmp final
to_clear:
bphwc 00460974 //修改地址
jmp work
to_record:
mov retaddr, esp
mov retaddr, [retaddr]
add retaddr, 0b8
mov retaddr, [retaddr]
bp retaddr
jmp work
to_reset:
bc retaddr
jmp beginning
final:
MSGYN "是否继续?"
cmp $RESULT, 1
je beginning
ret修改断点地址为 460974,断点类型为写入,至于为什么要用脚本,因为可以绕过断点检测呀;
手动设置 KiUserExceptionDispatcher 和 ZwContinue 断点,然后载入脚本:
硬件断点中断的位置是下一条指令,查看上面一条指令,将 EAX 的值写入 EDI 指向的地址中,数据窗口查看,是第 1 个被重定向的数据;
反汇编窗口中跟随 EAX 的值,只有简短的 3 条指令:
将自然数 F0616754 压入堆栈,然后用这个自然数与 8CE1703B 异或,接着就返回了;
460974 中存放的是重定向后函数的地址,也就是说 F0616754 与 8CE1703B 异或后就是函数的入口地址,计算一下:
XOR F0616754, 8CE1703B
结果为 7C80176F;将堆栈窗口向上滚动,在 ESP-C 的位置可以看到函数真正的入口点以及函数名:
可以看到,这个被重定向的函数为 kernel32.GetSystemTime,入口地址为 7C80176F,与手动计算的一致;
这样的话,就可以用脚本将 EBP-C 位置的数据写入 EDI 指向的地址,不就修复重定向了,岂不美哉?
看完被重定向的函数,还有一部分是没有重定向的,以 460818 为例,修改脚本中的地址,手动设置两个断点后载入脚本:
不仅与之前中断的指令相同,就连地址也相同,唯一不同的就是 EAX;
这样的话,就可以通过判断 EAX 中的地址来确定是否为重定向的值以及是否将 EBP-C 中的地址写入 EDI 指向的地址;
如何确定哪些数据是被重定向的呢?在内存窗口可以找到答案:
IAT 被重定向的数据处于同一区段,也就是 0046B000 ~ 0048DFFF 之间,如果 EAX 的值在 0046B000 ~ 0048DFFF 之间,就说明被重定向了;
同时查看各 DLL 的原始地址可以发现,所有 DLL 都位于 5D170000 之后,如果 EAX 的值大于 5D170000,说明就没有被重定向;
综合起来,取简单好记的中间值就是:如果 EAX 大于 50000000,则说明没有被重定向,否则就是被重定向了,需要将 EBP-C 中的值写入 EDI 指向的地址中;
逻辑清晰后,开始编写脚本:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54//第 1 个变量用来存储 KiUserExceptionDispatcher 函数的返回值
var retaddr
//第 2 个变量用来存储 ESP-C 的值
var repaddr
beginning:
bphws 004743d5, "x" // MOV DWORD PTR DS:[EDI],EAX
work:
eob to_process
run
to_process:
cmp eip, 7c92e47c
je to_clear
cmp eip, 7c92e493
je to_record
cmp eip, retaddr
je to_reset
cmp eip, 004743d5 //中断于 004743d5 则跳向修复
je to_repair
jmp final
to_clear:
bphwc 004743d5
jmp work
to_record:
mov retaddr, esp
mov retaddr, [retaddr]
add retaddr, 0b8
mov retaddr, [retaddr]
bp retaddr
jmp work
to_reset:
bc retaddr
jmp beginning
to_repair:
cmp eax, 50000000
ja beginning //eax 大于 50000000 则不用修复
mov repaddr, esp
//add repaddr, -0C //OllyScript 好像不支持加负数
sub repaddr, c //esp-c
mov repaddr, [repaddr] //esp-c 的值
mov [edi], repaddr //拷贝到 edi 指向的地址
jmp beginning
final:
MSGYN "是否继续?"
cmp $RESULT, 1
je beginning
ret写完当然是测试啦,手动设置 KiUserExceptionDispatcher 和 ZwContinue 断点,然后载入脚本:
IAT 重定向已经被修复啦,完美;
打开 ImportREC ,填写相关选项:
OEP 的 RVA 为:271B0;
IAT 的 RVA 为:60818;
IAT 的 Size 为:710;然而,有一个无效选项,RVA 为 00060DE8,重载程序,给无效数据设置内存写入断点,设置 KiUserExceptionDispatcher 和 ZwContinue 断点,然后载入脚本;
可以看到,ESP-C 位置的数据的确是 46E5CB,看来不是脚本的问题;
重载程序,使用 OEP.txt 到达 OEP 后,给 460DE8 设置内存写入断点,然后运行程序:
程序中断,既然要定位的是系统 API 函数,那么它的入口地址肯定大于 50000000,按下
Ctrl + T
设置条件,当 EIP 位于 0 ~ 50000000 之外是就意味着调用了系统 API,那时就暂停程序:设置完成后,按下
Ctrl + F11
或调试菜单选择跟踪步入运行程序;中断在这里,查看寄存器窗口就已经知道调用的是 USER32.MessageBoxA 了:
反汇编窗口中跟随到调用位置之后,发现 CALL 直接调用的正是 460DE8;
返回 ImportREC,修改无效选项,然后修复 dumped.exe;
修复 Anti-Dump
打开修复完成的程序,发现程序无法正常运行,将修复后的程序导入 OD,同时取消忽略异常;
在 OD 中运行程序后,提示信息显示有个访问违规异常,打开调用窗口:
应该是 429806 处调用 46C5F3 时产生了访问违规异常,在这行右键选择显示调用,定位到调用位置;
接着在调用指令上按下 Enter 或右键选择跟随,查看 CALL 里面的内容:
原来,CALL 是间接调用,内部是跳转表,但却没有数据,这就是壳的 Anti-Dump:
怪不得 EIP 指向 0,是因为跳转表中的地址根本没有数据;
再打开一个 OD 窗口,载入未脱壳的程序并运行,然后定位到跳转表:
对比两个窗口,因为没有数据,右键菜单没有跟随选项,信息窗口数据为 0,数据窗口数据也为 0;
在原程序的跳转表上按下 Enter 或右键选择跟随:
尝试多次后发现,跳转表项是顺序跳转的,每次执行 6 字节的指令就返回,且跳转表每项也是 6 个字节;
如果用跳转后执行的指令直接替换跳转表,应该就能解决 Anti-Dump 了:
在原程序所在的 OD 中选中所有指令,然后选择二进制复制,汇编窗口或数据窗口复制皆可,然后在 dump 程序所在的 OD 中选中所有跳转表项,然后选择二进制粘贴;
在这里指令不会完全覆盖跳转表,而指令下方的 0B6 应该是无效数据,复制了也白搭,就以指令长度为准吧,不行了再粘贴 0B6;
保存修改到文件,然后运行程序,bingo!