复杂的 ACProtect 与强大的 OllyScript

使用工具

  • OllyDbg 1.10原版,简称OD
  • OD 汉化插件均来自互联网;
  • UnPackMe来自互联网,仅供学习使用;
  • Dump 工具为 OD 插件 OllyDump,来自互联网;
  • IAT 重建工具为 ImportREC,来自互联网;
  • 强大的OllyScript,来自互联网;
  • 文中特殊数字均是HEX,为了书写方便采用DEC

操作流程

编写脚本绕过检测硬件断点到达 OEP
  1. 将 CM 导入 OD,开始脱壳的第 1 步,寻找 OEP,多次尝试后,发现最后一次异常法可以快速到达 OEP;

    最后一次异常法

    到达 OEP 后发现两点不同之处:

    • OEP 处的指令不是 C++ 程序的入口;
    • 堆栈数据与 EP 处不同;

    通过这两个不同之处,基本可以确定有 Stolen-Code;

  2. 为了更方便的到达 OEP,给当前 OEP 设置一个硬件执行断点,设置好断点,重载并运行程序后,发现程序会直接运行并不会中断在 OEP;

  3. 重载程序,在 KiUserExceptionDispatcher 以及其内部调用的 ZwContinue 处设置断点:

    KiUserExceptionDispatcher

    设置好断点后运行程序;

  4. 程序中断在 KiUserExceptionDispatcher 函数的行首,此时,堆栈中 ESP + 4 的单元中存放着 CONTEXT 结构体的指针,在数据窗口中查看:

    CONTEXT

    可以看到当前设置的 OEP 硬件断点,而其它 3 个为空;

  5. F7单步运行程序,发现在经过一个 CALL 后,断点被清除了:

    断点被清除了

    之所以无法用硬件断点到达 OEP,是因为经过异常派发函数后,断点被清除了;

  6. 这种情况下想快速到达 OEP 就需要借助 OllyScript 来绕过异常处理,重新设置断点;

    • 方法 1:检测清除断点的 CALL 是否执行;

      比如:当 ESP 到达 ZwContinue 时,清除断点的 CALL 肯定运行完毕了,此时就可以恢复硬件断点了;

      缺点也很明显,如果在 KiUserExceptionDispatcher 函数中还有清除断点的指令,这种方法就失效了;

    • 方法 2:判断 KiUserExceptionDispatcher 函数是否执行完毕;

      获取 KiUserExceptionDispatcher 函数的返回地址并设置断点,当程序中断在返回位置时就表示函数运行完毕,此时就可以恢复硬件断点了;

  7. 显然第 2 种方法更好,不过函数的返回地址在哪呢?要返回到哪里呢?

    返回地址

    函数的返回地址位于 CONTEXT 结构体 0xB8 偏移处;

    根据计算 12FC8C + 0B8 = 12FD44(16 进制数以字母开头时需要前缀 0 或 0x,不然 OD 不认识);

    当前的返回地址为 00471395(不够直观时可以切换显示方式);

    继续运行程序发现,函数被调用了两次,而最终的返回地址为 00471090;

    00471090

    函数的返回地址为 00471090,在相应区段设置内存访问断点,看看函数要返回到哪里:

    返回外壳

    内存映射

    通过内存窗口可以看到,程序的代码段位于 00401000 ~ 0044AFFF,返回地址不在其中,返回地址所在的区段应该是由壳创建的,属于壳的区段;

  8. 找到了返回地址,就可以开始写 OllyScript 脚本了:

    • 首先,需要在 OEP 处设置硬件执行断点:

      1
      bphws 004271b5, "x" //OEP 设置硬件执行断点
    • 然后,需要分支来判断中断的位置:

      1
      2
      3
      4
      5
      work: //主分支
      eob to_process //跳向判断分支
      run

      to_process: //判断分支
    • 如果中断在 KiUserExceptionDispatcher 函数的行首,也就是 7C92E47C,则移除 OEP 处的断点:

      1
      2
      3
      4
      5
      6
      7
      to_process:
      cmp eip, 7c92e47c //中断位于 KiUserExceptionDispatcher 函数的行首
      je to_clear //跳向清除分支

      to_clear: //清除分支
      bphwc 004271b5 //清除断点
      jmp work //回到主分支,继续判断
    • 如果中断在 ZwContinue,也就是 7C92E493,则记录函数的返回地址:

      中断在 ZwContinue

      当中断位于 ZwContinue 时,ESP 指向的值就是 CONTEXT 结构体的指针;

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      var 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
      28
      var 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
      39
      var 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

      这就是脚本的全部逻辑与内容;

  9. 脚本写好了,试一下:

    需要注意的是,脚本是根据中断位置执行的,在脚本中会自动设置返回位置的 CC 断点,但 KiUserExceptionDispatcher 和 ZwContinue 函数的断点不会自动设置,所以运行前需要手动设置这两个断点;

    成功到达 OEP

    运行脚本后,成功到达 OEP;

利用 Run 跟踪记录功能定位 Stolen Code
  1. 在初次到达 OEP 时,根据栈内容和 OEP 指令就确定这个 CM 有 Stolen-Code;

  2. 之前使用的方法是单步执行,寻找第 1 条指令PUSH EBP,这种方法适用于简单的加壳程序,但如果花指令有几百上千条,就捉襟见肘了;

  3. 使用 Run 跟踪记录功能把最接近 OEP 的指令记录下来,然后静态分析,更快捷高效;

  4. 给 KiUserExceptionDispatcher 和 ZwContinue 函数设置断点,在最后一次中断于 ZwContinue 时,在返回地址对应的区段设置内存访问断点,然后到达函数返回地址,接着取消内存访问断点,同时给 OEP 设置硬件执行断点,作为 RUN 跟踪的终止条件;

  5. 打开 RUN 跟踪面板,选择记录到文件:

    选择记录到文件

  6. 确认调试设置异常选项中已经忽略所有异常,防止自动跟踪过程中因为异常而中断:

    调试菜单异常选项

  7. 在调试菜单中启用 RUN 跟踪功能:

    启用 RUN 跟踪功能

    可以根据关闭 RUN 跟踪按钮是否可用来判断有没有打开 RUN 跟踪功能;

  8. 按下Ctrl + F11或在调试菜单中选择跟踪步入选项:

    跟踪步入

  9. 耐心等待,直到程序的状态由跟踪变为暂停:

    由跟踪变为暂停

  10. 使用 EditPlus 打开 RUN 跟踪记录的文件:

    EditPlus

    文件有 40M+ 大小,执行的指令有 90 多万行(幸亏我 F7 了几百行就放弃了),终止的条件是到达 OEP,这就是给 OEP 设置硬件执行断点的原因;

  11. 在 EditPlus 中文件末尾按下Ctrl + F,倒序搜索PUSH EBP

    搜索`PUSH EBP`

    至于为啥要倒序搜索,因为倒序会搜索到离当前 OEP 最近的相匹配的指令,也是最接近原始 OEP 的指令;

    可以看到,PUSH EBP后,ESP 的值为 12FFC0,紧接着是PUSH -1,那么 ESP 的值应该是 12FFBC;

    PUSH -1

    查看当前 OEP 的栈窗口,栈顶地址为 12FFBC 且值为 -1,完全吻合,这些应该就是原始 OEP 的指令;

  12. 将这三行有用的指令复制到 OD 任意位置,查看占用字节:

    查看占用字节

    这三行指令占用 5 个字节,那么,当前 OEP 往前 5 个字节就是原始 OEP,即 4271B5 - 5 = 4271B0;

    复制三行指令的机器码,粘贴到原始 OEP,同时撤销查看占用字节时的修改;

    复制三行指令的机器码

    粘贴到原始 OEP

  13. 用 OllyDump 将修改好的数据转存下来:

    OllyDump

    需要修改偏移为原始 OEP 的偏移,并取消重建输入表选项;

编写脚本修复 IAT 重定向
  1. Dump 完程序,接下来就是修复 IAT 了,使用脚本到达 OEP 后,在汇编窗口右键选择查找,然后选择所有模块间的调用:

    所有模块间的调用

    可以看到有一部分有函数名,有一部分没有函数名,说明 IAT 中部分数据被重定向了;

    数据窗口中跟随

    在任意调用上双击或按下 Enter 键或右键选择反汇编窗口中跟随,就可以到达调用位置;

    内存窗口

    在数据窗口查看所有 IAT 项后发现,虽然被重定向了,但都处于同一区段,也就是壳所在的区段,并没有专门创建新的区段;

  2. 复制之前的脚本,重命名为 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
    40
    var 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,断点类型为写入,至于为什么要用脚本,因为可以绕过断点检测呀;

  3. 手动设置 KiUserExceptionDispatcher 和 ZwContinue 断点,然后载入脚本:

    写入位置

    硬件断点中断的位置是下一条指令,查看上面一条指令,将 EAX 的值写入 EDI 指向的地址中,数据窗口查看,是第 1 个被重定向的数据;

  4. 反汇编窗口中跟随 EAX 的值,只有简短的 3 条指令:

    3条指令

    将自然数 F0616754 压入堆栈,然后用这个自然数与 8CE1703B 异或,接着就返回了;

    460974 中存放的是重定向后函数的地址,也就是说 F0616754 与 8CE1703B 异或后就是函数的入口地址,计算一下:

    XOR F0616754, 8CE1703B结果为 7C80176F;

  5. 将堆栈窗口向上滚动,在 ESP-C 的位置可以看到函数真正的入口点以及函数名:

    ESP - C

    可以看到,这个被重定向的函数为 kernel32.GetSystemTime,入口地址为 7C80176F,与手动计算的一致;

    这样的话,就可以用脚本将 EBP-C 位置的数据写入 EDI 指向的地址,不就修复重定向了,岂不美哉?

  6. 看完被重定向的函数,还有一部分是没有重定向的,以 460818 为例,修改脚本中的地址,手动设置两个断点后载入脚本:

    460818

    不仅与之前中断的指令相同,就连地址也相同,唯一不同的就是 EAX;

    这样的话,就可以通过判断 EAX 中的地址来确定是否为重定向的值以及是否将 EBP-C 中的地址写入 EDI 指向的地址;

  7. 如何确定哪些数据是被重定向的呢?在内存窗口可以找到答案:

    内存窗口可以找到答案

    IAT 被重定向的数据处于同一区段,也就是 0046B000 ~ 0048DFFF 之间,如果 EAX 的值在 0046B000 ~ 0048DFFF 之间,就说明被重定向了;

    同时查看各 DLL 的原始地址可以发现,所有 DLL 都位于 5D170000 之后,如果 EAX 的值大于 5D170000,说明就没有被重定向;

    综合起来,取简单好记的中间值就是:如果 EAX 大于 50000000,则说明没有被重定向,否则就是被重定向了,需要将 EBP-C 中的值写入 EDI 指向的地址中;

  8. 逻辑清晰后,开始编写脚本:

    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
  9. 写完当然是测试啦,手动设置 KiUserExceptionDispatcher 和 ZwContinue 断点,然后载入脚本:

    测试

    IAT 重定向已经被修复啦,完美;

  10. 打开 ImportREC ,填写相关选项:

    OEP 的 RVA 为:271B0;
    IAT 的 RVA 为:60818;
    IAT 的 Size 为:710;

    ImportREC

    然而,有一个无效选项,RVA 为 00060DE8,重载程序,给无效数据设置内存写入断点,设置 KiUserExceptionDispatcher 和 ZwContinue 断点,然后载入脚本;

    然后载入脚本

    可以看到,ESP-C 位置的数据的确是 46E5CB,看来不是脚本的问题;

  11. 重载程序,使用 OEP.txt 到达 OEP 后,给 460DE8 设置内存写入断点,然后运行程序:

    给 460DE8 设置内存写入断点

    程序中断,既然要定位的是系统 API 函数,那么它的入口地址肯定大于 50000000,按下Ctrl + T设置条件,当 EIP 位于 0 ~ 50000000 之外是就意味着调用了系统 API,那时就暂停程序:

    设置条件

    设置完成后,按下Ctrl + F11或调试菜单选择跟踪步入运行程序;

  12. 中断在这里,查看寄存器窗口就已经知道调用的是 USER32.MessageBoxA 了:

    USER32.MessageBoxA

    反汇编窗口中跟随到调用位置之后,发现 CALL 直接调用的正是 460DE8;

  13. 返回 ImportREC,修改无效选项,然后修复 dumped.exe;

修复 Anti-Dump
  1. 打开修复完成的程序,发现程序无法正常运行,将修复后的程序导入 OD,同时取消忽略异常;

    取消忽略异常

  2. 在 OD 中运行程序后,提示信息显示有个访问违规异常,打开调用窗口:

    调用窗口

    应该是 429806 处调用 46C5F3 时产生了访问违规异常,在这行右键选择显示调用,定位到调用位置;

  3. 接着在调用指令上按下 Enter 或右键选择跟随,查看 CALL 里面的内容:

    查看 CALL 里面的内容

  4. 原来,CALL 是间接调用,内部是跳转表,但却没有数据,这就是壳的 Anti-Dump:

    内部是跳转表

    怪不得 EIP 指向 0,是因为跳转表中的地址根本没有数据;

  5. 再打开一个 OD 窗口,载入未脱壳的程序并运行,然后定位到跳转表:

    然后定位到跳转表

    对比两个窗口,因为没有数据,右键菜单没有跟随选项,信息窗口数据为 0,数据窗口数据也为 0;

  6. 在原程序的跳转表上按下 Enter 或右键选择跟随:

    在原程序的跳转表上

    尝试多次后发现,跳转表项是顺序跳转的,每次执行 6 字节的指令就返回,且跳转表每项也是 6 个字节;

  7. 如果用跳转后执行的指令直接替换跳转表,应该就能解决 Anti-Dump 了:

    复制跳转后的指令

    在原程序所在的 OD 中选中所有指令,然后选择二进制复制,汇编窗口或数据窗口复制皆可,然后在 dump 程序所在的 OD 中选中所有跳转表项,然后选择二进制粘贴;

    在这里指令不会完全覆盖跳转表,而指令下方的 0B6 应该是无效数据,复制了也白搭,就以指令长度为准吧,不行了再粘贴 0B6;

  8. 保存修改到文件,然后运行程序,bingo!

    bingo!