CTF-Reverse要点


软件逆向工程简介

定义

Reverse engineering, also called back engineering, is the process by which a man-made object is deconstructed to reveal its designs, architecture, or to extract knowledge from the object; —— from wikipedia

软件代码逆向主要指对软件的结构,流程,算法,代码等进行逆向拆解和分析。

应用领域

主要应用于软件维护,软件破解,漏洞挖掘,恶意代码分析。

CTF竞赛中的逆向

涉及 Windows、Linux、Android 平台的多种编程技术,要求利用常用工具对源代码及二进制文件进行逆向分析,掌握 Android 移动应用APK文件的逆向分析,掌握加解密、内核编程、算法、反调试和代码混淆技术。
—— 《全国大学生信息安全竞赛参赛指南》

要求

  • 熟悉如操作系统,汇编语言,加解密等相关知识
  • 具有丰富的多种高级语言的编程经验
  • 熟悉多种编译器的编译原理
  • 较强的程序理解和逆向分析能力

常规逆向流程

  1. 使用strings/file/binwalk/IDA等静态分析工具收集信息,并根据这些静态信息进行google/github搜索
  2. 研究程序的保护方法,如代码混淆,保护壳及反调试等技术,并设法破除或绕过保护
  3. 反汇编目标软件,快速定位到关键代码进行分析
  4. 结合动态调试,验证自己的初期猜想,在分析的过程中理清程序功能
  5. 针对程序功能,写出对应脚本,求解出flag

定位关键代码tips

  1. 分析控制流

    控制流可以参见IDA生成的控制流程图(CFG),沿着分支循环和函数调用,逐块地阅读反汇编代码进行分析。

  2. 利用数据、代码交叉引用

    比如输出的提示字符串,可以通过数据交叉引用找到对应的调用位置,进而找出关键代码。代码交叉引用比如图形界面程序获取用户输入,就可以使用对应的windowsAPI函数,我们就可以通过这些API函数调用位置找到关键代码。

逆向tips

  1. 编码风格

    每个程序员的编码风格都有所不同,熟悉开发设计模式的同学能更迅速地分析出函数模块功能

  2. 集中原则

    程序员开发程序时,往往习惯将功能相关的代码或是数据写在同一个地方,而在反汇编代码中也能显示出这一情况,因此在分析时可以查看关键代码附近的函数和数据。

  3. 代码复用

    代码复用情况非常普遍,而最大的源代码仓库Github则是最主要的来源。在分析时可以找一些特征(如字符串,代码风格等)在Github搜索,可能会发现类似的代码,并据此恢复出分析时缺失的符号信息等。

  4. 七分逆向三分猜

    合理的猜测往往能事半功倍,遇到可疑函数却看不清里面的逻辑,不妨根据其中的蛛丝马迹猜测其功能,并依据猜测继续向下分析,在不断的猜测验证中,或许能帮助你更加接近代码的真相。

  5. 区分代码

    拿到反汇编代码,必须能区分哪些代码是人为编写的,而哪些是编译器自动附加的代码。人为编写的代码中,又有哪些是库函数代码,哪些才是出题人自己写的代码,出题人的代码又经过编译器怎样的优化?我们无须花费时间在出题人以外的代码上,这很重要。如果当你分析半天还在库函数里乱转,那不仅体验极差,也没有丝毫效果。

  6. 耐心

    无论如何,给予足够的时间,总是能将一个程序分析地透彻。但是也不应该过早地放弃分析。相信自己肯定能在抽茧剥丝的过程中突破问题。

动态分析

动态分析的目的在于定位关键代码后,在程序运行的过程中,借由输出信息(寄存器,内存变化,程序输出)等来验证自己的推断或是理解程序功能

主要方法有:调试,符号执行,污点分析

算法和数据结构识别

  • 常用算法识别

Tea/XTea/XXTea/IDEA/RC4/RC5/RC6/AES/DES/IDEA/MD5/SHA256/SHA1等加密算法,大数加减乘除、最短路等传统算法

  • 常用数据结构识别

如图、树、哈希表等高级数据结构在汇编代码中的识别。

代码混淆

比如使用OLLVMmovfuscator花指令虚拟化SMC等工具技术对代码进行混淆,使得程序分析十分困难。

那么对应的也有反混淆技术,最主要的目的就是复原控制流。比如模拟执行符号执行

保护壳

保护壳类型有许多,简单的压缩壳可以归类为如下几种

  • unpack -> execute

    直接将程序代码全部解压到内存中再继续执行程序代码

  • unpack -> execute -> unpack -> execute …

    解压部分代码,再边解压边执行

  • unpack -> [decoder | encoded code] -> decode -> execute

    程序代码有过编码,在解压后再运行函数将真正的程序代码解码执行

对于脱壳也有相关的方法,比如单步调试法ESP定律等等

反调试

反调试意在通过检测调试器等方法避免程序被调试分析。比如使用一些API函数如IsDebuggerPresent检测调试器,使用SEH异常处理,时间差检测等方法。也可以通过覆写调试端口、自调试等方法进行保护。

非常规逆向思路

非常规逆向题设计的题目范围非常之广,可以是任意架构的任意格式文件。

  • lua/python/java/lua-jit/haskell/applescript/js/solidity/webassembly/etc..
  • firmware/raw bin/etc..
  • chip8/avr/clemency/risc-v/etc.

但是逆向工程的方法学里不惧怕这些未知的平台格式,遇到这样的非常规题,我们也有一些基本的流程可以通用

前期准备

  • 阅读文档。快速学习平台语言的方法就是去阅读官方文档。
  • 官方工具。官方提供或建议的工具必然是最合适的工具
  • 教程。在逆向方面,也许有许多前辈写出了专门针对该平台语言的逆向教程,因此也可以快速吸收这其中的知识。

找工具

主要找文件解析工具反汇编器调试器反编译器。其中反汇编器是必需的,调试器也包含有相应的反汇编功能,而对于反编译器则要自求多福了,得之我幸失之我命。

找工具总结起来就是:Google大法好。合理利用Google搜索语法,进行关键字搜索可以帮助你更快更好地找到合适工具。

常见加密算法和编码识别

前言

在对数据进行变换的过程中,除了简单的字节操作之外,还会使用一些常用的编码加密算法,因此如果能够快速识别出对应的编码或者加密算法,就能更快的分析出整个完整的算法。CTF 逆向中通常出现的加密算法包括base64、TEA、AES、RC4、MD5等。

Base64

Base64 是一种基于64个可打印字符来表示二进制数据的表示方法。转换的时候,将3字节的数据,先后放入一个24位的缓冲区中,先来的字节占高位。数据不足3字节的话,于缓冲器中剩下的比特用0补足。每次取出6比特,按照其值选择ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/中的字符作为编码后的输出,直到全部输入数据转换完成。

通常而言 Base64 的识别特征为索引表,当我们能找到 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ 这样索引表,再经过简单的分析基本就能判定是 Base64 编码。

当然,有些题目 base64 的索引表是会变的,一些变种的 base64 主要 就是修改了这个索引表。

Tea

密码学中,微型加密算法(Tiny Encryption Algorithm,TEA)是一种易于描述和执行块密码,通常只需要很少的代码就可实现。其设计者是剑桥大学计算机实验室大卫·惠勒罗杰·尼达姆

参考代码:

#include <stdint.h>

void encrypt (uint32_t* v, uint32_t* k) {
    uint32_t v0=v[0], v1=v[1], sum=0, i;           /* set up */
    uint32_t delta=0x9e3779b9;                     /* a key schedule constant */
    uint32_t k0=k[0], k1=k[1], k2=k[2], k3=k[3];   /* cache key */
    for (i=0; i < 32; i++) {                       /* basic cycle start */
        sum += delta;
        v0 += ((v1<<4) + k0) ^ (v1 + sum) ^ ((v1>>5) + k1);
        v1 += ((v0<<4) + k2) ^ (v0 + sum) ^ ((v0>>5) + k3);  
    }                                              /* end cycle */
    v[0]=v0; v[1]=v1;
}

void decrypt (uint32_t* v, uint32_t* k) {
    uint32_t v0=v[0], v1=v[1], sum=0xC6EF3720, i;  /* set up */
    uint32_t delta=0x9e3779b9;                     /* a key schedule constant */
    uint32_t k0=k[0], k1=k[1], k2=k[2], k3=k[3];   /* cache key */
    for (i=0; i<32; i++) {                         /* basic cycle start */
        v1 -= ((v0<<4) + k2) ^ (v0 + sum) ^ ((v0>>5) + k3);
        v0 -= ((v1<<4) + k0) ^ (v1 + sum) ^ ((v1>>5) + k1);
        sum -= delta;                                   
    }                                              /* end cycle */
    v[0]=v0; v[1]=v1;
}

在 Tea 算法中其最主要的识别特征就是 拥有一个 image number :0x9e3779b9 。当然,这 Tea 算法也有魔改的,感兴趣的可以看 2018 0ctf Quals milk-tea。

RC4

密码学中,RC4(来自Rivest Cipher 4的缩写)是一种流加密算法,密钥长度可变。它加解密使用相同的密钥,因此也属于对称加密算法。RC4是有线等效加密(WEP)中采用的加密算法,也曾经是TLS可采用的算法之一。

void rc4_init(unsigned char *s, unsigned char *key, unsigned long Len) //初始化函数
{
    int i =0, j = 0;
    char k[256] = {0};
    unsigned char tmp = 0;
    for (i=0;i<256;i++) {
        s[i] = i;
        k[i] = key[i%Len];
    }
    for (i=0; i<256; i++) {
        j=(j+s[i]+k[i])%256;
        tmp = s[i];
        s[i] = s[j]; //交换s[i]和s[j]
        s[j] = tmp;
    }
 }

void rc4_crypt(unsigned char *s, unsigned char *Data, unsigned long Len) //加解密
{
    int i = 0, j = 0, t = 0;
    unsigned long k = 0;
    unsigned char tmp;
    for(k=0;k

通过分析初始化代码,可以看出初始化代码中,对字符数组s进行了初始化赋值,且赋值分别递增。之后对s进行了256次交换操作。通过识别初始化代码,可以知道rc4算法。

其伪代码表示为:

初始化长度为256的S盒。第一个for循环将0到255的互不重复的元素装入S盒。第二个for循环根据密钥打乱S盒。

  for i from 0 to 255
     S[i] := i
 endfor
 j := 0
 for( i=0 ; i<256 ; i++)
     j := (j + S[i] + key[i mod keylength]) % 256
     swap values of S[i] and S[j]
 endfor

下面i,j是两个指针。每收到一个字节,就进行while循环。通过一定的算法((a),(b))定位S盒中的一个元素,并与输入字节异或,得到k。循环中还改变了S盒((c))。如果输入的是明文,输出的就是密文;如果输入的是密文,输出的就是明文。

 i := 0
 j := 0
 while GeneratingOutput:
     i := (i + 1) mod 256   //a
     j := (j + S[i]) mod 256 //b
     swap values of S[i] and S[j]  //c
     k := inputByte ^ S[(S[i] + S[j]) % 256]
     output K
 endwhile

此算法保证每256次循环中S盒的每个元素至少被交换过一次

MD5

MD5消息摘要算法(英语:MD5 Message-Digest Algorithm),一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(hash value),用于确保信息传输完整一致。MD5由美国密码学家罗纳德·李维斯特(Ronald Linn Rivest)设计,于1992年公开,用以取代MD4算法。这套算法的程序在 RFC 1321 中被加以规范。

伪代码表示为:

/Note: All variables are unsigned 32 bits and wrap modulo 2^32 when calculating
var int[64] r, k

//r specifies the per-round shift amounts
r[ 0..15]:= {7, 12, 17, 22,  7, 12, 17, 22,  7, 12, 17, 22,  7, 12, 17, 22} 
r[16..31]:= {5,  9, 14, 20,  5,  9, 14, 20,  5,  9, 14, 20,  5,  9, 14, 20}
r[32..47]:= {4, 11, 16, 23,  4, 11, 16, 23,  4, 11, 16, 23,  4, 11, 16, 23}
r[48..63]:= {6, 10, 15, 21,  6, 10, 15, 21,  6, 10, 15, 21,  6, 10, 15, 21}

//Use binary integer part of the sines of integers as constants:
for i from 0 to 63
    k[i] := floor(abs(sin(i + 1)) × 2^32)

//Initialize variables:
var int h0 := 0x67452301
var int h1 := 0xEFCDAB89
var int h2 := 0x98BADCFE
var int h3 := 0x10325476

//Pre-processing:
append "1" bit to message
append "0" bits until message length in bits ≡ 448 (mod 512)
append bit length of message as 64-bit little-endian integer to message

//Process the message in successive 512-bit chunks:
for each 512-bit chunk of message
    break chunk into sixteen 32-bit little-endian words w[i], 0 ≤ i ≤ 15

    //Initialize hash value for this chunk:
    var int a := h0
    var int b := h1
    var int c := h2
    var int d := h3

    //Main loop:
    for i from 0 to 63
        if 0 ≤ i ≤ 15 then
            f := (b and c) or ((not b) and d)
            g := i
        else if 16 ≤ i ≤ 31
            f := (d and b) or ((not d) and c)
            g := (5×i + 1) mod 16
        else if 32 ≤ i ≤ 47
            f := b xor c xor d
            g := (3×i + 5) mod 16
        else if 48 ≤ i ≤ 63
            f := c xor (b or (not d))
            g := (7×i) mod 16

        temp := d
        d := c
        c := b
        b := leftrotate((a + f + k[i] + w[g]),r[i]) + b
        a := temp
    Next i
    //Add this chunk's hash to result so far:
    h0 := h0 + a
    h1 := h1 + b 
    h2 := h2 + c
    h3 := h3 + d
End ForEach
var int digest := h0 append h1 append h2 append h3 //(expressed as little-endian)

其鲜明的特征是:

    h0 = 0x67452301;
    h1 = 0xefcdab89;
    h2 = 0x98badcfe;
    h3 = 0x10325476;

迷宫问题

迷宫问题有以下特点:

  • 在内存中布置一张 “地图”
  • 将用户输入限制在少数几个字符范围内.
  • 一般只有一个迷宫入口和一个迷宫出口

布置的地图可以由可显字符 (比如#*)组合而成 (这非常明显, 查看字符串基本就知道这是个迷宫题了.), 也可以单纯用不可显的十六进制值进行表示. 可以将地图直接组成一条非常长的字符串, 或是一行一行分开布置. 如果是一行一行分开布置的话, 因为迷宫一般都会比较大, 所以用于按行(注意, 布置并非按顺序布置, 每行都对应一个具体的行号, 你需要确定行号才能还原迷宫地图) 布置迷宫的函数会明显重复多次.

而被限制的字符通常会是一些方便记忆的组合 (不是也没办法), 比如w/s/a/d, h/j/k/l, l/r/u/d这样的类似组合. 当然各个键具体的操作需要经过分析判断 (像那种只用一条字符串表示迷宫的, 就可以用t键表示向右移动12个字符这样). 对于二维的地图, 一般作者都会设置一个X坐标和一个Y坐标用于保存当前位置. 我们也可以根据这个特点来入手分析.

一般情况下, 迷宫是只有 1 个入口和 1 个出口, 像入口在最左上角(0, 0)位置, 而出口在最右下角(max_X, max_Y)处. 但也有可能是出口在迷宫的正中心, 用一个Y字符表示等等. 解答迷宫题的条件也是需要根据具体情况判断的.

当然迷宫的走法可能不止 1 条, 也有情况是有多条走法, 但是要求某一个走法比如说代价最小. 那么这就可以变相为一个算法问题.

Volga Quals CTF 2014: Reverse 100

接下来我们以这道题进行示例, 这是一道简单的迷宫题. 该题对地图按行乱序布置, 使用的字符是#*.

对应的crackme可以点击此处下载: rev100

对应的idb可以点击此处下载: rev100.i64

我们可以到.rodata段用光标选择所有的地图字符串, 按下shift+E提取所有的地图数据.

但是目前提取到的地图字符串, 从上往下并非是按顺序的, 因此我们需要回到 IDA 生成的伪 C 代码, 获取行号并重新排序组合起来.

最后得到的完整地图如下:

对应的迷宫地图文件可以点击此处下载: maze_array.txt

再来看迷宫移动所需要的字符:

这里我们知道, 可以使用的字符有L/R/U/D, 分别对应于左/右/上/下.

再往下看

通过调试是可以知道, 这里其实是每次在用户输入L/R/U/D后, 先打印一次你的输入, 然后打印对应的X/Y坐标. 而最后的判定成功的条件, 就是当pos_x == 89 && pos_y == 28. 那么我们就可以根据上述信息, 获得走出迷宫的路径.

最后得到的迷宫路径就是

RDDRRRRRRRRRRRRRRRRRRDDDDDDDDDDRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRUUUUUULLLLLLLLLDDRRRRR

虚拟机分析

有关虚拟机分析部分, 我们以一道简单的 crackme 来进行讲解.

对应的crackme可以点击此处下载: FuelVM.exe

对应的keygenme可以点击此处下载: fuelvm_keygen.py

对应的IDA数据库可以点击此处下载: FuelVM.idb

本题作者设计了一个具有多种指令的简单虚拟机. 我们使用 IDA 来进行分析. 并为了方便讲解, 我对反汇编出的一些变量重新进行了命名.

运行程序

我们运行程序 FuelVM.exe. 界面如下所示

在这个界面中, 我们看到右两个输入框, 一个用于输入用户名 Name, 另一个则用于输入密钥 Key. 还有两个按钮, Go 用于提交输入, 而 Exit 则用于退出程序.

获取用户输入

那么我们就可以从这里入手. 程序想获取用户输入, 需要调用的一个 API 是GetDlgItemTextA()

UINT GetDlgItemTextA(
  HWND  hDlg,
  int   nIDDlgItem,
  LPSTR lpString,
  int   cchMax
);

获取的输入字符串会保存在lpString里. 那么我们就可以打开 IDA 查找有交叉引用GetDlgItemTextA()的地方.

.text:00401142                 push    0Ch             ; cchMax
.text:00401144                 push    offset inputName ; lpString
.text:00401149                 push    3F8h            ; nIDDlgItem
.text:0040114E                 push    [ebp+hWnd]      ; hDlg
.text:00401151                 call    GetDlgItemTextA
.text:00401156                 push    0Ch             ; cchMax
.text:00401158                 push    offset inputKey ; lpString
.text:0040115D                 push    3F9h            ; nIDDlgItem
.text:00401162                 push    [ebp+hWnd]      ; hDlg
.text:00401165                 call    GetDlgItemTextA
.text:0040116A                 mov     var_a, 0
.text:00401171                 call    process_input
.text:00401176                 jmp     short locExit

如上, IDA 只有这里调用过GetDlgItemTextA并且调用了两次分别获取inputNameinputKey. 随后初始化了一个变量为 0, 因为还不明白这个变量的作用, 因此先重命名为var_a. 之后进行了一次函数调用并 jmp 跳转. 因为 jmp 跳转位置的代码是一些退出程序的代码, 因此我们可以断定上面的这个 call, 是在调用处理用户输入的函数. 因此将 jmp 的位置重命名为locExit, 函数则重命名为process_input.

处理用户输入

我们进入process_input函数, 该函数仅仅对输入字符串进行了很简单的处理.

  result = strlength((int)inputName);
  if ( v1 >= 7 )                                // v1 = length of inputName
  {
    *(_DWORD *)&lenOfName = v1;
    result = strlength((int)inputKey);
    if ( v2 >= 7 )                              // v2 = length of inputKey
    {
      i = 0;
      do
      {
        inputName[i] ^= i;
        ++i;
      }
      while ( i <= *(_DWORD *)&lenOfName );
      unk_4031CE = i;
      dword_4031C8 = dword_4035FF;
      initVM();
      initVM();
      __debugbreak();
      JUMPOUT(*(_DWORD *)&word_4012CE);
    }
  }
  return result;

首先是这个strlength()函数. 函数使用cld; repne scasb; not ecx; dec ecx来计算字符串长度并将结果保存在ecx里. 是汇编基础知识就不多介绍. 所以我们将该函数重命名为strlength

.text:004011C2 arg_0           = dword ptr  8
.text:004011C2
.text:004011C2                 push    ebp
.text:004011C3                 mov     ebp, esp
.text:004011C5                 mov     edi, [ebp+arg_0]
.text:004011C8                 sub     ecx, ecx
.text:004011CA                 sub     al, al
.text:004011CC                 not     ecx
.text:004011CE                 cld
.text:004011CF                 repne scasb
.text:004011D1                 not     ecx
.text:004011D3                 dec     ecx
.text:004011D4                 leave
.text:004011D5                 retn    4
.text:004011D5 strlength       endp

而在 IDA 生成的伪 C 代码处有v1v2, 我对其进行了注解, 可以看汇编, 里面是使用ecx7进行比较, 而ecx是字符串的长度, 于是我们可以知道, 这里对输入的要求是: inputName 和 inputKey 的长度均不少于 7

inputNameinputKey长度均不少于 7 时, 那么就可以对输入进行简单的变换. 以下是一个循环

      i = 0;
      do
      {
        inputName[i] ^= i;
        ++i;
      }
      while ( i <= *(_DWORD *)&lenOfName );

对应的 python 代码即

def obfuscate(username):
    s = ""
    for i in range(len(username)):
        s += chr(ord(username[i]) ^ i)
    return s

函数之后对一些变量进行了赋值 (这些并不重要, 就忽略不讲了.)

注册 SEH

.text:004012B5                 push    offset seh_handler
.text:004012BA                 push    large dword ptr fs:0
.text:004012C1                 mov     large fs:0, esp
.text:004012C8                 call    initVM
.text:004012CD                 int     3               ; Trap to Debugger

initVM完成的是一些虚拟机启动前的初始化工作 (其实就是对一些寄存器和相关的部分赋初值), 我们之后来讨论. 这里我们关注的是 SEH 部分. 这里注册了一个 SEH 句柄, 异常处理函数我重命名为seh_handler, 并之后使用int 3手动触发异常. 而在seh_handler位置, IDA 并未正确识别出对应的代码

.text:004012D7 seh_handler     db 64h                  ; DATA XREF: process_input+7Do
.text:004012D8                 dd 58Fh, 0C4830000h, 13066804h, 0FF640040h, 35h, 25896400h
.text:004012D8                 dd 0
.text:004012F4                 dd 1B8h, 0F7C93300h, 0F7C033F1h, 0FFC483E1h, 8F64FDEBh
.text:004012F4                 dd 5, 4C48300h, 40133068h, 35FF6400h, 0
.text:0040131C                 dd 258964h, 33000000h, 33198BC9h, 83E1F7C0h, 0FDEBFFC4h
.text:0040131C                 dd 58F64h, 83000000h, 5E6804C4h, 64004013h, 35FFh, 89640000h
.text:0040131C                 dd 25h, 0C033CC00h, 0C483E1F7h, 83FDEBFFh, 4035FF05h, 0D8B0200h
.text:0040131C                 dd 4035FFh, 3000B1FFh, 58F0040h, 4031C8h, 31C83D80h, 750A0040h
.text:0040131C                 dd 0B1FF4176h, 403000h, 31C8058Fh, 3D800040h, 4031C8h

我们可以点击相应位置按下c键, 将这些数据转换成代码进行识别. (我们需要按下多次 c 键进行转换), 得到如下代码.

如下, 在seh_handler位置, 又用类似的方法注册了一个位于401306h的异常处理函数, 并通过xor ecx,ecx; div ecx手动触发了一个除0异常. 而在loc_401301位置, 这是一个反调试技巧, jmp loc_401301+2会使得EIP转向一条指令中间, 使得无法继续调试. 所以我们可以将00401301~00401306部分的代码nop掉, 然后在00401306位置创建一个新函数seh_handler2

seh_handler:                            ; DATA XREF: process_input+7Do
.text:004012D7                 pop     large dword ptr fs:0
.text:004012DE                 add     esp, 4
.text:004012E1                 push    401306h
.text:004012E6                 push    large dword ptr fs:0
.text:004012ED                 mov     large fs:0, esp
.text:004012F4                 mov     eax, 1
.text:004012F9                 xor     ecx, ecx
.text:004012FB                 div     ecx
.text:004012FD                 xor     eax, eax
.text:004012FF                 mul     ecx
.text:00401301
.text:00401301 loc_401301:                             ; CODE XREF: .text:00401304j
.text:00401301                 add     esp, 0FFFFFFFFh
.text:00401304                 jmp     short near ptr loc_401301+2
.text:00401306 ; ---------------------------------------------------------------------------
.text:00401306                 pop     large dword ptr fs:0
.text:0040130D                 add     esp, 4
.text:00401310                 push    401330h
.text:00401315                 push    large dword ptr fs:0
.text:0040131C                 mov     large fs:0, esp
.text:00401323                 xor     ecx, ecx
.text:00401325                 mov     ebx, [ecx]
.text:00401327                 xor     eax, eax
.text:00401329                 mul     ecx

类似的, 还有401330h重命名为seh_handler3, 而40135Eh是最后一个注册的异常处理函数, 我们可以推测这才是虚拟机真正的 main 函数, 因此我们将40135Eh重命名为vm_main. (有关 SEH 和反调试的部分, 可以推荐大家自己去动态调试一番弄清楚)

恢复堆栈平衡

我们创建了一个vm_main函数 (重命名后还需要创建函数, IDA 才能识别), 然后按下F5提示失败, 失败的原因则是由于堆栈不平衡导致的. 因此我们可以点击 IDA 菜单项Options->General在右侧勾选stack pointer. 这样就会显示出对应的栈指针.

.text:004017F2 000                 jmp     vm_main
.text:004017F7     ; ---------------------------------------------------------------------------
.text:004017F7 000                 push    0               ; uType
.text:004017F9 004                 push    offset aError   ; "Error"
.text:004017FE 008                 push    offset Text     ; "The key is wrong."
.text:00401803 00C                 push    0               ; hWnd
.text:00401805 010                 call    MessageBoxA
.text:0040180A
.text:0040180A     locret_40180A:                          ; CODE XREF: vm_main+492j
.text:0040180A 000                 leave
.text:0040180B -04                 leave
.text:0040180C -08                 leave
.text:0040180D -0C                 leave
.text:0040180E -10                 leave
.text:0040180F -14                 leave
.text:00401810 -18                 leave
.text:00401811 -1C                 retn
.text:00401811     vm_main         endp ; sp-analysis failed

我们来到最下显示不平衡的位置. 最上的jmp vm_main表明虚拟机内在执行一个循环. 而MessageBoxA的调用则是显示最后弹出的错误信息. 而在locret_40180A位置处, 经过多次 leave 堆栈严重不平衡, 因此我们需要手动恢复堆栈平衡.

这里也很简单, 在0040180A位置已经堆栈平衡了 (000), 因此我们只需要将这一句leave修改为retn就可以了. 如下这样

.text:0040180A     locret_40180A:                          ; CODE XREF: vm_main+492j
.text:0040180A 000                 retn
.text:0040180B     ; ---------------------------------------------------------------------------
.text:0040180B 004                 leave
.text:0040180C 004                 leave
.text:0040180D 004                 leave

然后你就可以发现vm_main可以 F5 生成伪 C 代码了.

虚拟机指令分析

说实话, 虚拟机的分析部分是一个比较枯燥的还原过程, 你需要比对各个小部分的操作来判断这是一个怎样的指令, 使用的是哪些寄存器. 像这个 crackme 中, vm 进行的是一个取指-译码-执行的循环. 译码过程可给予我们的信息最多, 不同的指令都会在这里, 根据它们各自的opcode, 使用if-else if-else分支进行区分. 实际的还原过程并不复杂, 但有可能会因为虚拟机实现的指令数量而显得有些乏味.

最后分析出的结果如下:

opcode value
push 0x0a
pop 0x0b
mov 0x0c
cmp 0x0d
inc 0x0e
dec 0x0f
and 0x1b
or 0x1c
xor 0x1d
check 0xff

我们再来看分析后的initVM函数

int initVM()
{
  int result; // eax@1

  r1 = 0;
  r2 = 0;
  r3 = 0;
  result = (unsigned __int8)inputName[(unsigned __int8)cur_index];
  r4 = (unsigned __int8)inputName[(unsigned __int8)cur_index];
  vm_sp = 0x32;
  vm_pc = 0;
  vm_flags_zf = 0;
  vm_flags_sf = 0;
  ++cur_index;
  return result;
}

这里有 4 个通用寄存器 (r1/r2/r3/r4), 1 个sp指针和 1 个pc指针, 标志zfsf. 先前我们不知道的var_a也被重命名为cur_index, 指向的是inputName当前正在处理的字符索引.

对于 VM 实现的多个指令我们就不再多说, 重点来看下check部分的操作.

int __fastcall check(int a1)
{
  char v1; // al@1
  int result; // eax@4

  v1 = r1;
  if ( (unsigned __int8)r1 < 0x21u )
    v1 = r1 + 0x21;
  LOBYTE(a1) = cur_index;
  if ( v1 == inputKey[a1] )
  {
    if ( (unsigned __int8)cur_index >= (unsigned __int8)lenOfName )
      result = MessageBoxA(0, aGoodJobNowWrit, Caption, 0);
    else
      result = initVM();
  }
  else
  {
    result = MessageBoxA(0, Text, Caption, 0);
  }
  return result;
}

如果r1中的值跟inputKey[cur_index]相等, 那么会继续判断是否已经检查完了整个inputName, 如果没有出错且比对结束, 那么就会弹出Good job! Now write a keygen.的消息框. 否则会继续initVM进入下一轮循环.(出错了当然是弹出消息框提示错误了.)

cur_index会在initVM中自增 1, 那么还记得之前在process_input里有执行 2 次initVM吗. 因为有执行 2 次initVM, 所以我们的inputKey的前 2 位可以是任意字符.

      unk_4031CE = i;
      opcode = vm_pc;
      initVM();
      initVM();
      __debugbreak();
      JUMPOUT(*(_DWORD *)&word_4012CE);

故而我们分析完了整个虚拟机, 便可以开始着手编写Keygen.

对应的keygenme可以点击此处下载: fuelvm_keygen.py

$ python2 fuelvm_keygen.py ctf-wiki
[*] Password for user 'ctf-wiki' is: 4mRC*TKJI

对应的IDA数据库可以点击此处下载: FuelVM.idb

Unicorn Engine 简介

什么是 Unicorn 引擎

Unicorn 是一个轻量级, 多平台, 多架构的 CPU 模拟器框架. 我们可以更好地关注 CPU 操作, 忽略机器设备的差异. 想象一下, 我们可以将其应用于这些情景: 比如我们单纯只是需要模拟代码的执行而非需要一个真的 CPU 去完成那些操作, 又或者想要更安全地分析恶意代码, 检测病毒特征, 或者想要在逆向过程中验证某些代码的含义. 使用 CPU 模拟器可以很好地帮助我们提供便捷.

它的亮点 (这也归功于 Unicorn 是基于 qemu 而开发的) 有:

  • 支持多种架构: Arm, Arm64 (Armv8), M68K, Mips, Sparc, & X86 (include X86_64).
  • 对 Windows 和 nix 系统 (已确认包含 Mac OSX, Linux, BSD & Solaris) 的原生支持
  • 具有平台独立且简洁易于使用的 API
  • 使用 JIT 编译技术, 性能表现优异

你可以在 Black Hat USA 2015 获悉有关 Unicorn 引擎的更多技术细节. Github 项目主页: unicorn

尽管它不同寻常, 但它无法模拟整个程序或系统, 也不支持系统调用. 你需要手动映射内存并写入数据进去, 随后你才能从指定地址开始模拟.

应用的情景

什么时候能够用到 Unicorn 引擎呢?

  • 你可以调用恶意软件中一些有趣的函数, 而不用创建一个有害的进程.
  • 用于 CTF 竞赛
  • 用于模糊测试
  • 用于 gdb 插件, 基于代码模拟执行的插件
  • 模拟执行一些混淆代码

如何安装

安装 Unicorn 最简单的方式就是使用 pip 安装, 只要在命令行中运行以下命令即可 (这是适合于喜爱用 python 的用户的安装方法, 对于那些想要使用 C 的用户, 则需要去官网查看文档编译源码包):

pip install unicorn

但如果你想用源代码进行本地编译的话, 你需要在下载页面中下载源代码包, 然后可以按照以下命令执行:

  • *nix 平台用户
$ cd bindings/python
$ sudo make install
  • Windows 平台用户
cd bindings/python
python setup.py install

对于 Windows, 在执行完上述命令后, 还需要将下载页面的Windows core engine的所有 dll 文件复制到C:\locationtopython\Lib\site-packages\unicorn位置处.

使用 unicorn 的快速指南

我们将会展示如何使用 python 调用 unicorn 的 api 以及它是如何轻易地模拟二进制代码. 当然这里用的 api 仅是一小部分, 但对于入门已经足够了.

 1 from __future__ import print_function
 2 from unicorn import *
 3 from unicorn.x86_const import *
 4 
 5 # code to be emulated
 6 X86_CODE32 = b"\x41\x4a" # INC ecx; DEC edx
 7 
 8 # memory address where emulation starts
 9 ADDRESS = 0x1000000
10 
11 print("Emulate i386 code")
12 try:
13     # Initialize emulator in X86-32bit mode
14     mu = Uc(UC_ARCH_X86, UC_MODE_32)
15 
16     # map 2MB memory for this emulation
17     mu.mem_map(ADDRESS, 2 * 1024 * 1024)
18 
19     # write machine code to be emulated to memory
20     mu.mem_write(ADDRESS, X86_CODE32)
21 
22     # initialize machine registers
23     mu.reg_write(UC_X86_REG_ECX, 0x1234)
24     mu.reg_write(UC_X86_REG_EDX, 0x7890)
25 
26     # emulate code in infinite time & unlimited instructions
27     mu.emu_start(ADDRESS, ADDRESS + len(X86_CODE32))
28 
29     # now print out some registers
30     print("Emulation done. Below is the CPU context")
31 
32     r_ecx = mu.reg_read(UC_X86_REG_ECX)
33     r_edx = mu.reg_read(UC_X86_REG_EDX)
34     print(">>> ECX = 0x%x" %r_ecx)
35     print(">>> EDX = 0x%x" %r_edx)
36 
37 except UcError as e:
38     print("ERROR: %s" % e)

运行结果如下:

$ python test1.py 
Emulate i386 code
Emulation done. Below is the CPU context
>>> ECX = 0x1235
>>> EDX = 0x788f

样例里的注释已经非常直观, 但我们还是对每一行代码做出解释:

  • 行号 2~3: 在使用 Unicorn 前导入unicorn模块. 样例中使用了一些 x86 寄存器常量, 所以也需要导入unicorn.x86_const模块
  • 行号 6: 这是我们需要模拟的二进制机器码, 使用十六进制表示, 代表的汇编指令是: “INC ecx” 和 “DEC edx”.
  • 行号 9: 我们将模拟执行上述指令的所在虚拟地址
  • 行号 14: 使用Uc类初始化 Unicorn, 该类接受 2 个参数: 硬件架构和硬件位数 (模式). 在样例中我们需要模拟执行 x86 架构的 32 位代码, 我 们使用变量mu来接受返回值.
  • 行号 17: 使用mem_map方法根据在行号 9 处声明的地址, 映射 2MB 用于模拟执行的内存空间. 所有进程中的 CPU 操作都应该只访问该内存区域. 映射的内存具有默认的读, 写和执行权限.
  • 行号 20: 将需要模拟执行的代码写入我们刚刚映射的内存中. mem_write方法接受 2 个参数: 要写入的内存地址和需要写入内存的代码.
  • 行号 23~24: 使用reg_write方法设置ECXEDX寄存器的值
  • 行号 27: 使用emu_start方法开始模拟执行, 该 API 接受 4 个参数: 要模拟执行的代码地址, 模拟执行停止的内存地址 (这里是 X86_CODE32的最后 1 字节处), 模拟执行的时间和需要执行的指令数目. 如果我们像样例一样忽略后两个参数, Unicorn 将会默认以无穷时间和无穷指令数目的条件来模拟执行代码.
  • 行号 32~35: 打印输出ECXEDX寄存器的值. 我们使用函数reg_read来读取寄存器的值.

要想查看更多的 python 示例, 可以查看文件夹 bindings/python 下的代码. 而 C 的示例则可以查看 sample 文件夹下的代码.

LD_PRELOAD

原理

正常情况下, Linux 动态加载器ld-linux(见 man 手册 ld-linux(8)) 会搜寻并装载程序所需的共享链接库文件, 而LD_PRELOAD是一个可选的环境变量, 包含一个或多个指向共享链接库文件的路径. 加载器会先于 C 语言运行库之前载入LD_PRELOAD指定的共享链接库,也就是所谓的预装载 (preload)。

预装载意味着会它的函数会比其他库文件中的同名函数先于调用, 也就使得库函数可以被阻截或替换掉. 多个共享链接库文件的路径可以用冒号空格进行区分. 显然不会受到LD_PRELOAD影响的也就只有那些静态链接的程序了.

当然为避免用于恶意攻击, 在ruid != euid的情况下加载器是不会使用LD_PRELOAD进行预装载的.

更多阅读: https://blog.fpmurphy.com/2012/09/all-about-ld_preload.html#ixzz569cbyze4

例题

下面以 2014 年Hack In The Box Amsterdam: Bin 100为例. 题目下载链接: hitb_bin100.elf

这是一个 64 位的 ELF 文件. 运行结果如下图所示:

程序似乎在一直打印着一些句子. 并且没有停止下来的迹象. 我们就用 IDA 打开来看一下. 首先按下Shift+F12查找字符串.

显然, 除开一直在打印的句子外, 我们发现了一些有趣的字符串:

.rodata:0000000000400A53 00000006 C KEY:
.rodata:0000000000400A5F 0000001F C OK YOU WIN. HERE'S YOUR FLAG:

我们根据OK YOU WIN. HERE'S YOUR FLAG:的交叉引用来到关键代码处 (我删去了一些不必要的代码).

int __cdecl main(int argc, const char **argv, const char **envp)
{
  qmemcpy(v23, &unk_400A7E, sizeof(v23));
  v3 = v22;
  for ( i = 9LL; i; --i )
  {
    *(_DWORD *)v3 = 0;
    v3 += 4;
  }
  v20 = 0x31337;
  v21 = time(0LL);
  do
  {
    v11 = 0LL;
    do
    {
      v5 = 0LL;
      v6 = time(0LL);
      srand(233811181 - v21 + v6); // 初始化随机数种子
      v7 = v22[v11];
      v22[v11] = rand() ^ v7;   // 伪随机数
      v8 = (&funny)[8 * v11];
      while ( v5 < strlen(v8) )
      {
        v9 = v8[v5];
        if ( (_BYTE)v9 == 105 )
        {
          v24[(signed int)v5] = 105;
        }
        else
        {
          if ( (_DWORD)v5 && v8[v5 - 1] != 32 )
            v10 = __ctype_toupper_loc();    // 大写
          else
            v10 = __ctype_tolower_loc();    // 小写
          v24[(signed int)v5] = (*v10)[v9];
        }
        ++v5;
      }
      v24[(signed int)v5] = 0;
      ++v11;
      __printf_chk(1LL, " 鈾%80s 鈾玕n", v24); // 乱码的其实是一个音符
      sleep(1u);
    }
    while ( v11 != 36 );
    --v20;
  }
  while ( v20 );
  v13 = v22;    // key存储在v22数组内
  __printf_chk(1LL, "KEY: ", v12);
  do
  {
    v14 = (unsigned __int8)*v13++;
    __printf_chk(1LL, "%02x ", v14); // 输出key
  }
  while ( v13 != v23 );
  v15 = 0LL;
  putchar(10);
  __printf_chk(1LL, "OK YOU WIN. HERE'S YOUR FLAG: ", v16);
  do
  {
    v17 = v23[v15] ^ v22[v15];  // 跟key的值有异或
    ++v15;
    putchar(v17);   // 输出flag
  }
  while ( v15 != 36 );
  putchar(10);      // 输出换行
  result = 0;
  return result;
}

整个的代码流程主要就是在不断地循环输出funny里的句子, 满足循环条件后输出key, 并用key进行异或得到flag的值.

但我们可以发现, 整个循环的次数相对来说是比较少的. 所以我们可以采用一些方法, 让循环进行得更快一些. 比如说我手动 patch 一下, 不让程序输出字符串 (实际上printf的耗时是相当多的), 其次就是使用LD_PRELOAD使得程序的sleep()失效. 可以很明显地节省时间.

手动 patch 的过程比较简单. 我们可以找到代码位置, 然后用一些十六进制编辑器进行修改. 当然我们也可以使用IDA来进行 patch 工作.

.text:00000000004007B7                 call    ___printf_chk
.text:00000000004007BC                 xor     eax, eax

将光标点在call ___printf_chk上, 然后选择菜单Edit->Patch Program->Assemble(当然你可以使用其他 patch 方式. 效果都一样). 然后将其修改为nop(0x90), 如下图所示

4007B74007BD之间的汇编代码全部修改为nop即可. 然后选择菜单Edit->Patch Program->Apply patches to input file. 当然最好做一个备份 (即勾选Create a backup), 然后点击 OK 即可 (我重命名为了patched.elf, 下载链接: patched.elf).

现在进入LD_PRELOAD部分. 这里我们简单编写一下 c 代码, 下载链接: time.c

static int t = 0x31337;

void sleep(int sec) {
    t += sec;
}

int time() {
    return t;
}

然后使用命令gcc --shared time.c -o time.so生成动态链接文件. 当然也给出了下载链接: time.so

然后打开 linux 终端, 运行命令: LD_PRELOAD=./time.so ./patched.elf

过一会, 你就能听到 CPU 疯狂运转的声音, 然后很快就出来了 flag.

False Disassembly

对于一些常用的反汇编器, 如objdump, 或是基于objdump的反汇编器项目. 都存在一些反汇编的缺陷. 有一些方式可以让objdump反汇编出的代码, 并没有那么的准确.

跳到一条指令中间

最简单的方法就是利用jmp跳转到某一条指令中间执行, 也就是说真实的代码是从某条指令 “之中” 开始的, 但在反汇编时由于是针对整条指令而不能列出真正被运行的汇编指令代码.

说起来好像很拗口, 很难懂, 我们来看一个示例吧, 给出以下的汇编代码.

start:
    jmp label+1
label:  
    DB 0x90
    mov eax, 0xf001

这段代码label所在的第一条指令是DB 0x90. 我们来看看objdump对这段代码反汇编的结果:

08048080 <start>:
  8048080:  e9 01 00 00 00  jmp 8048086 <label+0x1>
08048085 <label>:
  8048085:  90      nop
  8048086:  b8 01 f0 00 00  mov eax,0xf001

看起来也没什么问题, DB 0x90被准确地反汇编成90 nop.

但是如果我们将nop指令修改为 1 字节以上的指令, 那么 objdump 就不会跟随我们的 jump 并正确的反汇编, 而是线性地从上往下继续汇编 (线性扫描算法). 比如我将DB 0x90改成了DB 0xE9, 来看看 objdump 再次反汇编的结果:

08048080 <start>:
  8048080:  e9 01 00 00 00  jmp 8048086 <label+0x1>
08048085 <label>:
  8048085:  e9 b8 01 f0 00  jmp 8f48242 <__bss_start+0xeff1b6>

对比之前的反汇编结果, 你很明显地看出来是什么情况了吧. DB 0xE9单纯只是一个数据, 也不会被执行, 而反汇编出的结果, 却将其视作一个指令, 之后的结果也因此而改变了.

objdump忽略了jmp的目的地址处的代码并直接汇编 jmp 后的指令, 这样我们真正的代码也就被很好地 “隐藏” 了起来

解决方法

该如何解决这个问题呢? 看起来最直接的方法就是将这个无用的0xE9用十六进制编辑器手动替换成0x90. 但是如果程序有进行文件校验, 计算 checksum 值, 那么这个方法就行不通了.

所以更好的解决办法是使用如 IDA 或类似有做控制流分析的反汇编器, 对于同样有问题的程序. 反汇编结果可能如下:

  ---- section .text ----:
08048080    E9 01 00 00 00  jmp Label_08048086
                                                ; (08048086)
                                                ; (near + 0x1)
08048085    DB E9

Label_08048086:
08048086    B8 01 F0 00 00  mov eax, 0xF001
                                                ; xref ( 08048080 ) 

反汇编结果看上去还行

运行时计算跳转地址

这种方法, 甚至可以对抗分析控制流的反汇编器. 我们可以看一个示例代码, 更好地理解:

; ----------------------------------------------------------------------------
    call earth+1
Return:
                    ; x instructions or random bytes here               x byte(s)
earth:              ; earth = Return + x
    xor eax, eax    ; align disassembly, using single byte opcode       1 byte
    pop eax         ; start of function: get return address ( Return )  1 byte
                    ; y instructions or random bytes here               y byte(s)
    add eax, x+2+y+2+1+1+z ; x+y+z+6                                    2 bytes
    push eax        ;                                                   1 byte
    ret             ;                                                   1 byte
                    ; z instructions or random bytes here               z byte(s)
; Code:
                    ; !! Code Continues Here !!
; ----------------------------------------------------------------------------

程序通过call+pop来获取调用函数当时保存到栈上的返回地址, 其实就是调用函数前的EIP. 然后在函数返回处塞入垃圾数据. 但实际上在函数运行时已经将返回地址修改到了 Code 处. 因此earth函数返回会跳转到Code处继续运行,而不是Return处继续运行.

来看一个简易的 demo

; ----------------------------------------------------------------------------
    call earth+1
earth:  
    DB 0xE9             ; 1 <--- pushed return address,
                        ; E9 is opcode for jmp to disalign disas-
    ; sembly
    pop eax             ; 1 hidden
    nop                 ; 1
    add eax, 9          ; 2 hidden
    push eax            ; 1 hidden
    ret                 ; 1 hidden
    DB 0xE9             ; 1 opcode for jmp to misalign disassembly
Code:                   ; code continues here <--- pushed return address + 9
    nop
    nop
    nop
    ret
; ----------------------------------------------------------------------------

如果是使用 objdump 进行反汇编, 光是call earth+1就会出现问题, 如下:

00000000 <earth-0x5>:
  0:    e8 01 00 00 00  call 6 <earth+0x1>
00000005 <earth>:
  5:    e9 58 90 05 09  jmp 9059062 <earth+0x905905d>
  a:    00 00           add %al,(%eax)
  c:    00 50 c3        add %dl,0xffffffc3(%eax)
  f:    e9 90 90 90 c3  jmp c39090a4 <earth+0xc390909f>

来看一下ida的情况

text:08000000   ; Segment permissions: Read/Execute
.text:08000000   _text  segment para public 'CODE' use32
.text:08000000      assume cs:_text
.text:08000000      ;org 8000000h
.text:08000000      assume  es:nothing, ss:nothing, ds:_text,
.text:08000000          fs:nothing, gs:nothing
.text:08000000      dd 1E8h
.text:08000004 ; -------------------------------------------------------------
.text:08000004      add cl, ch
.text:08000006      pop eax
.text:08000007      nop
.text:08000008      add eax, 9
.text:0800000D      push eax
.text:0800000E      retn
.text:0800000E ; -------------------------------------------------------------
.text:0800000F      dd 909090E9h
.text:08000013 ; -------------------------------------------------------------
.text:08000013      retn
.text:08000013 _text        ends
.text:08000013
.text:08000013
.text:08000013      end

我们在最后的 3 个nop, 都被很好的隐藏起来. 不仅如此, 我们计算EIP的过程也被完美的隐藏了起来. 实际上整个反汇编的代码已经跟实际代码完全不同.

如何解决这项问题? 实际上并没有能够保证100%准确反汇编的工具, 当反汇编器做到代码模拟执行的时候也许能做到完全正确的汇编.

在现实情况, 这并不是特别大的问题. 因为针对交互性反汇编器. 你是可以指定代码起始的位置. 而且当调试的时候, 也能很好的看明白程序实际跳转的地址.

所以此时我们除开需要静态分析, 也需要动态调试.

Reference: Beginners Guide to Basic Linux Anti Anti Debugging Techniques

Detecting Breakpoints

gdb 通过替换目标地址的字节为0xcc来实现断点, 这里给出一个简单的检测int 3断点的示例:

void foo() {
    printf("Hello\n");
}
int main() {
    if ((*(volatile unsigned *)((unsigned)foo) & 0xff) == 0xcc) {
        printf("BREAKPOINT\n");
        exit(1);
    }
    foo();
}

正常运行程序会输出 Hello, 但是如果之前有在foo函数这里设置cc断点并运行, gdb 则无法断下, 并会输出BREAKPOINT.

# gdb ./x
gdb> bp foo
Breakpoint 1 at 0x804838c
gdb> run
BREAKPOINT
Program exited with code 01.

这个要绕过也很简单, 那就是需要阅读汇编代码并注意设置断点不要在foo函数入口处. 实际情况就要看检测断点的位置是哪里.

这种监视断点的反调试技术, 关键不在于如何绕过它, 而是在于如何检测它. 在这个示例中可以很轻松的发现, 程序也有打印出相应的信息. 在实际情况中, 程序不会输出任何信息, 断点也无法轻易地断下. 我们可以使用perl脚本过滤反汇编代码中有关0xcc的代码出来进行检查.

我们可以使用 perl 脚本过滤反汇编代码中有关 0xcc 的代码出来进行检查

#!/usr/bin/perl
while(<>)
{
    if($_ =~ m/([0-9a-f][4]:\s*[0-9a-f \t]*.*0xcc)/ ){ print; }
}

显示结果

# objdump -M intel -d xxx | ./antibp.pl
      80483be: 3d cc 00 00 00 cmp eax,0xcc

检测到后, 既可以将 0xcc 修改成 0x00 或 0x90, 也可以做任何你想做的操作.

改变 0xcc 也同样可能带来问题, 就如上篇介绍一样, 程序如果有进行文件校验, 那么我们的改变是会被检测到的. 可能的情况下, 程序也不只是对函数入口点进行检测, 也会在一个循环里对整个函数进行检测.

因此你也可以用十六进制编辑器手动放置一个ICEBP(0xF1)字节到需要断下的位置 (而非int 3). 因为ICEBP也一样能让 gdb 断下来.

Reference: Beginners Guide to Basic Linux Anti Anti Debugging Techniques

Detecting debugging

检测调试器的方法很多, 比如检测进程名之类. 这里我们介绍一种方法, 就是通过检测一些函数的调用情况来分析程序当前是否处于被调试状态

int main()
{
    if (ptrace(PTRACE_TRACEME, 0, 1, 0) < 0) {
        printf("DEBUGGING... Bye\n");
        return 1;
    }
    printf("Hello\n");
    return 0;
}

一个进程只能被一个进程 ptrace, 如果你自己调用 ptrace, 那么其它程序就无法通过 ptrace 调试或向你的程序注入代码.

如果程序当前被 gdb 调试, 那么 ptrace 函数就会返回错误, 也就侧面表明了检测到了调试器的存在.

绕过方法 1

显然 ptrace 只能作用于使用 ptrace 的调试器, 我们可以用不使用 ptrace 的调试器.

我们也可以通过打补丁的方式将 ptrace 函数擦除, 更简单就是将 ptrace 的调用代码或是之后的校验给擦除了.

如果可执行文件 (实际情况下不太可能) 在编译时并没有启用 - s 选项(-s 选项能移除所有的符号表信息和重定位信息), 那么情况会变得简单很多. 我们从这个简单的情况来分析

# objdump -t test_debug | grep ptrace
080482c0    F *UND*     00000075    ptrace@@GLIBC_2.0

ptrace 在0x080482c0位置被调用

# objdump -d -M intel test_debug |grep 80482c0
80482c0:    ff 25 04 96 04 08   jmp ds:0x8049604
80483d4:    e8 e7 fe ff ff  call 80482c0 <_init+0x28>

那要是有启用 - s 选项, 该怎么处理呢? 这时我们需要使用 gdb

# gdb test_debug
gdb> bp ptrace
Breakpoint 1 at 0x80482c0
gdb> run
Breakpoint 1 at 0x400e02f0
......
0x400e02f0 <ptrace>: push %ebp
0x400e02f1 <ptrace+1>: mov %esp,%ebp
0x400e02f3 <ptrace+3>: sub $0x10,%esp
0x400e02f6 <ptrace+6>: mov %edi,0xfffffffc(%ebp)
0x400e02f9 <ptrace+9>: mov 0x8(%ebp),%edi
0x400e02fc <ptrace+12>: mov 0xc(%ebp),%ecx
------------------------------------------------------------------------------
Breakpoint 1, 0x400e02f0 in ptrace () from /lib/tls/libc.so.6

我们简单地断在了 ptrace 处, 现在输入 finish 执行到当前函数返回, 回到 main 函数里

# gdb test_debug
gdb> finish
00x80483d9 <main+29>:   add $0x10,%esp
0x80483dc   <main+32>:  test %eax,%eax
0x80483de   <main+34>:  jns 0x80483fa <main+62>
0x80483e0   <main+36>:  sub $0xc,%esp
0x80483e3   <main+39>:  push $0x80484e8
0x80483e8   <main+44>:  call 0x80482e0
------------------------------------------------------------------------------
0x080483d9 in main ()

将函数返回结果 eax 修改为正确的返回结果, 就可以了

gdb> set $eax=0
gdb> c
everything ok
Program exited with code 016.
_______________________________________________________________________________
No registers.
gdb>

绕过方法 2

方法 2 就是编写自己的 ptrace 函数

如前几篇所述, LD_PRELOAD环境变量可以将可执行文件指向我们自己的 ptrace 函数.

我们写一个 ptrace 函数并生成目标文件

// -- ptrace.c --
// gcc -shared ptrace.c -o ptrace.so
int ptrace(int i, int j, int k, int l)
{
    printf(" PTRACE CALLED!\n");
}

我们接下来就可以通过设置环境变量 LD_PRELOAD 来使用我们自己的 ptrace 函数, 当然这里是可以在 gdb 中进行设置

gdb> set environment LD_PRELOAD ./ptrace.so
gdb> run
PTRACE CALLED!
Hello World!
Program exited with code 015.
gdb>

可以看到程序无法检测到调试器了.

Reference: Beginners Guide to Basic Linux Anti Anti Debugging Techniques


脱壳技术


保护壳简介

认识壳是什么

是在一些计算机软件里也有一段专门负责保护软件不被非法修改或反编译的程序。

它们一般都是先于程序运行,拿到控制权,然后完成它们保护软件的任务。

由于这段程序和自然界的壳在功能上有很多相同的地方,基于命名的规则,就把这样的程序称为 了。

壳的分类

我们通常将 分为两类,一类是压缩壳,另一类是加密壳。

压缩壳

压缩壳早在 DOS 时代就已经出现了,但是当时因为计算能力有限,解压开销过大,并没有得到广泛的运用。

使用压缩壳可以帮助缩减 PE 文件的大小,隐藏了 PE 文件内部代码和资源,便于网络传输和保存。

通常压缩壳有两类用途,一种只是单纯用于压缩普通 PE 文件的压缩壳,而另一种则会对源文件进行较大变形,严重破坏 PE 文件头,经常用于压缩恶意程序。

常见的压缩壳有:Upx、ASpack、PECompat

加密壳

加密壳或称保护壳,应用有多种防止代码逆向分析的技术,它最主要的功能是保护 PE 免受代码逆向分析。

由于加密壳的主要目的不再是压缩文件资源,所以加密壳保护的 PE 程序通常比原文件大得多。

目前加密壳大量用于对安全性要求高,对破解敏感的应用程序,同时也有恶意程序用于避免(降低)杀毒软件的检测查杀。

常见的加密壳有:ASProtector、Armadillo、EXECryptor、Themida、VMProtect

壳的加载过程

保存入口参数

  1. 加壳程序初始化时保存各寄存器的值
  2. 外壳执行完毕,恢复各寄存器值
  3. 最后再跳到原程序执行

通常用 pushad / popadpushfd / popfd 指令对来保存和恢复现场环境

获取所需函数 API

  1. 一般壳的输入表中只有 GetProcAddressGetModuleHandleLoadLibrary 这几个 API 函数
  2. 如果需要其他 API 函数,则通过 LoadLibraryA(W)LoadLibraryExA(W) 将 DLL 文件映射到调用进程的地址空间中
  3. 如果 DLL 文件已被映射到调用进程的地址空间里,就可以调用 GetModuleHandleA(W) 函数获得 DLL 模块句柄
  4. 一旦 DLL 模块被加载,就可以调用 GetProcAddress 函数获取输入函数的地址

解密各区块数据

  1. 处于保护源程序代码和数据的目的,一般会加密源程序文件的各个区块。在程序执行时外壳将这些区块数据解密,以让程序正常运行
  2. 外壳一般按区块加密,按区块解密,并将解密的数据放回在合适的内存位置

跳转回原程序入口点

  1. 在跳转回入口点之前,一般会恢复填写原 PE 文件输入表(IAT),并处理好重定位项(主要是 DLL 文件)
  2. 因为加壳时外壳自己构造了一个输入表,因此在这里需要重新对每一个 DLL 引入的所有函数重新获取地址,并填写到 IAT 表中
  3. 做完上述工作后,会将控制权移交原程序,并继续执行

单步跟踪法

单步跟踪法的原理就是通过 Ollydbg 的步过 (F8), 步入(F7) 和运行到 (F4) 功能, 完整走过程序的自脱壳过程, 跳过一些循环恢复代码的片段, 并用单步进入确保程序不会略过 OEP. 这样可以在软件自动脱壳模块运行完毕后, 到达 OEP, 并 dump 程序.

要点

  1. 打开程序按 F8 单步向下, 尽量实现向下的 jmp 跳转
  2. 会经常遇到大的循环, 这时要多用 F4 来跳过循环
  3. 如果函数载入时不远处就是一个 call(近 call), 那么我们尽量不要直接跳过, 而是进入这个 call
  4. 一般跳转幅度大的 jmp 指令, 都极有可能是跳转到了原程序入口点 (OEP)

示例

示例程序可以点击此处下载: 1_trace.zip

单步跟踪法其实就是一步一步尽量从程序入口点往下走, 在单步的过程中注意 EIP 不要跑偏了, 但是对于一些比较复杂的壳而言, 单步的过程会显得异常枯燥而且容易把自己绕晕. 所以单步跟踪也常用于分析一些关键代码部分 (跟静态分析相结合), 而不是完全地从头分析到尾, 这有违逆向工程的理念.

用 Ollydbg 打开压缩包内的 Notepad.exe, 停在了下图位置. 入口点是一个pushad保存所有寄存器状态到栈中, 随后便是一个call调用位于0040D00A处的函数. 调用后便无条件跳转到459DD4F7处, 之后的push ebpretn显然没有任何意义. 像这种入口点附近就是一个call的我们称为近call, 对于近 call 我们选择步进, 按下 F7(当然你也只能选择步进, 不然 EIP 就跑偏程序停止了).

步进后又是一个call, 我们继续步进, 按 F7, 跟进后发现没有近 call 了, 我们可以看到程序在调GetModuleHandleA, GetProcAddress等 API, 继续向下分析.

之后会遇到多个跳转,我们尽量满足向下的跳转,对于向上的跳转不予实现并利用 F4 跳出循环,直到0040D3AF处, 我们看以下的代码

0040D3AF    61                  popad
0040D3B0    75 08               jnz short NotePad.0040D3BA
0040D3B2    B8 01000000         mov eax,0x1
0040D3B7    C2 0C00             retn 0xC
0040D3BA    68 CC104000         push NotePad.004010CC
0040D3BF    C3                  retn

这里popad可以恢复在程序入口点处保存的寄存器状态, 然后jnz跳转到0040D3BA处, 这里是利用pushretn来将EIP改变为004010CC, 也就是说在壳解压完代码等资源完毕后, 将通过jnz跳转到push处, 然后通过pushretEIP设置为程序原来的入口点 (OEP) 并返回到 OEP 处, 然后继续执行原程序的代码. 我们执行到retn返回后, 可以看到如下:

显然, 我们到了一堆被Ollydbg误认为是数据的地方继续执行, 显然Ollydbg分析错误了, 我们需要让Ollydbg重新分析, 我们可以右键选择分析->从模块中删除分析, 或是按下ctrl+a, 这时正确地显示出 OEP 处的汇编指令.

ESP 定律法

ESP 定律法是脱壳的利器, 是应用频率最高的脱壳方法之一.

要点

ESP 定律的原理在于利用程序中堆栈平衡来快速找到 OEP.

由于在程序自解密或者自解压过程中, 不少壳会先将当前寄存器状态压栈, 如使用pushad, 在解压结束后, 会将之前的寄存器值出栈, 如使用popad. 因此在寄存器出栈时, 往往程序代码被恢复, 此时硬件断点触发. 然后在程序当前位置, 只需要少许单步操作, 就很容易到达正确的 OEP 位置.

  1. 程序刚载入开始 pushad/pushfd
  2. 将全部寄存器压栈后就设对 ESP 寄存器设硬件断点
  3. 运行程序, 触发断点
  4. 删除硬件断点开始分析

示例

示例程序可以点击此处下载: 2_esp.zip

还是上一篇的示例, 入口一句pushad, 我们按下 F8 执行pushad保存寄存器状态, 我们可以在右边的寄存器窗口里发现ESP寄存器的值变为了红色, 也即值发生了改变.

我们鼠标右击ESP寄存器的值, 也就是图中的0019FF64, 选择HW break[ESP]后, 按下F9运行程序, 程序会在触发断点时断下. 如图来到了0040D3B0的位置. 这里就是上一篇我们单步跟踪时到达的位置, 剩余的就不再赘述.

一步到达 OEP 法

所谓的一步到达 OEP 的脱壳方法, 是根据所脱壳的特征, 寻找其距离 OEP 最近的一处汇编指令, 然后下 int3 断点, 在程序走到 OEP 的时候 dump 程序.

如一些压缩壳往往 popad 指令距离 OEP 或者大 jmp 特别近, 因此使用 Ollydbg 的搜索功能, 可以搜索壳的特征汇编代码, 达到一步断点到达 OEP 的效果.

要点

  1. ctrl+f 查找 popad
  2. ctrl+l 跳转到下一个匹配处
  3. 找到匹配处, 确认是壳解压完毕即将跳转到 OEP 部分, 则设下断点运行到该处
  4. 只适用于极少数压缩壳

示例

示例程序可以点击此处下载: 3_direct2oep.zip

还是用的原先的 notepad.exe 来示例, 用Ollydbg打开后, 我们按下ctrl+f来查找指定的字符串, 像popad是典型的一个特征, 有部分壳它就常用popad来恢复状态, 所以如下图所示来搜索popad.

在本例中, 当搜索到的popad不符合我们的要求时, 可以按下ctrl+l来搜索下一个匹配处, 大概按下个三四次, 我们找到了跳转到 OEP 的位置处.

内存镜像法

内存镜像法是在加壳程序被加载时, 通过 OD 的ALT+M快捷键, 进入到程序虚拟内存区段. 然后通过加两次内存一次性断点, 到达程序正确 OEP 的位置.

内存镜像法的原理在于对于程序资源段和代码段下断点, 一般程序自解压或者自解密时, 会首先访问资源段获取所需资源, 然后在自动脱壳完成后, 转回程序代码段. 这时候下内存一次性断点, 程序就会停在 OEP 处.

要点

  1. 选择菜单的选项->调试选项->异常
  2. 勾选所有的忽略异常
  3. 按下ALT+M, 打开内存镜像, 找到程序的第一个.rsrc, 按 F2 下断点, 然后按SHIFT+F9运行到断点
  4. 再按ALT+M, 打开内存镜像, 找到程序的第一个.rsrc上面的.text(在示例中是00401000处), 按 F2 下断点. 然后按SHIFT+F9(或者是在没异常情况下按 F9)

示例

示例程序可以点击此处下载: 4_memory.zip

OD 载入程序, 在菜单栏的选项->调试设置->异常标签页中勾选所有的忽略异常

按下Alt+M打开内存镜像, 找到资源段, 也就是地址=00407000, 大小=00005000.rsrc段, 选中 F2 下断

回到 CPU 窗口, 按下 F9 运行, 程序断在了0040D75F

再次按下Alt+M打开内存镜像, 对.text代码段下断

再继续运行, 程序断在了004010CC处, 也就是 OEP

最后一次异常法

最后一次异常法的原理是, 程序在自解压或自解密过程中, 可能会触发无数次的异常. 如果能定位到最后一次程序异常的位置, 可能就会很接近自动脱壳完成位置. 现在最后一次异常法脱壳可以利用 Ollydbg 的异常计数器插件, 先记录异常数目, 然后重新载入, 自动停在最后一次异常处.

要点

  1. 点击选项->调试选项—>异常, 把里面的√全部去掉! 按下CTRL+F2重载下程序
  2. 开始程序就是一个跳转, 在这里我们按SHIFT+F9, 直到程序运行, 记下从开始按SHIFT+F9到程序运行的次数m!
  3. CTRL+F2重载程序, 按SHIFT+F9(这次按的次数为程序运行的次数m-1次)
  4. 在 OD 的右下角我们看见有一个 “SE 句柄“, 这时我们按CTRL+G, 输入SE 句柄前的地址!
  5. 按 F2 下断点! 然后按SHIFT+F9来到断点处, F8 单步跟踪

示例

示例程序可以点击此处下载: 5_last_exception.zip

OD 载入程序, 在菜单选项->调试设置->异常标签页中取消勾选所有的忽略异常, 然后重载程序.

我们按下Shift+F9, 记录按了多少次, 程序正常运行. 我们要得到的是倒数第二次按下是按了多少次. 在本例中

  • shift+F9一次, 到了0040CCD2的位置
  • shift+F9两次, 程序正常运行

那么我们重载程序, 只需按下 1 次 (2-1=1)Shift+F9, 来到0040CCD2的位置, 观察堆栈窗口, 这里有一个SE处理程序: 0040CCD7

我们在 CPU 窗口 (汇编指令), 按Ctrl+G, 输入0040CCD7, 然后在此处按下 F2. 也就是在0040CCD7处设置断点, 然后按下Shift+F9运行, 触发断点.

触发断点后, 来单步跟踪. 向下都是一些循环和跳转, 我们使用 F4 跳过循环. 最后到达如下位置

显然在最后的mov ebp, 0041010CC; jmp ebp是在跳转向 OEP, 我们跳转过去如下图所示:

显然, 我们幸运地来到了 OEP 处.

SFX 法

“SFX” 法利用了 Ollydbg 自带的 OEP 寻找功能, 可以选择直接让程序停在 OD 找到的 OEP 处, 此时壳的解压过程已经完毕, 可以直接 dump 程序.

要点

  1. 设置 OD, 忽略所有异常, 也就是说异常选项卡里面都打上勾
  2. 切换到 SFX 选项卡, 选择 “字节模式跟踪实际入口 (速度非常慢)”, 确定
  3. 重载程序 (如果跳出是否 “压缩代码?” 选择 “否”, OD 直接到达 OEP)

示例

示例程序可以点击此处下载: 6_sfx.zip

首先我们在菜单选项->调试设置->异常标签页中勾选所有忽略异常.

然后切换到SFX标签页, 点选 “字节方式跟踪真正入口处 (速度非常慢)”

重载程序,程序已经停在了代码入口点, 并且也不需要对 OEP 进行重新分析.

DUMP 及 IAT 重建

原理

在找到程序 OEP 后, 我们需要将程序 dump 出来, 并重建IAT. IAT全名是Import Address Table, 表项指向函数实际地址.

示例

比如如下, 我们找到了 OEP, 到达了程序的真正入口点. 我们这时就需要将程序 dump 出来. 我们右键, 选择"用OllyDump脱壳调试进程"(不过你也可以使用LoadPE来 dump 出来):

弹出一个窗口, 看一下地址是否正确, 主要就是看看入口点地址有没有选对. 然后取消勾选重建输入表.

将 dump 出的文件命名, 我这里是命名为dump.exe啦. 我们尝试来运行一下dump.exe, 可以发现程序无法正常运行, 对于一些简单的壳, 你 dump 出来发现无法正常运行, 如果你确实找到了正确的 OEP 并用IDA反编译查看结果良好, 那么你的第一想法就应该是程序的IAT出现了问题. 你就需要重建IAT.

我们需要使用ImportREC来帮助修复输入表.

打开ImportREC, 选择一个正在运行的进程原版.exe(原版.exe是我在 OD 中正在调试的进程, OD 中的EIP正处在OEP位置, 在用Ollydump之后不要关闭这个进程哦.). ImportREC修复输入表入口点需要知道OEP, 也就是要在窗口右侧中间的OEP输入框中进行输入

我们所知, 在 Ollydbg 里我们知道程序目前在的入口点是0049C25C, 而镜像基址是00400000

因此我们这里需要填写OEP0009C25C

我们修改ImportREC中的OEP0009C25C然后点击AutoSearch后, 弹出提示框 “发现可能是原 IAT 地址”

我们点击"Get Imports"按钮便可以重建IAT. 左侧会显示IAT中各导入函数的地址以及是否有效. 显然在图中可以看到ImportREC找到了内存中IAT的位置并检测出各个函数都是有效的.

我们点击Fix Dump, 然后打开先前使用OllyDump插件转储出来的文件,也就是dump.exe文件。

那么ImportREC就会帮助恢复导入表,并生成dump_.exe文件. dump_.exe可以正常运行

手动查找 IAT 并使用 ImportREC 重建

示例程序可以从此链接下载: manually_fix_iat.zip

我们常用的ImportREC脱壳是使用的软件自带的IAT auto search, 但是如果我们要手动查找IAT的地址并dump出来, 又该怎么操作呢?

首先使用 ESP 定律, 可以很快地跳转到OEP: 00401110.

我们右键点击, 选择查找->所有模块间的调用

显示出调用的函数列表, 我们双击其中的某个函数 (注意这里要双击的应该是程序的函数而不是系统函数)

我们来到了函数调用处

右键点击跟随, 进入函数

然后再右键点击数据窗口中跟随->内存地址

这里因为显示是十六进制值, 不方便查看, 我们可以在数据窗口点击右键选择长型->地址, 就可以显示函数名

注意我们要向上翻到 IAT 表的起始位置, 可以看到最开始的函数地址是004050D8kernel.AddAtomA, 我们向下找到最后一个函数, 也就是user32.MessageBoxA函数, 计算一下整个 IAT 表的大小。在 OD 的最下方有显示块大小:0x7C, 所以我们整个 IAT 块大小就是0x7C

打开ImportREC, 选择我们正在调试的这个程序, 然后分别输入OEP:1110, RVA:50D8, SIZE:7C, 然后点击获取输入表

这里在输入表窗口中右键选择高级命令->选择代码块.

然后会弹出窗口, 选择完整转储, 保存为dump.exe文件

dump 完成后, 选择转储到文件, 这里选择修复我们刚刚 dump 出的 dump.exe, 得到一个dump\_.exe. 这时整个脱壳就完成了

DLL 文件脱壳

例题文件你可以点击此处下载: unpack_dll.zip

因为Dll脱壳需要这一步骤. Dll脱壳的最关键的步骤在于使用LordPE修改其Dll的标志, 用LordPE打开UnpackMe.dll, 然后在特征值那里点击..., 然后取消勾选DLL标志, 保存后, 系统就会将该文件视作一个可执行文件.

我们将UnpackMe.dll后缀名改成UnpackMe.exe, 然后用 OD 载入.

一般在入口点, 程序都会保存一些信息, 这里就很简单, 只作了一个cmp. 要注意的一点是, 这里的jnz跳转直接就跳到了unpacking过程的末尾. 因此我们需要修改寄存器的z标志来使得跳转失效. 同时在unpacking过程的末尾设下一个断点以避免脱壳完然后直接运行.(程序会断在这个断点上, 但是脱壳已经完成, 代码都很清晰)

Dll脱壳的基本步骤跟exe文件脱壳一样, 而在重建IAT时, 需要照着上篇 手动查找 IAT 并使用 ImportREC 重建 所说的那样, 手动找到IAT表并用ImportREC进行重建. 只是要注意, 在脱壳完 dump 后, 要记得用 LordPE 把DLL标志恢复过来并将文件后缀名改为.dll.


反调试技术


NtGlobalFlag

关于 NtGlobalFlag

在 32 位机器上, NtGlobalFlag字段位于PEB(进程环境块)0x68的偏移处, 64 位机器则是在偏移0xBC位置. 该字段的默认值为 0. 当调试器正在运行时, 该字段会被设置为一个特定的值. 尽管该值并不能十分可信地表明某个调试器真的有在运行, 但该字段常出于该目的而被使用.

该字段包含有一系列的标志位. 由调试器创建的进程会设置以下标志位:

FLG_HEAP_ENABLE_TAIL_CHECK (0x10)
FLG_HEAP_ENABLE_FREE_CHECK (0x20)
FLG_HEAP_VALIDATE_PARAMETERS (0x40)

检测代码

因此, 可以检查这几个标志位来检测调试器是否存在. 比如用形如以下的 32 位的代码在 32 位机器上进行检测:

mov eax, fs:[30h] ;Process Environment Block
mov al, [eax+68h] ;NtGlobalFlag
and al, 70h
cmp al, 70h
je being_debugged

以下是 64 位的代码在 64 位机器上的检测代码:

push 60h
pop rsi
gs:lodsq                ;Process Environment Block
mov al, [rsi*2+rax-14h] ;NtGlobalFlag
and al, 70h
cmp al, 70h
je being_debugged

要注意的是, 如果是一个 32 位程序在 64 位机器上运行, 那么实际上会存在两个 PEB: 一个是 32 位部分的而另一个是 64 位. 64 位的 PEB 的对应字段也会像在 32 位的那样而改变.

于是我们就还有以下的, 用 32 位的代码检测 64 位机器环境:

mov eax, fs:[30h] ; Process Environment Block
;64-bit Process Environment Block
;follows 32-bit Process Environment Block
mov al, [eax+10bch] ;NtGlobalFlag
and al, 70h
cmp al, 70h
je being_debugged

切记不要在没有掩盖其他位的情况下直接进行比较, 那样会无法检测到调试器.

ExeCryptor就有使用NtGlobalFlag来检测调试器, 不过NtGlobalFlag的那 3 个标志位只有当程序是由调试器创建, 而非由调试器附加上去的进程时, 才会被设置.

改变 NtGlobalFlag 初值

当然绕过这种检测的方法也十分简单, 那就是调试器想办法将该字段重新设置为 0. 然而这个默认的初值可以用以下四种方法任意一种改变:

  1. 注册表HKLM\System\CurrentControlSet\Control\SessionManagerGlobalFlag的值会替换进行NtGlobalFlag字段. 尽管它随后还可能由 Windows 改变 (以下会介绍), 注册表键值会对系统中所有进程产生影响并在重启后生效.

    当然这也产生了另一种检测调试器的方法: 如果一个调试器为了隐藏自己, 而将注册表中的键值复制到NtGlobalFlag字段中, 然而注册表中的键值事先已经替换并且尚未重启生效. 那么调试器只是复制了一个假的值, 而非真正需要的那个. 如果程序知道真正的值而非注册表中的那个假的值, 那么就可以察觉到调试器的存在.

    当然调试器也可以运行其他进程然后查询NtGlobalFlag字段来获取真正的值.

  2. 依旧是GlobalFlag, 不过这里的是HKLM\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\<filename>. (映像劫持), 这里需要将<filename>替换为需要更改的可执行文件的文件名 (不需要指定路径). 设置好GlobalFlag后, 系统会将其值覆盖到NtGlobalFlag字段 (只单独为指定的进程覆盖). 不过也还是可以再次由 Windows 改变 (见下).

  3. 在加载配置表 (Load Configuration Table) 的两个字段: GlobalFlagsClearGlobalFlagsSet.

    GlobalFlagsClear列出需要清空的标志位, 而GlobalFlagsSet则列出需要设置的标志位, 这些设置会在GlobalFlag应用之后再生效, 因此它可以覆盖掉GlobalFlag指定的值. 然而它无法覆盖掉 Windows 指定设置的标志位. 比如设置FLG_USER_STACK_TRACE_DB (0x1000)可以让 Windows 设置FLG_HEAP_VALIDATE_PARAMETERS (0x40)标志位, 就算FLG_HEAP_VALIDATE_PARAMETERS在加载配置表 (Load Configuration Table) 中被清空了, Windows 也会在随后的进程加载过程中重新设置.

  4. 当调试器创建进程时, Windows 会做出一些改变. 通过设置环境变量中的_NO_DEBUG_HEAP, NtGlobalFlag将会因为调试器而不会设置其中的 3 个堆的标志位. 当然它们依旧可以通过GlobalFlag或加载配置表中的GlobalFlagsSet继续设置.

如何绕过检测?

有以下 3 种方法来绕过NtGlobalFlag的检测

  • 手动修改标志位的值 (FLG_HEAP_ENABLE_TAIL_CHECK, FLG_HEAP_ENABLE_FREE_CHECK, FLG_HEAP_VALIDATE_PARAMETERS)
  • 在 Ollydbg 中使用hide-debug插件
  • 在 Windbg 禁用调试堆的方式启动程序 (windbg -hd program.exe)

手动绕过示例

以下是一个演示如何手动绕过检测的示例

.text:00403594     64 A1 30 00 00 00          mov     eax, large fs:30h   ; PEB struct loaded into EAX
.text:0040359A                                db      3Eh                 ; IDA Pro display error (the byte is actually used in the next instruction)
.text:0040359A     3E 8B 40 68                mov     eax, [eax+68h]      ; NtGlobalFlag (offset 0x68 relative to PEB) saved to EAX
.text:0040359E     83 E8 70                   sub     eax, 70h            ; Value 0x70 corresponds to all flags on (FLG_HEAP_ENABLE_TAIL_CHECK, FLG_HEAP_ENABLE_FREE_CHECK, FLG_HEAP_VALIDATE_PARAMETERS)
.text:004035A1     89 85 D8 E7 FF FF          mov     [ebp+var_1828], eax
.text:004035A7     83 BD D8 E7 FF FF 00       cmp     [ebp+var_1828], 0   ; Check whether 3 debug flags were on (result of substraction should be 0 if debugged)
.text:004035AE     75 05                      jnz     short loc_4035B5    ; No debugger, program continues...
.text:004035B0     E8 4B DA FF FF             call    s_selfDelete        ; ...else, malware deleted

在 Ollydbg 中在偏移0x40359A设置断点, 运行程序触发断点. 然后打开CommandLine插件用dump fs:[30]+0x68dump 出NtGlobalFlag的内容

右键选择Binary->Fill with 00's将值0x70替换为0x00即可.

Heap Flags

关于 Heap flags

Heap flags包含有两个与NtGlobalFlag一起初始化的标志: FlagsForceFlags. 这两个字段的值不仅会受调试器的影响, 还会由 windows 版本而不同, 字段的位置也取决于 windows 的版本.

  • Flags 字段:
    • 在 32 位 Windows NT, Windows 2000 和 Windows XP 中, Flags位于堆的0x0C偏移处. 在 32 位 Windows Vista 及更新的系统中, 它位于0x40偏移处.
    • 在 64 位 Windows XP 中, Flags字段位于堆的0x14偏移处, 而在 64 位 Windows Vista 及更新的系统中, 它则是位于0x70偏移处.
  • ForceFlags 字段:
    • 在 32 位 Windows NT, Windows 2000 和 Windows XP 中, ForceFlags位于堆的0x10偏移处. 在 32 位 Windows Vista 及更新的系统中, 它位于0x44偏移处.
    • 在 64 位 Windows XP 中, ForceFlags字段位于堆的0x18偏移处, 而在 64 位 Windows Vista 及更新的系统中, 它则是位于0x74偏移处.

在所有版本的 Windows 中, Flags字段的值正常情况都设为HEAP_GROWABLE(2), 而ForceFlags字段正常情况都设为0. 然而对于一个 32 位进程 (64 位程序不会有此困扰), 这两个默认值, 都取决于它的宿主进程(host process) 的 subsystem版本 (这里不是指所说的比如 win10 的 linux 子系统). 只有当subsystem3.51及更高的版本, 字段的默认值才如前所述. 如果是在3.10-3.50版本之间, 则两个字段的HEAP_CREATE_ALIGN_16 (0x10000)都会被设置. 如果版本低于3.10, 那么这个程序文件就根本不会被运行.

如果某操作将FlagsForgeFlags字段的值分别设为20, 但是却未对subsystem版本进行检查, 那么就可以表明该动作是为了隐藏调试器而进行的.

当调试器存在时, 在Windows NT, Windows 2000和 32 位Windows XP系统下, Flags字段会设置以下标志:

HEAP_GROWABLE (2)
HEAP_TAIL_CHECKING_ENABLED (0x20)
HEAP_FREE_CHECKING_ENABLED (0x40)
HEAP_SKIP_VALIDATION_CHECKS (0x10000000)
HEAP_VALIDATE_PARAMETERS_ENABLED (0x40000000)

在 64 位Windows XP系统, Windows Vista及更新的系统版本, Flags字段则会设置以下标志 (少了HEAP_SKIP_VALIDATION_CHECKS (0x10000000)):

HEAP_GROWABLE (2)
HEAP_TAIL_CHECKING_ENABLED (0x20)
HEAP_FREE_CHECKING_ENABLED (0x40)
HEAP_VALIDATE_PARAMETERS_ENABLED (0x40000000)

而对于ForgeFlags字段, 正常情况则会设置以下标志:

HEAP_TAIL_CHECKING_ENABLED (0x20)
HEAP_FREE_CHECKING_ENABLED (0x40)
HEAP_VALIDATE_PARAMETERS_ENABLED (0x40000000)

因为NtGlobalFlag标志的关系, heap也会设置一些标志位

  • 如果在NtGlobalFlag字段中有设置FLG_HEAP_ENABLE_TAIL_CHECK标志, 那么在heap字段中就会设置HEAP_TAIL_CHECKING_ENABLED标志.
  • 如果在NtGlobalFlag字段中有设置FLG_HEAP_ENABLE_FREE_CHECK标志, 那么在heap字段中就会设置FLG_HEAP_ENABLE_FREE_CHECK标志.
  • 如果在NtGlobalFlag字段中有设置FLG_HEAP_VALIDATE_PARAMETERS标志, 那么在heap字段中就会设置HEAP_VALIDATE_PARAMETERS_ENABLED标志 (在Windows NTWindows 2000中还会设置HEAP_CREATE_ALIGN_16 (0x10000)标志).

heap flags同样也如上节的NtGlobalFlag那样, 不过它受到注册表HKLM\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\<filename>位置的PageHeapFlags"键的控制.

获取 heap 位置

有多种方法能获知heap的位置, 方法之一就是kernel32GetProcessHeap()函数, 当然也可以用以下的 32 位汇编代码来检测 32 位环境 (实际上就有一些壳避免使用该 api 函数, 直接查询 PEB):

mov eax, fs:[30h] ;Process Environment Block
mov eax, [eax+18h] ;get process heap base

或使用以下 64 位代码来检测 64 位环境

push 60h
pop rsi
gs:lodsq ;Process Environment Block
mov eax, [rax+30h] ;get process heap base

或使用以下 32 位代码检测 64 位环境

mov eax, fs:[30h] ;Process Environment Block
;64-bit Process Environment Block
;follows 32-bit Process Environment Block
mov eax, [eax+1030h] ;get process heap base

另外一种方法则是使用kernel32GetProcessHeaps()函数, 其实它只是简单的转给了ntdllRtlGetProcessHeaps()函数, 这个函数会返回属于当前进程的堆的数组, 而数组的第一个堆, 就跟kernel32GetProcessHeap()函数所返回的是一样的.

这个过程可以用 32 位代码检测 32 位 windows 环境来实现:

push 30h
pop esi
fs:lodsd ;Process Environment Block
;get process heaps list base
mov esi, [esi+eax+5ch]
lodsd

同上, 用 64 位代码检测 64 位 windows 环境的代码是:

push 60h
pop rsi
gs:lodsq ;Process Environment Block
;get process heaps list base
mov esi, [rsi*2+rax+20h]
lodsd

或使用 32 位代码检测 64 位 window 环境:

mov eax, fs:[30h] ;Process Environment Block
;64-bit Process Environment Block
;follows 32-bit Process Environment Block
mov esi, [eax+10f0h] ;get process heaps list base
lodsd

检测 Flags 字段

那么显然, 检测调试器我们就可以从检测那几个FlagsForgeFlags的标志位入手.

先看Flags字段的检测代码, 用 32 位代码检测 32 位 windows 环境, 且subsystem版本在3.10-3.50之间:

call GetVersion
cmp al, 6
cmc
sbb ebx, ebx
and ebx, 34h
mov eax, fs:[30h] ;Process Environment Block
mov eax, [eax+18h] ;get process heap base
mov eax, [eax+ebx+0ch] ;Flags
;neither HEAP_CREATE_ALIGN_16
;nor HEAP_SKIP_VALIDATION_CHECKS
and eax, 0effeffffh
;HEAP_GROWABLE
;+ HEAP_TAIL_CHECKING_ENABLED
;+ HEAP_FREE_CHECKING_ENABLED
;+ HEAP_VALIDATE_PARAMETERS_ENABLED
cmp eax, 40000062h
je being_debugged

32 位代码检测 32 位 windows 环境, 且subsystem3.51及更高版本:

call GetVersion
cmp al, 6
cmc
sbb ebx, ebx
and ebx, 34h
mov eax, fs:[30h] ;Process Environment Block
mov eax, [eax+18h] ;get process heap base
mov eax, [eax+ebx+0ch] ;Flags
;not HEAP_SKIP_VALIDATION_CHECKS
bswap eax
and al, 0efh
;HEAP_GROWABLE
;+ HEAP_TAIL_CHECKING_ENABLED
;+ HEAP_FREE_CHECKING_ENABLED
;+ HEAP_VALIDATE_PARAMETERS_ENABLED
;reversed by bswap
cmp eax, 62000040h
je being_debugged

64 位代码检测 64 位 windows 环境 (64 位进程不必受subsystem版本困扰):

push 60h
pop rsi
gs:lodsq ;Process Environment Block
mov ebx, [rax+30h] ;get process heap base
call GetVersion
cmp al, 6
sbb rax, rax
and al, 0a4h
;HEAP_GROWABLE
;+ HEAP_TAIL_CHECKING_ENABLED
;+ HEAP_FREE_CHECKING_ENABLED
;+ HEAP_VALIDATE_PARAMETERS_ENABLED
cmp d [rbx+rax+70h], 40000062h ;Flags
je being_debugged

用 32 位代码检测 64 位 windows 环境:

push 30h
pop eax
mov ebx, fs:[eax] ;Process Environment Block
;64-bit Process Environment Block
;follows 32-bit Process Environment Block
mov ah, 10h
mov ebx, [ebx+eax] ;get process heap base
call GetVersion
cmp al, 6
sbb eax, eax
and al, 0a4h
;Flags
;HEAP_GROWABLE
;+ HEAP_TAIL_CHECKING_ENABLED
;+ HEAP_FREE_CHECKING_ENABLED
;+ HEAP_VALIDATE_PARAMETERS_ENABLED
cmp [ebx+eax+70h], 40000062h
je being_debugged

如果是直接通过KUSER_SHARED_DATA结构的NtMajorVersion字段 (位于 2G 用户空间的0x7ffe026c偏移处) 获取该值 (在所有 32 位 / 64 位版本的 Windows 都可以获取该值), 可以进一步混淆kernel32GetVersion()函数调用操作.

检测 ForgeFlags 字段

当然另一个方法就是检测ForgeFlags字段, 以下是 32 位代码检测 32 位 Windows 环境, subsystem版本在3.10-3.50之间:

call GetVersion
cmp al, 6
cmc
sbb ebx, ebx
and ebx, 34h
mov eax, fs:[30h] ;Process Environment Block
mov eax, [eax+18h] ;get process heap base
mov eax, [eax+ebx+10h] ;ForceFlags
;not HEAP_CREATE_ALIGN_16
btr eax, 10h
;HEAP_TAIL_CHECKING_ENABLED
;+ HEAP_FREE_CHECKING_ENABLED
;+ HEAP_VALIDATE_PARAMETERS_ENABLED
cmp eax, 40000060h
je being_debugged

32 位代码检测 32 位 windows 环境, 且subsystem3.51及更高版本:

call GetVersion
cmp al, 6
cmc
sbb ebx, ebx
and ebx, 34h
mov eax, fs:[30h] ;Process Environment Block
mov eax, [eax+18h] ;get process heap base
;ForceFlags
;HEAP_TAIL_CHECKING_ENABLED
;+ HEAP_FREE_CHECKING_ENABLED
;+ HEAP_VALIDATE_PARAMETERS_ENABLED
cmp [eax+ebx+10h], 40000060h
je being_debugged

64 位代码检测 64 位 windows 环境 (64 位进程不必受subsystem版本困扰):

push 60h
pop rsi
gs:lodsq ;Process Environment Block
mov ebx, [rax+30h] ;get process heap base
call GetVersion
cmp al, 6
sbb rax, rax
and al, 0a4h
;ForceFlags
;HEAP_TAIL_CHECKING_ENABLED
;+ HEAP_FREE_CHECKING_ENABLED
;+ HEAP_VALIDATE_PARAMETERS_ENABLED
cmp d [rbx+rax+74h], 40000060h
je being_debugged

用 32 位代码检测 64 位 windows 环境:

call GetVersion
cmp al, 6
push 30h
pop eax
mov ebx, fs:[eax] ;Process Environment Block
;64-bit Process Environment Block
;follows 32-bit Process Environment Block
mov ah, 10h
mov ebx, [ebx+eax] ;get process heap base
sbb eax, eax
and al, 0a4h
;ForceFlags
;HEAP_TAIL_CHECKING_ENABLED
;+ HEAP_FREE_CHECKING_ENABLED
;+ HEAP_VALIDATE_PARAMETERS_ENABLED
cmp [ebx+eax+74h], 40000060h
je being_debugged

The Heap

堆在初始化时, 会检查heap flags, 并视一些标志位的有无设置而对环境作出额外的改变. 像Themida就有采用这种方法来检测调试器.

比如:

  • 如果设置了HEAP_TAIL_CHECKING_ENABLED标志 (见Heap Flags节), 那么在 32 位 windows 中就会在分配的堆块尾部附加 2 个0xABABABAB(64 位环境就是 4 个).
  • 如果设置了HEAP_FREE_CHECKING_ENABLED(见Heap Flags节) 标志, 那么当需要额外的字节来填充堆块尾部时, 就会使用0xFEEEFEEE(或一部分) 来填充

那么, 一种新的检测调试器的方法就是来检查这些值.

堆指针已知

如果已知一个堆指针, 那么我们可以直接检查堆块里的数据. 然而在Windows Vista及更高版本中采用了堆保护机制 (32 位 / 64 位都有), 使用了一个异或密钥来对堆块大小进行了加密. 虽然你可以选择是否使用密钥, 但是默认是使用的. 而且就堆块首部的位置, 在Windows NT/2000/XPWindows Vista及更高版本之间也是不相同的. 因此我们还需要将Windows版本也考虑在内.

可以使用以下 32 位代码来检测 32 位环境:

    xor ebx, ebx
    call GetVersion
    cmp al, 6
    sbb ebp, ebp
    jb l1
    ;Process Environment Block
    mov eax, fs:[ebx+30h]
    mov eax, [eax+18h] ;get process heap base
    mov ecx, [eax+24h] ;check for protected heap
    jecxz l1
    mov ecx, [ecx]
    test [eax+4ch], ecx
    cmovne ebx, [eax+50h] ;conditionally get heap key
l1: mov eax, <heap ptr>
    movzx edx, w [eax-8] ;size
    xor dx, bx
    movzx ecx, b [eax+ebp-1] ;overhead
    sub eax, ecx
    lea edi, [edx*8+eax]
    mov al, 0abh
    mov cl, 8
    repe scasb
    je being_debugged

或使用以下 64 位代码检测 64 位环境:

    xor ebx, ebx
    call GetVersion
    cmp al, 6
    sbb rbp, rbp
    jb l1
    ;Process Environment Block
    mov rax, gs:[rbx+60h]
    mov eax, [rax+30h] ;get process heap base
    mov ecx, [rax+40h] ;check for protected heap
    jrcxz l1
    mov ecx, [rcx+8]
    test [rax+7ch], ecx
    cmovne ebx, [rax+88h] ;conditionally get heap key
l1: mov eax, <heap ptr>
    movzx edx, w [rax-8] ;size
    xor dx, bx
    add edx, edx
    movzx ecx, b [rax+rbp-1] ;overhead
    sub eax, ecx
    lea edi, [rdx*8+rax]
    mov al, 0abh
    mov cl, 10h
    repe scasb
    je being_debugged

这里没有使用 32 位代码检测 64 位环境的样例, 因为 64 位的堆无法由 32 位的堆函数解析.

堆指针未知

如果无法得知堆指针, 我们可以使用kernel32HeapWalk()函数或ntdllRtlWalkHeap()函数 (或甚至是kernel32GetCommandLine()函数). 返回的堆大小的值会被自动解密, 因此就不需要再关心 windows 的版本

可以使用以下 32 位代码来检测 32 位环境:

    mov ebx, offset l2
    ;get a pointer to a heap block
l1: push ebx
    mov eax, fs:[30h] ;Process Environment Block
    push d [eax+18h] ;save process heap base
    call HeapWalk
    cmp w [ebx+0ah], 4 ;find allocated block
    jne l1
    mov edi, [ebx] ;data pointer
    add edi, [ebx+4] ;data size
    mov al, 0abh
    push 8
    pop ecx
    repe scasb
    je being_debugged
    ...
l2: db 1ch dup (0) ;sizeof(PROCESS_HEAP_ENTRY)

或使用以下 64 位代码检测 64 位环境:

    mov rbx, offset l2
    ;get a pointer to a heap block
l1: push rbx
    pop rdx
    push 60h
    pop rsi
    gs:lodsq ;Process Environment Block
    ;get a pointer to process heap base
    mov ecx, [rax+30h]
    call HeapWalk
    cmp w [rbx+0eh], 4 ;find allocated block
    jne l1
    mov edi, [rbx] ;data pointer
    add edi, [rbx+8] ;data size
    mov al, 0abh
    push 10h
    pop rcx
    repe scasb
    je being_debugged
    ...
l2: db 28h dup (0) ;sizeof(PROCESS_HEAP_ENTRY)

这里没有使用 32 位代码检测 64 位环境的样例, 因为 64 位的堆无法由 32 位的堆函数解析.

Interrupt 3

无论何时触发了一个软件中断异常, 异常地址以及 EIP 寄存器的值都会同时指向产生异常的下一句指令. 但断点异常是其中的一个特例.

EXCEPTION_BREAKPOINT(0x80000003)异常触发时, Windows 会认定这是由单字节的 “CC“ 操作码 (也即Int 3指令) 造成的. Windows 递减异常地址以指向所认定的 “CC“ 操作码, 随后传递该异常给异常处理句柄. 但是 EIP 寄存器的值并不会发生变化.

因此, 如果使用了 CD 03(这是 Int 03 的机器码表示),那么当异常处理句柄接受控制时, 异常地址是指向 03 的位置.

sDebuggerPresent

关于 IsDebuggerPresent

当调试器存在时, kernel32IsDebuggerPresent()函数返回的是一个非0值.

BOOL WINAPI IsDebuggerPresent(void);

检测代码

它的检测方法非常简单, 比如用以下代码 (32 位还是 64 位都是相同的这份代码) 在 32 位 / 64 位环境中检测:

call IsDebuggerPresent
test al, al
jne being_debugged

实际上, 这个函数只是单纯地返回了BeingDebugged标志的值. 检查BeingDebugged标志位的方法也可以用以下 32 代码位代码检查 32 位环境来实现:

mov eax, fs:[30h] ;Process Environment Block
cmp b [eax+2], 0 ;check BeingDebugged
jne being_debugged

或使用 64 位代码检测 64 位环境

push 60h
pop rsi
gs:lodsq ;Process Environment Block
cmp b [rax+2], 0 ;check BeingDebugged
jne being_debugged

或使用 32 位代码检测 64 位环境

mov eax, fs:[30h] ;Process Environment Block
;64-bit Process Environment Block
;follows 32-bit Process Environment Block
cmp b [eax+1002h], 0 ;check BeingDebugged
jne being_debugged

如何绕过

想要克服这些检测, 只需要将BeingDebugged标志设为0即可 (或改变一下返回值).

CheckRemoteDebuggerPresent

关于 CheckRemoteDebuggerPresent

kernel32CheckRemoteDebuggerPresent()函数用于检测指定进程是否正在被调试. Remote在单词里是指同一个机器中的不同进程.

BOOL WINAPI CheckRemoteDebuggerPresent(
  _In_    HANDLE hProcess,
  _Inout_ PBOOL  pbDebuggerPresent
);

如果调试器存在 (通常是检测自己是否正在被调试), 该函数会将pbDebuggerPresent指向的值设为0xffffffff.

检测代码

可以用以下 32 位代码检测 32 位环境

push eax
push esp
push -1 ;GetCurrentProcess()
call CheckRemoteDebuggerPresent
pop eax
test eax, eax
jne being_debugged

或 64 位代码检测 64 位环境

enter 20h, 0
mov edx, ebp
or rcx, -1 ;GetCurrentProcess()
call CheckRemoteDebuggerPresent
leave
test ebp, ebp
jne being_debugged

如何绕过

比如有如下的代码

int main(int argc, char *argv[])
{
    BOOL isDebuggerPresent = FALSE;
    if (CheckRemoteDebuggerPresent(GetCurrentProcess(), &isDebuggerPresent ))
    {
        if (isDebuggerPresent )
        {
            std::cout << "Stop debugging program!" << std::endl;
            exit(-1);
        }
    }
    return 0;
}

我们可以直接修改isDebuggerPresent的值或修改跳转条件来绕过 (注意不是CheckRemoteDebuggerPresent的 izhi, 它的返回值是用于表示函数是否正确执行).

但如果要针对CheckRemoteDebuggerPresent这个 api 函数进行修改的话. 首先要知道CheckRemoteDebuggerPresent内部其实是通过调用NtQueryInformationProcess来完成功能的. 而我们就需要对NtQueryInformationProcess的返回值进行修改. 我们将在 NtQueryInformationProcess 篇进行介绍.

NtQueryInformationProcess

NTSTATUS WINAPI NtQueryInformationProcess(
  _In_      HANDLE           ProcessHandle,
  _In_      PROCESSINFOCLASS ProcessInformationClass,
  _Out_     PVOID            ProcessInformation,
  _In_      ULONG            ProcessInformationLength,
  _Out_opt_ PULONG           ReturnLength
);

ProcessDebugPort

未公开的ntdllNtQueryInformationProcess()函数接受一个信息类的参数用于查询. ProcessDebugPort(7)是其中的一个信息类. kernel32CheckRemoteDebuggerPresent()函数内部通过调用NtQueryInformationProcess()来检测调试, 而NtQueryInformationProcess内部则是查询EPROCESS结构体的DebugPort字段, 当进程正在被调试时, 返回值为0xffffffff.

可以用以下 32 位代码在 32 位环境进行检测:

push eax
mov eax, esp
push 0
push 4 ;ProcessInformationLength
push eax
push 7 ;ProcessDebugPort
push -1 ;GetCurrentProcess()
call NtQueryInformationProcess
pop eax
inc eax
je being_debugged

用以下 64 位代码在 64 位环境进行检测:

xor ebp, ebp
enter 20h, 0
push 8 ;ProcessInformationLength
pop r9
push rbp
pop r8
push 7 ;ProcessDebugPort
pop rdx
or rcx, -1 ;GetCurrentProcess()
call NtQueryInformationProcess
leave
test ebp, ebp
jne being_debugged

由于信息传自内核, 所以在用户模式下的代码没有轻松的方法阻止该函数检测调试器.

ProcessDebugObjectHandle

Windows XP 引入了debug对象, 当一个调试会话启动, 会同时创建一个debug对象以及与之关联的句柄. 我们可以使用ProcessDebugObjectHandle (0x1e)类来查询这个句柄的值

可以用以下 32 位代码在 32 位环境进行检测:

push 0
mov eax, esp
push 0
push 4 ;ProcessInformationLength
push eax
push 1eh ;ProcessDebugObjectHandle
push -1 ;GetCurrentProcess()
call NtQueryInformationProcess
pop eax
test eax, eax
jne being_debugged

用以下 64 位代码在 64 位环境进行检测:

xor ebp, ebp
enter 20h, 0
push 8 ;ProcessInformationLength
pop r9
push rbp
pop r8
push 1eh ;ProcessDebugObjectHandle
pop rdx
or rcx, -1 ;GetCurrentProcess()
call NtQueryInformationProcess
leave
test ebp, ebp
jne being_debugged

ProcessDebugFlags

ProcessDebugFlags (0x1f)类返回EPROCESS结构体的NoDebugInherit的相反数. 意思是, 当调试器存在时, 返回值为0, 不存在时则返回1.

可以用以下 32 位代码在 32 位环境进行检测:

push eax
mov eax, esp
push 0
push 4 ;ProcessInformationLength
push eax
push 1fh ;ProcessDebugFlags
push -1 ;GetCurrentProcess()
call NtQueryInformationProcess
pop eax
test eax, eax
je being_debugged

用以下 64 位代码在 64 位环境进行检测:

xor ebp, ebp
enter 20h, 0
push 4 ;ProcessInformationLength
pop r9
push rbp
pop r8
push 1fh ;ProcessDebugFlags
pop rdx
or rcx, -1 ;GetCurrentProcess()
call NtQueryInformationProcess
leave
test ebp, ebp
je being_debugged

ZwSetInformationThread

关于 ZwSetInformationThread

ZwSetInformationThread 等同于 NtSetInformationThread,通过为线程设置 ThreadHideFromDebugger,可以禁止线程产生调试事件,代码如下

#include <Windows.h>
#include <stdio.h>

typedef DWORD(WINAPI* ZW_SET_INFORMATION_THREAD) (HANDLE, DWORD, PVOID, ULONG);
#define ThreadHideFromDebugger 0x11
VOID DisableDebugEvent(VOID)
{
    HINSTANCE hModule;
    ZW_SET_INFORMATION_THREAD ZwSetInformationThread;
    hModule = GetModuleHandleA("Ntdll");
    ZwSetInformationThread = (ZW_SET_INFORMATION_THREAD)GetProcAddress(hModule, "ZwSetInformationThread");
    ZwSetInformationThread(GetCurrentThread(), ThreadHideFromDebugger, 0, 0);
}

int main()
{
    printf("Begin\n");
    DisableDebugEvent();
    printf("End\n");
    return 0;
}

关键代码为ZwSetInformationThread(GetCurrentThread(), ThreadHideFromDebugger, 0, 0);,如果处于调试状态,执行完该行代码,程序就会退出

如何绕过

注意到该处 ZwSetInformationThread 函数的第 2 个参数为 ThreadHideFromDebugger,其值为 0x11。调试执行到该函数时,若发现第 2 个参数值为 0x11,跳过或者将 0x11 修改为其他值即可

花指令

原理

花指令是企图隐藏掉不想被逆向工程的代码块 (或其它功能) 的一种方法, 在真实代码中插入一些垃圾代码的同时还保证原有程序的正确执行, 而程序无法很好地反编译, 难以理解程序内容, 达到混淆视听的效果.

例题

这里以看雪.TSRC 2017CTF秋季赛第二题作为讲解. 题目下载链接: ctf2017_Fpc.exe

程序写了几个函数混淆视听, 将关键的验证逻辑加花指令防止了 IDA 的静态分析. 我们用 IDA 打开 Fpc 这道题, 程序会先打印一些提示信息, 然后获取用户的输入.

这里使用了不安全的scanf函数, 用户输入的缓冲区只有0xCh长, 我们双击v1进入栈帧视图

因此我们可以通过溢出数据, 覆盖掉返回地址, 从而转移到任意地址继续执行.

这里我还需要解释一下, 就是scanf之前写的几个混淆视听的函数, 是一些简单的方程式但实际上是无解的. 程序将真正的验证逻辑加花混淆, 导致 IDA 无法很好的进行反编译. 所以我们这道题的思路就是, 通过溢出转到真正的验证代码处继续执行.

我们在分析时可以在代码不远处发现以下数据块.

因为 IDA 没能很好的识别数据, 因此我们可以将光标移到数据块的起始位置, 然后按下C键 (code) 将这块数据反汇编成代码

值得注意的是, 这段代码的位置是0x00413131, 0x41'A'的 ascii 码,而0x31'1'的 ascii 码. 由于看雪比赛的限制, 用户输入只能是字母和数字, 所以我们也完全可以利用溢出漏洞执行这段代码

用 OD 打开, 然后Ctrl+G到达0x413131处设下断点, 运行后输入12345612345611A回车, 程序成功地到达0x00413131处. 然后右键分析->从模块中删除分析识别出正确代码

断在0x413131处后, 点击菜单栏的"查看", 选择"RUN跟踪", 然后再点击"调试", 选择"跟踪步入", 程序会记录这段花指令执行的过程, 如下图所示:

这段花指令本来很长, 但是使用 OD 的跟踪功能后, 花指令的执行流程就非常清楚. 整个过程中进行了大量的跳转, 我们只要取其中的有效指令拿出来分析即可.

需要注意的是, 在有效指令中, 我们依旧要满足一些条件跳转, 这样程序才能在正确的逻辑上一直执行下去.

比如0x413420处的jnz ctf2017_.00413B03. 我们就要重新来过, 并在0x413420设下断点

通过修改标志寄存器来满足跳转. 继续跟踪步入 (之后还有0041362E jnz ctf2017_.00413B03需要满足). 保证逻辑正确后, 将有效指令取出继续分析就好了

反调试技术例题

我们现在来分析一道 2016 年 SecCon 的anti-debugging题, 题目下载链接: bin.exe

这是一个 32 位的 PE 文件, 是一个控制台程序, 我们直接运行, 会要求输入password. 当你输入一个错误的password时则会提示你password is wrong.

我们用 IDA 打开来看下, 最快速的方式就是直接查看字符串, 根据password is wrong找到关键代码. IDA 显示的结果如下图:

显然, 字符串表明程序中可能有各种检测, 比如检测进程名ollydbg.exe, ImmunityDebugger.exe, idaq.exeWireshark.exe. 然后也有其他的检测. 我们也看到了字符串password is wrongYou password is correct的字样. 同时还发现了一个很有可能就是待解密的 flag 的字符串. 那么我们就先根据password is wrong的交叉引用来到关键函数处.

如下所示: 程序果然使用了大量的反调试技巧.

int __cdecl main(int argc, const char **argv, const char **envp)
{
  v23 = 0;
  memset(&v24, 0, 0x3Fu);
  v22 = 1;
  printf("Input password >");
  v3 = (FILE *)sub_40223D();
  fgets(&v23, 64, v3);
  strcpy(v21, "I have a pen.");
  v22 = strncmp(&v23, v21, 0xDu); // 1. 直接比较明文字符串与输入字符串
  if ( !v22 )
  {
    puts("Your password is correct.");
    if ( IsDebuggerPresent() == 1 )     // 2. API: IsDebuggerPresent()
    {
      puts("But detected debugger!");
      exit(1);
    }
    if ( sub_401120() == 0x70 )         // 3. 检测PEB的0x68偏移处是否为0x70. 检测NtGlobalFlag()
    {
      puts("But detected NtGlobalFlag!");
      exit(1);
    }

    /*  BOOL WINAPI CheckRemoteDebuggerPresent(
     *    _In_    HANDLE hProcess,
     *    _Inout_ PBOOL  pbDebuggerPresent
     *  );
     */
    v4 = GetCurrentProcess();
    CheckRemoteDebuggerPresent(v4, &pbDebuggerPresent);
    if ( pbDebuggerPresent )            // 4. API: CheckRemoteDebuggerPresent()
    {
      printf("But detected remotedebug.\n");
      exit(1);
    }
    v13 = GetTickCount();
    for ( i = 0; i == 100; ++i )
      Sleep(1u);
    v16 = 1000;
    if ( GetTickCount() - v13 > 1000 )  // 5. 检测时间差
    {
      printf("But detected debug.\n");
      exit(1);
    }
    lpFileName = "\\\\.\\Global\\ProcmonDebugLogger";
    if ( CreateFileA("\\\\.\\Global\\ProcmonDebugLogger", 0x80000000, 7u, 0, 3u, 0x80u, 0) != (HANDLE)-1 )
    {
      printf("But detect %s.\n", &lpFileName);      // 6. 检测ProcessMonitor
      exit(1);
    }
    v11 = sub_401130();     // 7. API: CreateToolhelp32Snapshot()检测进程
    if ( v11 == 1 )
    {
      printf("But detected Ollydbg.\n");
      exit(1);
    }
    if ( v11 == 2 )
    {
      printf("But detected ImmunityDebugger.\n");
      exit(1);
    }
    if ( v11 == 3 )
    {
      printf("But detected IDA.\n");
      exit(1);
    }
    if ( v11 == 4 )
    {
      printf("But detected WireShark.\n");
      exit(1);
    }
    if ( sub_401240() == 1 )    // 8. 通过vmware的I/O端口进行检测
    {
      printf("But detected VMware.\n");
      exit(1);
    }
    v17 = 1;
    v20 = 1;
    v12 = 0;
    v19 = 1 / 0;
    ms_exc.registration.TryLevel = -2;  // 9. SEH
    printf("But detected Debugged.\n");
    exit(1);
  }
  printf("password is wrong.\n");
  return 0;
}

我在代码里写了注释, 列出了其中所使用的 9 个保护技术部分. 我们来逐一分析一下吧.

比较明文字符串

printf("Input password >");
v3 = (FILE *)sub_40223D();
fgets(&v23, 64, v3);
strcpy(v21, "I have a pen.");
v22 = strncmp(&v23, v21, 0xDu); // 1. 直接比较明文字符串与输入字符串
if ( !v22 )  {
    ......
}

这里就是输出Input password >. 然后用fgets()获取用户输入的字符串, 将I have a pen.复制到v21的缓冲区中, 然后用strncmp比对用户输入与I have a pen.的内容, 并将比较结果返回给v22. 以下会根据v22, 也就是根据输入的password是否正确, 而进行跳转.

IsDebuggerPresent()

puts("Your password is correct.");
if ( IsDebuggerPresent() == 1 )     // 2. API: IsDebuggerPresent()
{
    puts("But detected debugger!");
    exit(1);
}

显然, 输入的password正确, 就会输出提示Your password is correct.. ??? 不觉得奇怪吗. 难道I have a pen.就是我们的 flag 了吗? 不不不当然不是. 这其实是一个陷阱, 既然你知道了I have a pen.那么就肯定有通过某种逆向手段在对程序进行分析. 所以接下来的部分就开始进行一些反调试或其他的检测手段 (实际中也可以出现这样的陷阱).

一开始的是IsDebuggerPresent(), 根据返回结果判断是否存在调试. 如果不太清楚的话, 可以返回去看 IsDebuggerPresent()

NtGlobalFlag

接下来是检测NtGlobalFlag这个字段的标志位. 通过检测 PEB 的字段值是否为0x70来检测调试器, 如果不太清楚的话, 可以返回去看 NtGlobalFlag

if ( sub_401120() == 0x70 )         // 3. 检测PEB的0x68偏移处是否为0x70. 检测NtGlobalFlag()
{
    puts("But detected NtGlobalFlag!");
    exit(1);
}

那我们来简单看一下sub_401120()好了

int sub_401120()
{
  return *(_DWORD *)(__readfsdword(48) + 0x68) & 0x70;
}

0x68是 PEB 的NtGlobalFlag字段对应偏移值. 0x70FLG_HEAP_ENABLE_TAIL_CHECK (0x10), FLG_HEAP_ENABLE_FREE_CHECK (0x20)FLG_HEAP_VALIDATE_PARAMETERS (0x40)这三个标志

CheckRemoteDebuggerPresent

/*  BOOL WINAPI CheckRemoteDebuggerPresent(
 *    _In_    HANDLE hProcess,
 *    _Inout_ PBOOL  pbDebuggerPresent
 *  );
 */
v4 = GetCurrentProcess();
CheckRemoteDebuggerPresent(v4, &pbDebuggerPresent);
if ( pbDebuggerPresent )            // 4. API: CheckRemoteDebuggerPresent()
{
    printf("But detected remotedebug.\n");
    exit(1);
}

这里我顺便在注释里列出了CheckRemoteDebuggerPresent()这个 API 的函数原型. 如果检测到调试器的存在, 会将pbDebuggerPresent设置为一个非零值. 根据其值检测调试器 (CheckRemoteDebuggerPresent() 篇)

时间差检测

v13 = GetTickCount();
for ( i = 0; i == 100; ++i )    // 睡眠
    Sleep(1u);
v16 = 1000;
if ( GetTickCount() - v13 > 1000 )  // 5. 检测时间差
{
    printf("But detected debug.\n");
    exit(1);
}

GetTickCount会返回启动到现在的毫秒数, 循环里光是sleep(1)就进行了 100 次, 也就是 100 毫秒. 两次得到的时间作差如果大于 1000 毫秒, 时差明显大于所耗的时间, 也就间接检测到了调试.

ProcessMonitor

lpFileName = "\\\\.\\Global\\ProcmonDebugLogger";
if ( CreateFileA("\\\\.\\Global\\ProcmonDebugLogger", 0x80000000, 7u, 0, 3u, 0x80u, 0) != (HANDLE)-1 )
{
    printf("But detect %s.\n", &lpFileName);      // 6. 检测ProcessMonitor
    exit(1);
}

这里通过检测设备文件\\\\.\\Global\\ProcmonDebugLogger来检测ProcessMonitor

检测进程名

这里通过执行sub_401130()函数来检测进程, 并根据检测到的不同进程, 返回相应的值.

v11 = sub_401130();     // 7. API: CreateToolhelp32Snapshot()检测进程
if ( v11 == 1 )
{
    printf("But detected Ollydbg.\n");
    exit(1);
}
if ( v11 == 2 )
{
    printf("But detected ImmunityDebugger.\n");
    exit(1);
}
if ( v11 == 3 )
{
    printf("But detected IDA.\n");
    exit(1);
}
if ( v11 == 4 )
{
    printf("But detected WireShark.\n");
    exit(1);
}

我们就来看一下sub_401130()函数

signed int sub_401130()
{
  PROCESSENTRY32 pe; // [sp+0h] [bp-138h]@1
  HANDLE hSnapshot; // [sp+130h] [bp-8h]@1
  int i; // [sp+134h] [bp-4h]@1

  pe.dwSize = 296;
  memset(&pe.cntUsage, 0, 0x124u);
  hSnapshot = CreateToolhelp32Snapshot(2u, 0);
  for ( i = Process32First(hSnapshot, &pe); i == 1; i = Process32Next(hSnapshot, &pe) )
  {
    if ( !_stricmp(pe.szExeFile, "ollydbg.exe") )
      return 1;
    if ( !_stricmp(pe.szExeFile, "ImmunityDebugger.exe") )
      return 2;
    if ( !_stricmp(pe.szExeFile, "idaq.exe") )
      return 3;
    if ( !_stricmp(pe.szExeFile, "Wireshark.exe") )
      return 4;
  }
  return 0;
}

这里使用了 API: CreateToolhelp32Snapshot来获取当前的进程信息. 并在 for 循环里依次比对. 如果找到指定的进程名, 就直接返回相应的值. 然后根据返回值跳转到不同的分支里.

检测 VMware

检测 VMware 也是检测一些特征. 根据检测的结果进行判断.

if ( sub_401240() == 1 )    // 8. 通过vmware的I/O端口进行检测
{
    printf("But detected VMware.\n");
    exit(1);
}

来看sub_401240()函数.

signed int sub_401240()
{
  unsigned __int32 v0; // eax@1

  v0 = __indword(0x5658u);
  return 1;
}

这是 VMware 的一个 “后门”I/O 端口, 0x5658 = "VX". 如果程序在 VMware 内运行, 程序使用In指令通过0x5658端口读取数据时, EBX寄存器的值就会变为0x564D5868(0x564D5868 == "VMXh")

看 IDA 反编译出的伪 C 代码并不很直观地体现这点, 我们看汇编代码就清楚了

.text:0040127A                 push    edx
.text:0040127B                 push    ecx
.text:0040127C                 push    ebx
.text:0040127D                 mov     eax, 564D5868h   //  <------
.text:00401282                 mov     ebx, 0
.text:00401287                 mov     ecx, 0Ah
.text:0040128C                 mov     edx, 5658h   //  <------
.text:00401291                 in      eax, dx
.text:00401292                 pop     ebx
.text:00401293                 pop     ecx
.text:00401294                 pop     edx

更多阅读: E-cards don?t like virtual environments

SEH

v17 = 1;
v20 = 1;
v12 = 0;
v19 = 1 / 0;    // 9. SEH
ms_exc.registration.TryLevel = -2;
printf("But detected Debugged.\n");
exit(1);

接下来这一段, 很奇怪不是吗. 这里v19 = 1 / 0;明显是不合常理的, 会产生一个除零异常. 而后面的ms_exc.registration.TryLevel = -2;这是解除异常, TryLevel=TRYLEVEL_NONE (-2) . 来看汇编代码.

.text:004015B8                 mov     [ebp+var_88], 1
.text:004015C2                 mov     [ebp+var_7C], 1
.text:004015C9                 mov     [ebp+var_9C], 0
.text:004015D3                 mov     [ebp+ms_exc.registration.TryLevel], 0
.text:004015DA                 mov     eax, [ebp+var_7C]
.text:004015DD                 cdq
.text:004015DE                 idiv    [ebp+var_9C]
.text:004015E4                 mov     [ebp+var_80], eax
.text:004015E7                 mov     [ebp+ms_exc.registration.TryLevel], 0FFFFFFFEh
.text:004015EE                 jmp     short loc_40160A

在这里的idiv [ebp+var_9C]触发异常后就由程序注册的异常处理函数接管, 而如果没有在异常处理程序入口设下断点的话, 程序就容易跑飞.

获取 flag

但整个看下了. 怎么感觉关 flag 一点事都没有了呢? 还有没有记起之前在字符串窗口看到的疑似是那个待解密的 flag 的字符串? 实际上由于 IDA 反编译的限制, 使得反编译出的伪 C 代码并不正确. 比如在最后一段的printf("But detected Debugged.\n");这里, 我们来看具体的汇编代码.

.text:00401627                 call    sub_4012E0
.text:0040162C                 movzx   eax, ax
.text:0040162F                 mov     [ebp+var_A8], eax
.text:00401635                 cmp     [ebp+var_A8], 0      // <------
.text:0040163C                 jz      short loc_401652     // <------
.text:0040163E                 push    offset aButDetectedD_2 ; "But detected Debugged.\n"
.text:00401643                 call    _printf
.text:00401648                 add     esp, 4
.text:0040164B                 push    1               ; int
.text:0040164D                 call    _exit

实际上这一段代码并没有被 IDA 反编译出来. 而loc_401652位置则是一串代码, 亮点在于使用了一个MessageBoxA的函数. 而且函数参数之一就是我们的待解密 flag. 那么我们就可以在输入I have a pen.后, 在if ( !v22 )跳转的汇编代码部分, 将其手动改为跳转到 flag 解密及弹出messagebox的部分运行, 让程序自己帮忙解密并输出, 就可以了.

操作如下图所示:

这里是输入I have a pen.后的跳转部分, 因为正常跳转到的部分, 全是一些检测调试的内容, 所以我们直接跳到代码解密的部分. 也就是00401663的位置.

00401663以上的mov-cmp-jnz也是一个验证部分, 就不管了, 直接跳到00401663这里的mov ecx, 7这里运行解密代码, 并顺着执行MessageBoxA()弹出消息框, 拿到 flag


文章作者: 杰克成
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 杰克成 !
评论
  目录