脱壳的艺术(翻译:The Art of Unpacking)

文章目录
  1. 1. 摘要
  2. 2. 1.概述
  3. 3. 2.调试器检查技术
    1. 3.1. 2.1 PEB.BeingDebugged标志:IsDebuggerPresent()
    2. 3.2. 2.2 PEB.NtGlobalFlag,Heap Flags
    3. 3.3. 2.3 DebugPort:CheckRemoteDebuggerPresent()/NtQueryInformationProcess()
    4. 3.4. 2.4 调试器中断
    5. 3.5. 2.8 DebugObject:NtQueryObject()
    6. 3.6. 2.9 调试窗口
    7. 3.7. 2.10 调试进程
    8. 3.8. 2.11 驱动设备
    9. 3.9. 2.12 OllyDbg:Guard Pages
  4. 4. 3.断点和PARCHING检测技术
    1. 4.1. 3.1 软件断点检测
    2. 4.2. 3.2 硬件断点检测
    3. 4.3. 3.3 通过代码校验和检测Patch(壳代码完整性校验)
  5. 5. 4.反分析技术
    1. 5.1. 4.1. 加密和压缩
    2. 5.2. 4.2. 垃圾指令和代码扩展
    3. 5.3. 4.3. 反反汇编
  6. 6. 5. 对抗调试技术
    1. 6.1. 5.1. 通过异常使调试器错误执行和停止执行
    2. 6.2. 6.7. 虚拟机
  7. 7. 7. 工具
    1. 7.1. 7.1. OllyDbg
    2. 7.2. 7.2. Ollyscript
    3. 7.3. 7.3 Olly Advanced
    4. 7.4. 7.4. OllyDump
    5. 7.5. 7.5. ImpRec
  8. 8. 8. 参考

摘要

脱壳是一门艺术,同时也是一种智力挑战,在逆向领域脱壳是最令人头脑兴奋的智力游戏之一。在某些情况下,逆向工作者需要对操作系统内部原理非常熟悉,这样才能识别和绕过壳开发人员的反逆向分析技术,耐心和聪明是成功脱壳的两个主要条件。这些挑战包括壳开发人员开发壳,同时在另一方面也包括逆向工作研究者如何绕过壳的这些保护。
这篇文章主要介绍了壳开发人员使用的一些反逆向工程技术,同时也讨论如何绕过和关闭这些保护的技术和一些公开的工具。当遇到被壳保护的恶意代码的时候,这些信息能让逆向工作研究人员尤其是恶意代码分析人员更加容易的去识别这些技术,然后绕过这些反逆向分析技术去进行下一步的分析工作。这篇文章的第二个目的是让一些开发人员能够去使用这些技术在一定程度上减缓被逆向分析的可能,给代码增加更多的保护。当然,遇到逆向分析高手的时候,什么方法都没辙。
关键词:逆向工程,壳,保护,反调试,反逆向分析

1.概述

在逆向工程领域,壳是最有趣的难题之一,在脱壳的过程中,逆向工作者会学到很多东西,包括操作系统原理、逆向技巧、逆向工具和技术。壳(本文中壳这个术语包含压缩壳和保护壳)是被用于保护可执行文件免受分析的一种技术。壳被商业软件合法使用,以防止信息泄露,篡改和盗版。不幸的是,恶意代码也以相同的目的使用壳,但目的却是恶意的。
因为有太多被加壳的恶意代码样本,研究者和恶意代码分析者为了分析这些样本开始去研究脱壳技术。同时,新的反逆向分析技术也不断的被用到壳的保护中,阻止逆向分析者去分析这些被保护的应用程序,同时也阻止这些被保护的应用程序被脱壳。经过不断的轮回发展,新的反逆向技术不断被开发同时在另一边逆向分析者也不断研究各种脱壳技巧、技术和工具。
这篇文章主要介绍壳使用的各种反逆向分析技术,同时也讨论如何去绕过和关闭壳的这些保护的工具和技术。在另一方面,内存dump等技术能轻松绕过某些壳的保护,那反逆向分析技术是不是不需要了呢?反调试技术还是必须的,在某些情况下被保护的代码需要被动态调试分析,如:

  • 部分保护的代码需要动态分析才能绕过,以便内存dump和输入表重建
  • 需要深入分析被壳保护的代码,以便能提取特征用于反病毒软件使用

除此之外,当遇到恶意代码使用反逆向分析技术去阻止被动态调试和分析恶意函数的时候,熟悉反逆向分析技术也是很有用的。
本文不是一个完整的反逆向分析技术列表,只包含了那些在壳里面经常被使用、有意思的技术。如果想学习更多的反逆向分析及逆向技术,建议读者参考本文最后一节提到的参考链接和书籍。
作者希望读者能够在本文中找到一些有用的技术、技巧。希望大家能在脱壳中找到乐趣。

2.调试器检查技术

本小结主要列举壳用于检测进程是否被调试的以及一个调试器是否运行的技术。调试器检测技术包含从非常简单的(明显)的利用APIs检测到利用内核对象(kernel objects)检测。

2.1 PEB.BeingDebugged标志:IsDebuggerPresent()

检测调试器最基本的方式是检查PEB(Process Environment Block,在windbg中的结构为_PEB)的BeingDebugged标志位。可以直接使用kernel32!IsDebuggerPresent()函数取这个标志位,检查进程是否被用户态调试器调试。
下面的代码展示了IsDebuggerPresent()这个API实际执行的代码,通过访问TEB(Thread Environment Block,在windbg中结构为_TEB)结构可以获得PEB的地址,BeingDebugged标志位在PEB偏移0x2处。

1
mov     eax,large fs:18h
mov     eax,[eax+30h]
movzx   eax,byte ptr [eax+2]
retn

为了防止逆向工作者在IsDebuggerPresent()函数下断点或则patch这个函数,一些壳会直接通过检查PEB的BeingDebugged标志而不调用IsDebuggerPresent()。
例子
下面的例子展示了使用IsDebuggerPresent()和PEB.BeingDebugged检测调试器的区别:

1
;call kernel32!IsDebuggerPresent()
call    [IsDebuggerPresent]
test    eax,eax
jnz     .debugger_found

;check PEB.BeingDebugged directly
mov     eax,dword [fs:0x30] ;EAX=TEB.ProcessENvironmentBlock
movzx   eax,byte [eax+0x02] ;AL= PEB.BeingDebugged
test    eax,eax
jnz     .debugger_found

因为这种检测方式是非常明显的,所以壳一般会使用花指令或则反汇编方法去混淆这部分代码,这部分将会在后面的章节讨论。
解决方案
可以通过手工清零PEB.BeingDebugged标志位非常容易的绕过这种检测调试器的方法。在OllyDbg中可以通过在数据窗口按Ctrl+G快捷键然后输入fs:[30]查看PEB结构数据。
除此之外,可以通过Ollyscript(在工具章节会提到)命令“dbh” patch上面提到的标志位:

1
dbh

最后,olly Advanced插件暂时没有选项去设置BeingDebugged标志位清零。

2.2 PEB.NtGlobalFlag,Heap Flags

PEB.NtGlobalFlag:NtGlobalFlag是PEB中另一个标志位(PEB偏移0x68,名称为NtGlobalFlag)可以用来检测程序是否被调试器加载。正常情况下,当一个进程没有被调试NtGlobalFlag的值为0x0,当一个进程被调试的是否这个值一般为0x70,值为0x70是因为以下的标志位被设置:

  • FLG_HEAP_ENABLE_TAIL_CHECK(0x10)
  • FLG_HEAP_EBABLE_FREE_CHECK(0x20)
  • FLG_HEAP_VALIDATE_PARAMETERS(0x40)

这些标志位在函数ntdll!LdrpInitializeExecutionOptions()中被设置,同时需要注意PEB.NtGlobalFlag的默认值可以通过gflags.exe工具设置或则通过在注册表中设置键值改变:

1
HKLM\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution Options

Heap Flags:由于NtGlobalFlag的某些标志位被设置,创建堆的时候某些标志位也会被设置,这些设置主要在函数ntdll!RtlCreateHeap()函数中。最典型的,非调试状态下进程(PEB.ProcessHeap)创建的初始堆的标志位Flags和ForceFlags将会被设置为0x02(HEAP_GROWABLE)和0x0。然而当进程被调试的时候这两个标志位将会被设置为0x50000062(依赖NTGlobalFLag的值)和0x40000060(Flags和0x6001007D与运算得到)。默认情况下,当一个进程被调试的时候,堆创建的时候堆的这些标志位将会被设置:

  • HEAP_TAIL_CHECKING_ENABLED(0x20)
  • HEAP_FREE_CHECKING_ENABLED(0x40)

例子
下面的代码展示了当PEB.NtGlobalFlag不等于0的时候,PEB.ProcessHeap中一些标志位将会被设置:

1
;ebx = PEB
mov     ebx,[fs:0x30]

;check if PEB.NtGlobalFlag!=0
cmp     dword [ebx+0x68],0
jne     .debugger_found

;eax = PEB.ProcessHeap
mov     eax,[ebx+0x18]

;check PEB.ProcessHeap.Flags
cmp     dword [eax+0x0c],2
jne     .debuger_found

;check PEB.ProcessHeap.ForceFlags
cmp     dword [eax+0x10],0
jne     .debugger_found

解决方法
解决方法就是将PEB.NtGlobalFlag和PEB.HeapProcess标志位设置为未调制状态下的值,下面展示了一个通过ollyscript去这只标志位的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var     peb
var patch_addr
var process_heap

//retrieve PEB via a hardcoded TEB address (first thread:0x7ffde000)
mov peb,[7ffde000+30]

//patch PEB.NtGlobalFlag
lea patch_addr,[peb+68]
mov [patch_addr],0

//patch PEB.ProcessHeap.Flag/ForceFlags
mov process_heap,[peb+18]
lea patch_addr,[process_heap+0c]
mov [patch_addr],2
lea patch_addr,[process_heap+10]
mov [patch_addr],0

olly Advanced 插件也没有选项去设置PEB.NtGlobalFLags和PEB.ProcessHeap标志位。

2.3 DebugPort:CheckRemoteDebuggerPresent()/NtQueryInformationProcess()

Kernel32!CheckRemoteDebuggerPresent()是另一个可以检测进程是否正在被调试的API,这个API内部调用ntdll!NtQueryInformationProcess()参数ProcessInformationClass设置为ProcecssDebugPort(7)。而在内核中,NtQueryInformationProcess()通过查询EPROCESS(windbg中_EPROCESS结构)中的DebugPort字段。当被用户态调试器调试时,DebugPort的值将为非零,而ProcessINformation的值将会被设置为0xFFFFFFFF,否则正常情况下ProcessInformation的值为0x0。
Kernel32!CheckRemoteDebuggerPresent()函数有两个参数,第一个参数为进程句柄,第二个参数为一个指向BOOL类型的一个指针,当进程被调试的时候这个值将会被设置为TRUE。

1
2
3
4
BOOL CheckRemoteDebuggerPresent(
HANDLE hProcess,
PBOOL pbDebuggerPresent
)

Ntdll!NtQueryInformationProcess()有5个参数,为了检测调试器,参数ProcessInformationClass将会被设置为ProcessDebugPort(7):

1
2
3
4
5
6
7
NTSTATUS NTAPI NtQueryInformationProcess(
HANDLE ProcessHandle,
PROCESSINFOCLASS ProcessInformationClass,
PVOID ProcessInformation,
ULONG ProcessInformationLength,
PULONG RturnLength
)

例子
下面的例子展示了调用CheckRemoteDebuggerPresent()和NtQueryInformationProcess()去检测一个进程是否在被调试:

1
;using kernel32!CheckRemoteDebuggerPresent()
lea     eax,[.bDebuggerPresent]
push    eax                     ;pbDebuggerPresent
push    0xffffffff              ;hProcess
call    [CheckRemoteDebuggerPresent]
cmp     dword [.bDebuggerPresent], 0
jne     .debugger_found

;using ntdll!NtQueryInformationProcess(ProcessDebugPort)
lea     eax,[.dwReturnPort]
push    eax                 ;ReturnLength
push    4                   ;ProcessInformationLength
lea     eax,[.dwDebugPort]
push    eax                 ;ProcessInformation
push    ProcessDebugPort    ;ProcessInformationClass(7)
push    0xffffffff
call    [NtQUeryInformationProcess]
cmp     dword [.dwDebugPort],0
jne     .debugger_found

解决方法
一种解决方案是在NtQueryInformationProcess()函数返回的地方设置断点,当断点命中后,将ProcessInformation设置为0,下面是一个自动设置该值为0的ollyscript脚本:

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
var     bp_NtQueryInformationProcess

//set a breakpoint handler
eob bp_handler_NtQueryInformationProcess

//set a breakpoint where NtQueryInformationProcess returns
gpa "NtQueryInformationProcess", "ntdll.dll"
find $RESULT, #C21400# //return 14
mov bp_NtQueryInformationProcess,$RESULT
bphws bp_NtQueryInfomationProcess,"x"
run

bp_handler_NtQueryInformationProcess:
//ProcessInformationClass ==ProcessDebugPort?
cmp [esp+8],7
jne bp_handler_NtQueryInformationProcess_continue

//patch ProcessInformation to 0
mov patch_addr,[esp+c]
mov [patch_addr],0

//clear breakpoint
bphwc bp_NtQUeryInformationProcess

bp_handle_NtQueryInformationProcess_continue:
run

Olly Advanced插件有一个选项去patch NtQueryInformationProcess(),实现通过注入操作NtQUeryInformationProcess()返回值的代码实现。

2.4 调试器中断

当软件断点指令INT3(断点)和INT1(单步)在调试器中触发的时候,正常情况下异常处理函数不会被调用因为调试器会首先处理这些中断产生的异常。因此壳可以在一场处理函数中设置一个标志位,如果这个标志位在中断指令执行后没有被设置,那就意味着这个进程正在被调试。另外一些壳会使用API代替中断指令,因为kernel32!DebugBreak()内部会调用INT3。
例子
在本例中当异常处理函数被调用的时候将会通过线程上线文(CONTEXT record)将EAX设置为0xFFFFFFFF:

1
;set exception handler
push    .exception_handler
push    dword [fs:0]
mov     [fs:0],esp

;reset flag(EAX) invoke int3
xor     eax,eax
int3

;restore exception handler
pop     dword [fs:0]
add     esp,4

;check if the flag had been set
test    eax, eax
je      .debugger_found

.exception_handler:
;EAX = ContextRecord
mov     eax,[esp+0xc]
;set flag (COntextRecord.EAX)
mov     dword [eax+0xb0],0xfffffff
;set ContextRecord.EIP
inc     dword [eax+0xb8]
xor     eax,eax
retn

解决方案
当使用ollydbg时,当由于调试中断引发步入或则执行终止,应该首先确定异常处理函数的地址(通过View->SEH Chain),在异常处理函数地址设置断点。然后使用Shift+F9,当单步调试或则设置断点调试时这些异常将会被被一场处理函数处理。这样前面设置的断点就会命中,就可以找到一场处理函数。


设置忽略调试异常

另一种解决方案是设置将单步/断点异常自动传递给一场处理函数,可以通过ollydbg选项设置:Options->Debugging Options->Exceptions->”Ignor following exceptions”,选中“INT 3 Breaks”和“Single-step break”。

### 2.5 时间检测
当进程正在被调试时,将会花几个CPU时钟周期去处理调试事件、单步调试等。壳就可以利用这多出来的指令执行时间的差异去检测调试器,如果时间花费比正常执行更长,则这个进程很可能正在被调试。
例子
下面是一个利用时间检测调试器的简单例子,使用RDTSC(Read Time-Stamp Counter)在指令执行前后读取时间,然后计算执行时间增量。这里这个增量为0x200,具体的增量取决于两个RDTSC指令之间需要执行的指令的数量。
1
rdtsc
mov     ecx,eax
mov     ebx,edx
;...more instructions
nop
push    eax
pop     eax
nop
;...mor instructions

;compute delta between RDTSC instructions
rdtsc

;Check high order bits
cmp     ebx,ebx
ja      .debugger_found
;Check  low order bits
sub     eax,ecx
cmp     eax,0x200
ja      .debugger_found


时间变化还可以使用kernel32!GetTickCount()或则检查SharedUserData(_KUSER_SHARED_DATA)结构的偏移0xC处的TickCountLow和TickCountMultiplier。
当使用垃圾代码或则其他混淆技术去隐藏基于时间检测调试器技术时,将会很难去定位,特别是使用RDTSC指令时。
解决方案
一种绕过的方案是定位到时间检测的代码然后避免在这部分代码单步调试。调试者也可以在这在时间增量比较代码之前设置断点然后直接执行而不是单步,直到断点命中。除此之外也可以在GetTickCount()函数中设置断点,以确定它在那里被调用或修改GetTickCount的返回值。
Olly Advanced提供了另外一种解决方案,主要动过内核驱动程序实现,关键实现如下:
- 设置CR4寄存器的TSD标志位。当TSD标志位被设置时,如果RDTSC指令在内核模式之外被执行,GP(General Protection)异常将会被触发。
- 设置中断描述符表(IDT),以便处理GP异常,过滤RTDSC指令执行,如果是因为执行RDTSC指令而出发GP异常的话,只需要在前一次返回的时间戳上加1返回即可。

需要注意的是因为驱动可能会造成系统不稳定,因此最好在非生产机或则虚拟机进行这个实验。

### 2.6. SeDebugPrivilege
默认情况下,进程的SeDebugPrivilege权限是没有启用的,但是当一个进程是被Ollydbg或则Windbg启动的时候,进程的SeDebugPrivilege权限是启用的。这是因为调试器一般会调整自己的token会启用SeDebugPrivilege权限,并且当调试器调试启动进程的时候权限会继承,因此被调试进程的SeDebugPrivilege也会被启用。
一些壳会通过试图去打开CSRSS.exe进程的句柄间接判断是否启用了SeDebugPrivilege权限判断是否正在被调试,如果能够打开CSRSS.exe进程就意味进程的SeDebugPrivilege权限已经被启用,同时暗示该进程有可能正在被调试。因为CSRSS.exe进程的安全描述符(security descriptor)只允许SYSTEM权限去打开,但是如果一个进程又SeDebugPrivilege权限,它就可以打开任意进程而无视折别描述符。注意默认情况下SeDebugPrivilege权限只能被赋予给Administrators组用户。
例子
1
;query for the pid of csrss.exe
call    [CsrGetProcessId]

;try to open the csrss.exe process
push    eax
push    FALSE
push    PROCESS_QUERY_INFORMATION
call    [OpenProcess]

;if OpenProcess() was successful
;process is probably being debugged
test     eax,eax
jnz      .debugger_found


上面的例子通过ntdll!CsrGetProcessId()获取csrss.exe的pid,但是壳可能会通过进程枚举的方式去获得csrss.exe的pid,如果OpenProcess()成功,则意味SeDebugPrivilege权限被启用,进程有可能正在被调试。
解决方案
一种解决方案是在ntdll!NtOpenProcess()返回处设置一个断点,如果传递的参数是进程csrss.exe的pid,一旦断点命中,将返回值EAX设置为0xC0000022(STATUS_ACCESS_DENIED)。

### 2.7 父进程检测
正常情况下一个进程的父进程应该是explorer.exe(如进程被双击执行),如果进程的父进程不是explorer.exe则进程有可能被其他进程启动则表明进程有可能正在被调试。
一种检测方式如下:
- 1.获得当前进程的pid通过TEB.ClientId或者使用GetCurrentProcessId()
- 2.通过Process32First/Next()进程遍历的方式记录下explorer.exe的PID(比较PROCESSENTRY32.szExeFile)和当前进程父进程的PID(PROCESSENTRY32.th32ParentProcessID)
- 3.如果当前进程的父进程PID不是explorer.exe的则表明当前进程可能正在被调试。
然而需要注意的是如果进程是通过命令行或则默认shell不是explorer.exe的时候这种调试器检测的方式将会出错。
解决方案
一种解决方案是使用Olly Advance去将Process32Next()执行设置为失败,这样壳列举进程的代码将会执行失败,就有可能因为列举进程失败跳过PID检查
。实现的方式是将EAX置零然后返回的代码去patch kernel32!Process32NextW()函数的入口。


2.8 DebugObject:NtQueryObject()

另一种反调试技术是检测当前系统调试器是否在运行,而不是检测当前进程是否正在被调试。
在逆向论坛中讨论的一个有趣的方式是检查DebugObject内核对象的数量。这种检测方式的原理是只要一个进程被调试,在内核中肯定会为调试会话创建一个DebugObject内核对象。
DebugObject的数量可以通过ntdll!NtQueryObject()查询所有Object类型的信息获得。NtQueryObject需要5个参数,为了查询所有对象类型,ObjectHandle参数需要设置为NULL,ObjectInformationClass需要设置为ObjectAllTypeInformation(3)。

1
NTSTATUS NTAPI NtQueryObject(
                            HANDLE ObjectHandle,
                            OBJECT_INFORMATION_CLASS ObjectInformationClass,
                            PVOID ObjectInformation,
                            ULONG Length,
                            PULONG ResultLength
                            )

NtQueryObject会返回一个OBJECT_ALL_INFORMATION结构,结构中类型为ObjectTypeInformation的参数NumberOfObjectsTypes是Object类型数量的一个数组。
然后检测函数会遍历ObjectTypeInformation类型的数组,数组中元素结构如下:

1
2
3
4
5
6
typedef struct _OBJECT_TYPE_INFORMATION {
[00] UNICODE_STRING TypeName;
[08] ULONG TotalNumberOfHandles;
[0C] ULONG TotalNumberOfObjects;
... more fields ...
}

比较TypeName字段类型为UNICODE字符串是否为“DebugObject”,然后检查TotalNumberOfObjects或则TotalNumberOfHandles是否为非0。
解决方案
和NtQueryInformationProcess()的解决方案相似,可以在NtQueryObject()返回的地方设置一个断点,然后修改返回的OBJECT_ALL_INFORMATION结构。
具体操作方式就是将NumberOfobjectsTypes字段设置为0,这样就能阻止壳去遍历ObjectTypeInformation数组。可以通过创建一个Ollydbg脚本完成,类似之前的NtQueryInformationProcess()的解决方案。同样的,可以通过Olly advanced插件去修改NtQueryObject()这个API,如果查询类型是ObjectAllTypeInformation的话会将返回的buffer清零。

2.9 调试窗口

调试器窗口是否存在是系统中调试器是否运行的一个标志,因为调试器会创建的特定Class名称(OLLYDBG是OllyDbg,Windbg是WinDbgFrameClass)的窗口,调试器窗口可以通过user32!FindWindow()或者user32!FindWindowEx()找到。
例子
下面的例子会通过Findwindow()找Ollydbg、Windbg创建的窗口名称去检测Ollydbg或者Windbg是否在运行

1
push    NULL
push    .szWindowClassOllyDbg
call    [FindWindowA]
test    eax,eax
jnz     .debugger_found
push    NULL
push    .szWindowClassWinDbg
call    [FindWindowA]
test    eax,eax
jnz     .debugger_found
.szWindowClassOllyDbg db "OLLYDBG",0
.szWindowClassWinDbg db "WinDbgFrameClass",0

解决方案
一种解决方案是在FindWindow()/FindWindowEx()入口设置断点。当断点命中的时候,修改参数lpClassName字符串使程序返回失败。另一种解决方案是直接将返回值设置为NULL。

2.10 调试进程

另一种检测调试器的方法是枚举系统中所有的进程,检查进程名是否是调试器(如OllyDbg.exe、windbg.exe等),实现方式也特别简单,只需要调用Process32First/Next()然后检查进程名是否是一个调试器。
一些壳也会使用kernel32!ReadProcessMemory()读取进程的内存然后搜索调试器相关的字符串(如OLLYDBG),以防止逆向者重命名调试器文件名。一旦检查到一个调试器,壳会展示一条错误信息,然后静默退出或则终止调试器。
解决方案
和父进程名检查相似,这里的解决方案也可以patch kernel32!Process32NextW()使函数总是返回失败阻止壳去枚举进程。

2.11 驱动设备

检查系统中是否运行内核调试器经典的方法是去访问内核调试器的设备驱动程序,这项技术非常简单,只需要调用kernel31!CreateFile()打开内核调试器(如SoftICE)公开的设备驱动名称。
例子
一个简单的检测如下:

1
push    NULL
push    0
push    OPEN_EXISTING
push    NULL
push    FILE_SHARE_READ
push    GENERIC_READ
push    .szDeviceNameNtice
call    [CreateFileA]
cmp     eax,INVALID_HANDLE_VALUE
jne     .debugger_found
.szDeviceNameNtice db "\\.\NTICE",0

一些版本的SoftICE也会在设备名称后面加数字就会使这种检测方法失效。在逆向论坛中提到了一种解决方法是在设备名称后面暴力附加数字直到正确的设备名称被找到。一些比较新版本的壳也会通过设备驱动名称去检测系统监控程序,如Regmon和FileMon。
解决方案
一种简单解决方案是在Kernel32!CreateFileW()中设置断点,当断点命中的时候,修改FileName参数或则修改返回值为INVALID_HANDLE_VALUE (0xFFFFFFFF)。

2.12 OllyDbg:Guard Pages

这种检测方式只针对OllyDbg,因为这和Ollydbg设置内存访问/写断点的特点相关。
除了硬件断点和软件断点之外,OllyDbg还允许内存访问/写断点;这种类型的断点是通过Guard Pages实现的。简单的说,guard pages提供了一种当内存被访问的时候能被通知的方法。
Guard pages通过PAGE_GUARD内存熟悉设置,如果guard page所在的地址被访问将会触发一个STATUS_GUARD_PAGE_VIOLATION (0x80000001)异常。壳检测的原理是如果进程正在被OllyDbg调试,当guard page被访问的时候将没有异常抛出,这个异常访问将会被当作一个内存断点。
例子
在下面的示例代码中,分配了一段内存,在这段内存中存一段代码然后设置这段内存属性为PAGE_GUARD属性。然后将标志位(EAX)初始化为0,然后在这段page pages中执行代码触发STATUS_GUARD_PAGE_VIOLATION异常,如果正在被OllyDbg调试,标志位不会被改变,因为异常处理函数不会被调用。

1
; set up exception handler
push    .exception_handler
push    dword [fs:0]
mov     [fs:0], esp

; allocate memory
push    PAGE_READWRITE
push    MEM_COMMIT
push    0x1000
push    NULL
call    [VirtualAlloc]
test    eax,eax
jz      .failed
mov     [.pAllocatedMem],eax

; store a RETN on the allocated memory
mov     byte [eax],0xC3

; then set the PAGE_GUARD attribute of the allocated memory
lea     eax,[.dwOldProtect]
push    eax
push    PAGE_EXECUTE_READ | PAGE_GUARD
push    0x1000
push    dword [.pAllocatedMem]
call    [VirtualProtect]

; set marker (EAX) as 0
xor     eax,eax

; trigger a STATUS_GUARD_PAGE_VIOLATION exception
call    [.pAllocatedMem]

; check if marker had not been changed (exception handler not called)
test    eax,eax
je      .debugger_found
:::

.exception_handler
;EAX = CONTEXT record
mov     eax,[esp+0xc]
;set marker (CONTEXT.EAX) to 0xffffffff
; to signal that the exception handler was called
mov     dword [eax+0xb0],0xffffffff
xor     eax,eax
retn

解决方案
因为guard pages会触发一个异常,逆向者可以故意触发一个异常,这样一场处理函数会被调用。在上面的例子中逆向者可以使用“INT3”加“RETN”替换RETN指令,一旦INT3被执行,可以通过Shift+F9强制调试器执行异常处理函数,然后异常处理函数执行之后,EAX将会被设置为正确的值,最后RETN指令也会正常执行。
如果异常处理函数确实是检查STATUS_GUARD_PAGE_VIOLATION异常,逆向者可以在异常处理函数中设置一个断点,然后修改ExceptionRecord参数,具体来说就是将ExceptionCode手动设置为STATUS_GUARD_PAGE_VIOLATION。

3.断点和PARCHING检测技术

这一节将会列举常见检测软件断点、硬件断点及Patch的方法。

3.1 软件断点检测

软件断点是通过将断点地址处的指令修改为0xcc(INT3断点)实现的。壳可以在壳代码或则API中扫描0xcc指令检测软件断点。
例子
一种检测方式可以像下面一样简单:

1
cld
mov     edi,Protected_Code_Start
mov     ecx,Protected_Code_End - Protected_Code_Start
mov     al,0xcc
repne   scasb
jz      .breakpoint_found

一些壳会对比较指令做一些操作,使得检测指令不是很明显,例如:

1
if(byte XOR 0x55 == 0x99) then breakpoint found
Where: 0x99 == 0xCC XOR 0x55

解决方案
如果软件断点容易被检测可以使用硬件断点替代,要在系统API代码上设置断点,如果壳会检测API上的断点,可以UNICODE版本API上设置断点,因为ANSI版本API最终会调用UNICODE版本API(如使用LoadLibraryExW替代LoadLibraryA),或则使用Native API替代(如ntdll!LdrLoadDll)。

3.2 硬件断点检测

另一种类型的断点是硬件断点,硬件断点通过调试寄存器(debug registers)设置,寄存器包含Dr0到Dr7。其中DR0-Dr3用于记录断点地址信息,最多4个,DR6用来标识断点是否被触发,而Dr7用于包含用于控制4个断点的标识,如启用/禁用断电及读/写点。
因为在Ring3不能直接访问调试寄存器,因此硬件断点需要一些代码才能检测。因为CONTEXT结构中包含调试寄存器,所以壳主要利用CONTEXT结构检测硬件断点。CONTEXT结构可以通过传递给异常处理函数的参数ContextRecord访问。
例子
下面是一个查询调试寄存器的示例代码

1
; set up exception handler
push    .exception_handler
push    dword [fs:0]
mov     [fs:0], esp

; eax will be 0xffffffff if hardware breakpoints are identified
xor     eax,eax

; throw an exception
mov     dword [eax],0

; restore exception handler
pop     dword [fs:0]
add     esp,4

; test if EAX was updated (breakpoint identified)
test    eax,eax
jnz     .breakpoint_found
:::

.exception_handler
;EAX = CONTEXT record
mov     eax,[esp+0xc]

;check if Debug Registers Context.Dr0-Dr3 is not zero
cmp     dword [eax+0x04],0
jne     .hardware_bp_found
cmp     dword [eax+0x08],0
jne     .hardware_bp_found
cmp     dword [eax+0x0c],0
jne     .hardware_bp_found
cmp     dword [eax+0x10],0
jne     .hardware_bp_found
jmp     .exception_ret

.hardware_bp_found
;set Context.EAX to signal breakpoint found
mov     dword [eax+0xb0],0xffffffff
.exception_ret
;set Context.EIP upon return
add     dword [eax+0xb8],6
xor     eax,eax
retn

一些壳会将解密密钥一部分放在调试寄存器,这样调试寄存器会被初始化为一个特定的值或则为0,如果被解密的代码是需要被脱壳的导入表或则可执行代码,当这些调试寄存器被修改了,壳解密将会失败并且会因为无效指令导致异常而意外终止。
解决方案
如果软件断点没有被检测,可以使用软件断点代替。同时也可以使用ollydbg的读/写内存断点代替。如果需要设置API断点可以在UNICODE/Native版本API设置软件断点。

3.3 通过代码校验和检测Patch(壳代码完整性校验)

代码完整性校验是校验壳代码是否被修改,修改可能是因为patch了壳反调试函数或则设置了软件断点。壳完整性校验通过代码校验和实现,校验和算法可以是简单的校验和也可以是复杂的散列算法。
例子
下面是一个计算校验和的简单计算方式:

1
mov     esi,Protected_Code_Start
mov     ecx,Protected_Code_End - Protected_Code_Start
xor     eax,eax

.checksum_loop
movzx   ebx,byte [esi]
add     eax,ebx
rol     eax,1
inc     esi
loop    .checksum_loop
cmp     eax,dword [.dwCorrectChecksum]
jne     .patch_found

解决方案
如果是软件断点被校验和检测到了,可以用硬件断点替代。如果因为patch代码被检测到了,逆向人员可以在patch位置代码设置内存访问断点确定校验和检测函数的地址,一旦找到了校验和函数就可以将校验和修改为正常值或则直接修改校验和失败的标志位。

4.反分析技术

反分析技术主要目的是减缓逆向人员分析和理解壳代码和被壳保护的代码,主要技术如加密/压缩、垃圾指令、乱序代码(permutation)及反汇编技术。这些反分析技术主要目的是去混淆代码、使人知难而退和浪费分析者的时间,这就需要分析者有耐心、足够聪明等特性才能绕过这些反分析技术。

4.1. 加密和压缩

加密和压缩式反分析最基本的技术,最初的目的是防止分析者毫无困难的使用反汇编工具分析被保护的代码。
加密:壳通常会加密壳代码及被保护的代码。加密算法因壳的不同而各异,从非常简单的循环异或加密到运算量很大的复杂算法。一些多态型的壳在不同的样本会使用不同的加密算法,解密算法也不同,生成的每一个样本都非常不一样,并且可能影响壳识别工具的正确性。
解密函数通常是一个执行取数据、计算数据、存储数据的循环,下面是一个简单揭秘函数包含几条简单的XOR操作去解密一个被加密的DWORD值。

1
0040A07C LODS   DWORD PTR DS:[ESI]
0040A07D XOR    EAX,EBX
0040A07F SUB    EAX,12338CC3
0040A084 ROL    EAX,10
0040A087 XOR    EAX,799F82D0
0040A08C STOS   DWORD PTR ES:[EDI]
0040A08D INC    EBX
0040A08E LOOPD  SHORT 0040A07C ;decryption loop

下面是另一个多态壳解密函数

1
00476056 MOV    BH,BYTE PTR DS:[EAX]  //
00476058 INC    ESI
00476059 ADD    BH,0BD                //
0047605C XOR    BH,CL                 //
0047605E INC    ESI
0047605F DEC    EDX
00476060 MOV    BYTE PTR DS:[EAX],BH  //
00476062 CLC
00476063 SHL    EDI,CL
::: More garbage code
00476079 INC    EDX
0047607A DEC    EDX
0047607B DEC    EAX                   //
0047607C JMP    SHORT 0047607E
0047607E DEC    ECX                   //
0047607F JNZ    00476056 ;decryption loop  //

下面是另一个解密函数,和上面是同样的多态壳

1
0040C045 MOV    CH,BYTE PTR DS:[EDI]  //
0040C047 ADD    EDX,EBX
0040C049 XOR    CH,AL                //
0040C04B XOR    CH,0D9               //
0040C04E CLC
0040C04F MOV    BYTE PTR DS:[EDI],CH //
0040C051 XCHG   AH,AH
0040C053 BTR    EDX,EDX
0040C056 MOVSX EBX,CL
::: More garbage code
0040C067 SAR    EDX,CL
0040C06C NOP
0040C06D DEC    EDI                  //
0040C06E DEC    EAX                  //
0040C06F JMP    SHORT 0040C071
0040C071 JNZ    0040C045 ;decryption loop  //

上面两个多态壳解密代码例子,高亮(后面有//的)语句是主要的解密指令,剩下的指令都是些垃圾指令用来混淆逆向者的。注意寄存器是如何交换数据以及这两个加密函数是如何变换的。
压缩:压缩的主要目的是减小可执行文件代码和数据的大小,原始可执行文件被压缩后它的可读字符串也将会被压缩,这样也有了混淆的作用。壳使用压缩软法的情况:UPX使用NRV、LZMA,FSG使用aPLib,Upack使用LZMA,yoda使用LZO。一些压缩引擎是免费非商业用途的,当用于商业用途时需要许可/注册。
解决方案
解密和解压缩函数循环都很容易绕过,逆向者只需要在解密/解压缩循环结束的地方设置一个断点。不过需要注意的是一些壳在解密循环可能会包含断点检测代码。

4.2. 垃圾指令和代码扩展

垃圾指令:在壳的解码算法中插入垃圾指令是另一种混淆逆向分析者的有效办法。旨在隐藏代码真正的目的,包括解密函数、反分析函数如检测调试器。垃圾代码将检测调试器、断点、patch等代码隐藏在一大堆无用并且令人困惑的指令中,增加了这些反分析技术的有效性。除此之外,有效的垃圾代码看起来和正常代码很相似。
例子
下面是一个解密函数,在函数中插入了很多垃圾指令:

1
0044A21A JMP    SHORT sample.0044A21F
0044A21C XOR    DWORD PTR SS:[EBP],6E4858D
0044A223 INT    23
0044A225 MOV    ESI,DWORD PTR SS:[ESP]
0044A228 MOV    EBX,2C322FF0
0044A22D LEA    EAX,DWORD PTR SS:[EBP+6EE5B321]
0044A233 LEA    ECX,DWORD PTR DS:[ESI+543D583E]
0044A239 ADD    EBP,742C0F15
0044A23F ADD    DWORD PTR DS:[ESI],3CB3AA25
0044A245 XOR    EDI,7DAC77F3
0044A24B CMP    EAX,ECX
0044A24D MOV    EAX,5ACAC514
0044A252 JMP    SHORT sample.0044A257
0044A254 XOR    DWORD PTR SS:[EBP],AAE47425
0044A25B PUSH   ES
0044A25C ADD    EBP,5BAC5C22
0044A262 ADC    ECX,3D71198C
0044A268 SUB    ESI,-4
0044A26B ADC    ECX,3795A210
0044A271 DEC    EDI
0044A272 MOV    EAX,2F57113F
0044A277 PUSH   ECX
0044A278 POP    ECX
0044A279 LEA    EAX,DWORD PTR SS:[EBP+3402713D]
0044A27F DEC    EDI
0044A280 XOR    DWORD PTR DS:[ESI],33B568E3
0044A286 LEA    EBX,DWORD PTR DS:[EDI+57DEFEE2]
0044A28C DEC    EDI
0044A28D SUB    EBX,7ECDAE21
0044A293 MOV    EDI,185C5C6C
0044A298 MOV    EAX,4713E635
0044A29D MOV    EAX,4
0044A2A2 ADD    ESI,EAX
0044A2A4 MOV    ECX,1010272F
0044A2A9 MOV    ECX,7A49B614
0044A2AE CMP    EAX,ECX
0044A2B0 NOT    DWORD PTR DS:[ESI]

上面这个例子中有用的指令只有下面这几句:

1
0044A225 MOV    ESI,DWORD PTR SS:[ESP]
0044A23F ADD    DWORD PTR DS:[ESI],3CB3AA25
0044A268 SUB    ESI,-4
0044A280 XOR    DWORD PTR DS:[ESI],33B568E3
0044A29D MOV    EAX,4
0044A2A2 ADD    ESI,EAX
0044A2B0 NOT    DWORD PTR DS:[ESI]

指令变换(Code Permutation):code permutation是一种壳使用的比较高级的反分析技术。通过指令变换一些简单的指令将会被转换成一串相同功能的但是复杂的指令。
下面是一个简单的code permutation的例子,简单指令如下:

1
mov     eax,ebx
test    eax,eax

经过转换的等价功能的指令如下:

1
push    ebx
pop     eax
or      eax,eax

结合垃圾指令,指令变换是一种有效的技术手段去减缓逆向者去理解被保护的代码。
例子
为了说明指令变换变换的作用,下面是一个检测调试器的函数,函数已经被指令变换并且在指令间插入了垃圾代码,代码如下:

1
004018A3 MOV    EBX,A104B3FA
004018A8 MOV    ECX,A104B412
004018AD PUSH   004018C1
004018B2 RETN
004018B3 SHR    EDX,5
004018B6 ADD    ESI,EDX
004018B8 JMP    SHORT 004018BA
004018BA XOR    EDX,EDX
004018BC MOV    EAX,DWORD PTR DS:[ESI]
004018BE STC
004018BF JB     SHORT 004018DE
004018C1 SUB    ECX,EBX
004018C3 MOV    EDX,9A01AB1F
004018C8 MOV    ESI,DWORD PTR FS:[ECX]
004018CB LEA    ECX,DWORD PTR DS:[EDX+FFFF7FF7]
004018D1 MOV    EDX,600
004018D6 TEST   ECX,2B73
004018DC JMP    SHORT 004018B3
004018DE MOV    ESI,EAX
004018E0 MOV    EAX,A35ABDE4
004018E5 MOV    ECX,FAD1203A
004018EA MOV    EBX,51AD5EF2
004018EF DIV    EBX
004018F1 ADD    BX,44A5
004018F6 ADD    ESI,EAX
004018F8 MOVZX  EDI,BYTE PTR DS:[ESI]
004018FB OR     EDI,EDI
004018FD JNZ    SHORT 00401906

上面的例子仅仅是一个简单的检测调试器的函数:

1
00401081 MOV    EAX,DWORD PTR FS:[18]
00401087 MOV    EAX,DWORD PTR DS:[EAX+30]
0040108A MOVZX  EAX,BYTE PTR DS:[EAX+2]
0040108E TEST   EAX,EAX
00401090 JNZ    SHORT 00401099

解决方案
垃圾指令和指令变换是一种无聊的浪费逆向时间的方式。因此知道在混淆技术中的隐藏代码是否值得去理解是很重要的(如只执行解密,壳的初始化等)。
避免去跟踪调试这些被混淆的代码是在壳经常使用的API上设置断点(如VirtualAlloc/VirtualProtect/LoadLibrary/GetProcAddress等)或则使用API logging工具记录那些API被使用,然后将这些API作为调试跟踪的标记。如果在调试跟踪过程中出现了什么异常(如调试器/断点被检测),这时候才去跟踪调试这部分代码。此外可以通过设置内存读/写断点找到那些试图去修改/访问受保护进程特定内存的代码,而不是通过大量调试跟踪代码去找到这部分函数。
最后,在虚拟机中使用ollydbg调试的时候可以通过快照记录特定的调试状态,如果调试过程中出了问题可以通过快照恢复到特定的调试状态。

4.3. 反反汇编

混淆汇编是另一种混淆分析的方式。反汇编是一种有效的反静态分析技术,如果结合垃圾指令和指令变换会使其更加有效。
反汇编的一个例子是在代码中插入一个字节的垃圾指令,然后添加一个条件分支使代码执行到垃圾指令。然而条件分支的条件永远都是FALSE。因此垃圾指令永远都不会执行,但是会触发反汇编工具去反汇编垃圾指令所在的地址,最后导致反汇编错误输出。
例子
下面是一个简单的通过PEB检测调试器的代码,PEB.BeingDebugged检查代码中使用了一些反反汇编代码。高亮部分代码是主要的代码,剩余部分主要是反反汇编代码,通过使用垃圾指令0xff和假的条件跳转使反汇编错误反汇编垃圾指令,反汇编输出如下:

1
;Anti-disassembly sequence #1
push    .jmp_real_01
stc
jnc     .jmp_fake_01
retn
.jmp_fake_01:
db      0xff
.jmp_real_01:
;--------------------------
mov eax,dword [fs:0x18]           //

;Anti-disassembly sequence #2
push    .jmp_real_02
clc
jc      .jmp_fake_02
retn
.jmp_fake_02:
db      0xff
.jmp_real_02:
;--------------------------
mov     eax,dword [eax+0x30]
movzx   eax,byte [eax+0x02]
test    eax,eax
jnz     .debugger_found

windbg的反汇编输出:

1
0040194a 6854194000     push 0x401954
0040194f f9             stc
00401950 7301           jnb image00400000+0x1953 (00401953)
00401952 c3             ret
00401953 ff64a118       jmp dword ptr [ecx+0x18]
00401957 0000           add [eax],al
00401959 006864         add [eax+0x64],ch
0040195c 194000         sbb [eax],eax
0040195f f8             clc
00401960 7201           jb image00400000+0x1963 (00401963)
00401962 c3             ret
00401963 ff8b40300fb6   dec dword ptr [ebx+0xb60f3040]
00401969 40             inc eax
0040196a 0285c0750731   add al,[ebp+0x310775c0]

ollydbg反汇编输出:

1
0040194A 68 54194000    PUSH 00401954
0040194F F9             STC
00401950 73 01          JNB SHORT 00401953
00401952 C3             RETN
00401953 FF64A1 18      JMP DWORD PTR DS:[ECX+18]
00401957 0000           ADD BYTE PTR DS:[EAX],AL
00401959 0068 64        ADD BYTE PTR DS:[EAX+64],CH
0040195C 1940 00        SBB DWORD PTR DS:[EAX],EAX
0040195F F8             CLC
00401960 72 01          JB SHORT 00401963
00401962 C3             RETN
00401963 FF8B 40300FB6  DEC DWORD PTR DS:[EBX+B60F3040]
00401969 40             INC EAX
0040196A 0285 C0750731  ADD AL,BYTE PTR SS:[EBP+310775C0]

最后是IAD的反汇编输出:

1
0040194A            push (offset loc_401953+1)
0040194F            stc
00401950            jnb short loc_401953
00401952            retn
00401953 ; --------------------------------------------------------------
00401953
00401953 loc_401953: ; CODE XREF: sub_401946+A
00401953 ; DATA XREF: sub_401946+4
00401953            jmp dword ptr [ecx+18h]
00401953            sub_401946 endp
00401953
00401953 ; --------------------------------------------------------------
00401957            db 0
00401958            db 0
00401959            db 0
0040195A            db 68h ; h
0040195B            dd offset unk_401964
0040195F            db 0F8h ; °
00401960            db 72h ; r
00401961            db 1
00401962            db 0C3h ; +
00401963            db 0FFh
00401964 unk_401964 db 8Bh ; ï ; DATA XREF: text:0040195B
00401965            db 40h ; @
00401966            db 30h ; 0
00401967            db 0Fh
00401968            db 0B6h ; ?
00401969            db 40h ; @
0040196A            db 2
0040196B            db 85h ; à
0040196C            db 0C0h ; +
0040196D            db 75h ; u

注意分析这三个反汇编器/调试器是如何被陷入反反汇编中的,这些将会让逆向工作者很烦恼和困惑。这里仅仅描述了一种反反汇编的例子,还有几种其他的反反汇编技术。另外可以通过宏编写反反汇编代码,可以使汇编代码看起来比较整洁。
建议读者参考Eldad Eliam编写的一边优秀的关于逆向的书籍(Reversing: Secrects of Reverse Engineering),详细学习关于反反汇编及其他逆向技术。

5. 对抗调试技术

这部分将列举壳主动对抗调试器的技术如当进程正在被调试时会突然中止或者断点失效等,和之前描述的技术相同,如果结合使用其他反分析技术将这部分代码隐藏会使这些技术更加有效。

5.1. 通过异常使调试器错误执行和停止执行

一直线性的跟踪调试代码可以使逆向者更加轻松的掌握和理解代码的目的。因此,一些壳会采用多种技术使跟踪代码非线性的且耗时的。
一种常用的方式是在壳在恢复代码过程中抛出异常,通过抛出异常,这就需要逆向者明白异常执行的时候EIP将指向何处,以及异常处理函数执行之后EIP指向的位置。
此外,异常也是壳实现重复终止脱壳恢复代码的一种方式。当异常抛出时,如果进程正在被调试,调试器会暂停执行解包代码。
壳通常使用结构化异常处理(SEH)机制处理异常,较新版本的壳也会使用VEH异常处理。
例子
下面是一个示例代码,当循环执行后溢出标志位被ROL指令设置会抛出一个溢出异常(使用INTO指令),实现代码错误执行。但是因为溢出异是一个trap异常,EIP将执行JMP指令。如果逆向者使用OllyDbg并且逆向者没有将异常传递给异常处理函数(使用Shift+F7/F8/F9)并持续使用单步调试,逆向者将会进入到一个死循环中。

1
; set up exception handler
push    .exception_handler
push    dword [fs:0]
mov     [fs:0], esp

; throw an exception
mov     ecx,1
.loop:
rol     ecx,1
into
jmp     .loop
; restore exception handler
pop     dword [fs:0]
add     esp,4
:::
.exception_handler
;EAX = CONTEXT record
mov     eax,[esp+0xc]
;set Context.EIP upon return
add     dword [eax+0xb8],2
xor     eax,eax
retn

壳常抛出的异常有访问异常(0xC0000005)、断点异常(0xC0000003)和单步异常(0xC0000004)。
解决方案




对于壳使用异常捕获方式实现无条件跳转的代码,OllyDbg可以设置将异常自动传递给一场处理函数,配置方式为ptions ->
Debugging Options -> Exceptions。上图展示了配置的异常处理的截图对话框。逆向者也可以添加勾选框中未出现的自定义异常处理。
对在异常处理中执行重要操作的壳,逆向者可以在异常处理中设置断点,异常处理函数的地址可以在Ollydbg中使用View->SEH Chain查看,然后使用Shift+F7/F8/F9将执行权限交给异常处理函数。

### 5.2. 屏蔽输入
为了阻止逆向者操作调试器,壳会在主要的脱壳函数执行时使用user32!BlockInput()屏蔽键盘和鼠标输入,将这个操作隐藏在垃圾指令和反反汇编代码中,如果逆向者不能识别,壳的这种操作将会很有效。如果BlockInput执行了,系统将会像失去响应一样,会令逆向者非常困惑。
一个典型的例子就是当逆向者在函数GetProcAddress()中设置了一个断点,然后跳过几个垃圾代码的执行,这时候壳已经执行了BloockInput()。一旦GetProcAddress()断点命中,逆向者会突然发现自己不能操作调试器并会困惑刚刚发生了什么。
例子
BlockInput()需要一个布尔型参数fBlockIt。如果fBlockIt为true,键盘和鼠标事件将会被屏蔽,如果为false,键盘和鼠标屏蔽将会解锁。
1
; Block input
push    TRUE
call    [BlockInput]
; ...Unpacking code...
; Unblock input
push    FALSE
call    [BlockInput]


解决方案
幸运的是这里有一个简单的方法绕过,直接patch BlockInput()的入口为RETN,下面是一个patch user32!BlockInput()入口的ollyscript。
1
gpa     "BlockInput", "user32.dll"
mov     [$RESULT], #C20400# //retn 4


Olly Advanced插件也有patch BlockInput()的选项。除此之外,也可以手动的使用CTRL+ALT+DELETE解锁输入屏蔽。

### 5.3. 对调试器隐藏线程
这项技术实现通过ntdll!NtSetInformationThread(),这个API主要常常被用来设置线程优先级。然而,这个API也可以用来阻止调试事件发送到调试器。
NtSetInformationThread()的参数如下所示,为了实现在调试器中隐藏线程,ThreadInformationClass参数需要设置为TheadHideFromDebugger (0x11),ThreadHandle常设置为当前线程(0xfffffffe):
1
NTSTATUS NTAPI NtSetInformationThread(
                    HANDLE ThreadHandle,
                    THREAD_INFORMATION_CLASS ThreadInformationClass,
                    PVOID ThreadInformation,
                    ULONG ThreadInformationLength
                    );


在NtSetInformationThread函数内部,ThreadHideFromDebugger参数将会设置ETHREAD(_ETHREAD)的HideThreadFromDebugger字段。一旦被设置了,函数DbgkpSendApiMessage()将不会被调用,DbgkpSendApiMessage函数的主要目的是将事件发往调试器。
例子
一个典型的调用NtSetInformationThread()函数的例子如下:
1
push    0 ;InformationLength
push    NULL ;ThreadInformation
push    ThreadHideFromDebugger ;0x11
push    0xfffffffe ;GetCurrentThread()
call    [NtSetInformationThread]


解决方案
可以在ntdll!NtSetInformationThread()设置断点,一旦命中,逆向者可以操作EIP阻止API调用到内核中。也可以通过ollyscript脚本自动完成这个操作。另外Olly Advanced插件也有patch ntdll!NtSetInformationThread()函数的选项,如果参数ThreadInformationClass为HideThreadFromDebugger,函数只会直接返回而不会进入内核。

### 5.4. 禁用断点
另一种攻击调试器的方式为禁用调试器。为了禁用硬件断点,壳可以通过CONTEXT结构去修改调试寄存器。
例子
在下面例子中,调试寄存器将会被清零,实现方法是在异常处理函数中修改传递过来的CONTEXT记录。
1
; set up exception handler
push    .exception_handler
push    dword [fs:0]
mov     [fs:0], esp

; throw an exception
xor     eax,eax
mov     dword [eax],0
; restore exception handler
pop     dword [fs:0]
add     esp,4
:::
.exception_handler
;EAX = CONTEXT record
mov     eax,[esp+0xc]
;Clear Debug Registers: Context.Dr0-Dr3,Dr6,Dr7
mov     dword [eax+0x04],0
mov     dword [eax+0x08],0
mov     dword [eax+0x0c],0
mov     dword [eax+0x10],0
mov     dword [eax+0x14],0
mov     dword [eax+0x18],0
;set Context.EIP upon return
add     dword [eax+0xb8],6
xor     eax,eax
retn


另一方面,对于软件断点,壳可以只搜索INT3(0xCC)指令,然后使用任意操作码替换;这样操作之后软件断点会失效并且原始的指令也会被破坏。
解决方案
显然,如果硬件断点会被检测到,可以使用软件断点替代,反之亦然。如果这两种断点都会被检测到,可以尝试使用ollydbg的内存访问/写断点替代。

### 5.5 未处理异常过滤
MSDN文档指出如果一个异常到达未处理异常过滤器(kernel32!UnhandledExceptionFilter)且该进程没有被调试,未处理异常过滤器将会调用顶层异常处理函数,该函数是函数通过kernel32!SetUnhandledExceptionFilter()被设置为顶层异常处理函数,是kernel32!SetUnhandledExceptionFilter()的参数。壳可以设置一个异常处理函数然后抛出一个异常,如果进程正在被调试调试器会作为第二优先级接受到这个异常,否则代码控制权将传递给异常过滤函数并继续执行。
例子
下面是一个使用SetUnhandledExceptionFilter()设置顶层异常过滤器使用的示例,然后会抛出一个访问异常。如果进程正在被调试,调试器将会作为第二优先级接收到这个一场,否则异常将会设置CONTEXT.EIP然后继续执行。
1
;set the exception filter
push    .exception_filter
call    [SetUnhandledExceptionFilter]
mov     [.original_filter],eax

;throw an exception
xor     eax,eax
mov     dword [eax],0
;restore exception filter
push    dword [.original_filter]
call    [SetUnhandledExceptionFilter]
:::
.exception_filter:
;EAX = ExceptionInfo.ContextRecord
mov     eax,[esp+4]
mov     eax,[eax+4]
;set return EIP upon return
add     dword [eax+0xb8],6
;return EXCEPTION_CONTINUE_EXECUTION
mov     eax,0xffffffff
retn


一些壳也可能直接手动使用kernel32!_BasepCurrentTopLevelFilter设置异常过滤取代SetUnhandledExceptionFilter(),利用这种方式防止逆向者使用API断点。
解决方案
有趣的是,在kernel32!UnhandledExceptionFilter()函数内部使用ntdll!NtQueryInformationProcess (ProcessDebugPort)去判断当前进程是否正在被调试,然后决定是否调用注册的一场过滤器。因此这里的解决方案和DebugPort的检测方案相同。

### 5.6 Ollydbg:OutputDebugString()字符格式Bug
这里的调试器攻击只针对Ollydbg。Ollydbg已知的一个格式化字符串漏洞,会造成调试器崩溃或者执行任意代码,这个bug可以通过一个不正确的字符串参数传递给kernel32!OutputDebugString()而触发,这个bug仍然存在当前版本的OllyDbg(1.10)并且仍然没有被修复。
例子
这个简单的例子会导致OllyDbg抛出一个访问异常或则意外终止:
1
push    .szFormatString
call    [OutputDebugStringA]
:::
.szFormatString db "%s%s",0


解决方案
解决方案是直接Patch kernel32!OutputDebugStringA()函数头部,让函数仅仅执行一个RETN。

## 6. ADVANCED 和其他技术
本小结将列举一些高级的以及一些其他未在之前反逆向小结提及的技术。

### 6.1 进程注入



进程注入已经成为一些壳的功能,有了这个功能,壳的脱壳stub会选择特定的宿主进程(如自己进程/explorer.exe/iexplorer.exe等),然后将脱壳后的可执行程序注入到宿主进程中。



上面是一个支持进程注入功能的壳的屏幕截图。
恶意代码利用壳的这种特性绕过允许白名单进程联网的防火墙。
壳执行进程注入的一种方法如下所示:

- 1.创建一个宿主作为一个挂起的子进程。使用CREATE_SUSPENDED标志kernel32!CreateProcess()创建进程。这样创建的初始线程将会被创建并被挂起,DLLs这会还没有开始加载,因为加载函数(ntdll!LrdInitializeThunk)还没有被调用。初始线程的的线程上下文被设置,如寄存器信息、PEB地址、宿主进程的入口点。
- 2.调用kernel32!GetThreadContext(),获取子进程的的Context。
- 3.通过CONTEXT.EBX获取获取子进程的PEB地址。
- 4.通过PEB.ImageBase(PEB+0x8)获得子进程的映像基地址。
- 5.使用ntdll!NtUnmapViewOfSection()参数为指向子进程映像基地址的指针卸载子进程的内存空间数据。
- 6.脱壳stub使用kernel32!VirtualAllocEx()在紫禁城中分配内存空间,dwsize参数等于脱壳后的可执行文件大小。
- 7.使用kernel32!WriteProcessMemory()将脱壳后的可执行文件PE头及每个节写入到子进程内存空间。
- 8.更新子进程的PEB.ImageBase匹配脱壳后的可执行文件的基地址。
- 9.使用kernel32!SetThreadContext()修改子进程的初始线程的CONTEXT.EAX为脱壳后的可执行文件的入口地址。
- 10.使用kernel32!ResumeThread()恢复子进程运行。

为了调试被寄生的子进程的入口点,逆向者可以在被脱壳可执行文件包含入口节被写入到子进程的时候在函数WriteProcessMemory()中设置一个断点,然后将patch脱壳可执行文件的入口点为一个死循环(0xEB 0xFE)。当子进程的初始线程被恢复执行的时候,子进程将会在入口点进入一个死循环,然后逆向者可以使用调试器挂上子进程,恢复入口点的代码,并继续调试。

### 6.2 拦截调试器(Debugger Blocker)
Armadillo壳引入了一种被称为Debugger Blocker的特性。这种特性可以防止逆向者使用调试器attach被保护的进程。这种保护功能是通过windows提供的调试功能实现的。



具体来说就是,壳的脱壳stub会充当一个调试器(父进程),执行调试/控制包含脱壳后可执行文件的子进程。
因为被保护的进程已经正在被调试了,当调试器使用kernel32!DebugActiveProcess()挂接时会失败因为native API ntdll!NtDebugActiveProcess()会返回STATUS_PORT_ALREADY_SET错误码。在函数NtDebugActiveProcess()内部失败的原因是该进程的EPROCESS的DebugPort已经被设置。
为了去挂上被保护的进程进行调试,在几个逆向论坛中发布了一种解决方案,可以在父进程的进程上下文中调用kernel32!DebugActiveProcessStop()。可以使用调试器挂上父进程调试,然后在函数kernel32!WaitForDebugEvent()上设置断点,一旦断点命中,就使用代码注入执行DebugActiveProcessStop(ChildProcessPID)。执行成功后就可以使用调试器调试被保护的进程。

### 6.3 TLS回调
壳使用的另一种技术是在实际入口点代码执行之前执行代码。通过线程本地回调实现(Thread Local Storage,TLS)。壳可能会通过TLS实现调试器检测和代码解密,这样逆向者就不能调试这些函数。
TLS可以通过PE解析工具识别,如pedump。如果可执行文件存在TLS目录,使用pedump将可以在PE文件的Data Directory entries看到。
1
Data Directory
EXPORT rva: 00000000 size: 00000000
IMPORT rva: 00061000 size: 000000E0
:::
TLS rva: 000610E0 size: 00000018                       //
:::
IAT rva: 00000000 size: 00000000
DELAY_IMPORT rva: 00000000 size: 00000000
COM_DESCRPTR rva: 00000000 size: 00000000
unused rva: 00000000 size: 00000000


然后可以看到TLS目录实际的内容,AddressOfCallBacks字段会指向一个以null为结束符的回调函数数组。
1
TLS directory:
StartAddressOfRawData: 00000000
EndAddressOfRawData: 00000000
AddressOfIndex: 004610F8
AddressOfCallBacks: 004610FC      //
SizeOfZeroFill: 00000000
Characteristics: 00000000


在本例中,相对虚拟地址RVA 0x4610fc指向回掉函数指针(0x490f43和0x44654e)。



默认情况下,Ollydbg加载样本文件后会停在入口点。因为TLS回调试在入口点调用之前执行,OllyDbg应该重新配置以便能够停在TLS回调执行之前。
可以通过Options -> Debugging Options -> Events -> Make first pause at -> System breakpoint设置以便能够断在ntdll.dll中。



设置后,OllyDbg会断在函数ntdll! _LdrpInitializeProcess()中,该函数在ntdll!_LdrpRunInitializeRoutines()函数执行TLS回调之前。这样就可以在TLS回调函数中设置断点并调试。
更多关于PE文件格式的信息,包括pedump的二进制文件和源代码可以在下面的链接中找到:An In-Depth Look into the Win32 Portable Executable File Format by Matt Pietrek(http://msdn.microsoft.com/msdnmag/issues/02/02/PE/default.aspx)、An In-Depth Look into the Win32 Portable Executable File Format, Part 2 by Matt Pietrek(https://msdn.microsoft.com/msdnmag/issues/02/03/PE2/)
最新版本的PE文件格式信息:Microsoft Portable Executable and Common Object File Format Specification(http://www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx)

### 6.4 偷字节
壳偷的字节基本都是被保护可执行文件代码的一部分(通常是入口点的少量代码),壳会删除这部分代码并在分配的内存中执行这部分代码。这是一种保护可执行文件的一种方式,如果被保护的代码从内存中被dump出来,被偷的那部分指令将无法恢复。
下面是一个可执行文件原始的入口点代码:
1
004011CB MOV    EAX,DWORD PTR FS:[0]
004011D1 PUSH   EBP
004011D2 MOV    EBP,ESP
004011D4 PUSH   -1
004011D6 PUSH   0047401C
004011DB PUSH   0040109A
004011E0 PUSH   EAX
004011E1 MOV    DWORD PTR FS:[0],ESP
004011E8 SUB    ESP,10
004011EB PUSH   EBX
004011EC PUSH   ESI
004011ED PUSH   EDI


接下来是同一个样本但是前两行指令被Enigma保护壳偷了的代码:
1
004011CB POP    EBX       //
004011CC CMP    EBX,EBX   //
004011CE DEC    ESP       //
004011CF POP    ES        //
004011D0 JECXZ  SHORT 00401169
004011D2 MOV    EBP,ESP
004011D4 PUSH   -1
004011D6 PUSH   0047401C
004011DB PUSH   0040109A
004011E0 PUSH   EAX
004011E1 MOV    DWORD PTR FS:[0],ESP
004011E8 SUB    ESP,10
004011EB PUSH   EBX
004011EC PUSH   ESI
004011ED PUSH   EDI


下面是同一个样本被ASProtect保护壳偷了几个指令的代码,它在被偷字节的函数前添加了跳转指令,然后将偷来的字节和垃圾代码混合在一起,很难恢复被盗的指令。
1
004011CB JMP    00B70361         //
004011D0 JNO    SHORT 00401198
004011D3 INC    EBX
004011D4 ADC    AL,0B3
004011D6 JL     SHORT 00401196
004011D8 INT1
004011D9 LAHF
004011DA PUSHFD
004011DB MOV    EBX,1D0F0294
004011E0 PUSH   ES
004011E1 MOV    EBX,A732F973
004011E6 ADC    BYTE PTR DS:[EDX-E],CH
004011E9 MOV    ECX,EBP
004011EB DAS
004011EC DAA
004011ED AND    DWORD PTR DS:[EBX+58BA76D7],ECX


### 6.5 API重定向
API重定向是一种防止逆向者轻松重建被保护可执行文件导入表的方法。通常情况下,原始的导入表将会被销毁,调用API将会被重定向到一片被分配的内存的函数中,这些函数负责调用这些API。
下面的例子展示了调用kernel32!CopyFileA()这个API的代码:
1
00404F05 LEA    EDI,DWORD PTR SS:[EBP-20C]
00404F0B PUSH   EDI
00404F0C PUSH   DWORD PTR SS:[EBP-210]
00404F12 CALL   <JMP.&KERNEL32.CopyFileA


这种调用方式是一个执行JMP的stub,跳转地址是从导入表引用的:
1
004056B8 JMP DWORD PTR DS:[<&KERNEL32.CopyFileA>]


然而当ASProtect重定向kernel32!CopyFileA()这个API时,这个stub被替换成CALL一个函数,这个函数地址位于一片被分配出来的内存并最终会执行偷来的指令调用kernel32!CopyFileA():
1
004056B8   CALL     00D90000


下面是一个如何放置偷来的指令的说明。kernel32!CopyFileA()函数的前7条指令已经被复制。另外,在地址0x7c83005e处指向的call指令也被复制。然后通过RETN指令控制权被转到0x7c830063,该地址位于kernel32!CopyFileA()函数中。



一些壳还会还会尽可能将整个DLL映像文件映射到内存,然后将API调用重定向到这个DLL副本中。这种技术使在实际API下断点变得很困难。

### 6.6. 多线程壳
使用多线程壳时候,通常会启另一个线程执行一些重要的操作如解密被保护的数据。使用多线程壳会增加复杂性和理解代码的难度,因为调试跟踪壳代码将会比较复杂。
多线程壳的一个例子是PECrypt,它使用第二个线程来解密主线程获取的数据,这些线程通过事件对象进行同步。
PECrypt执行和线程同步如下所示:


6.7. 虚拟机

使用虚拟机的原因很简单:逆向者最终会弄清楚如何绕过/解决反调试和反分析技术,最终被保护的代码也会在内存中解密,这样就很容易静态分析。
随着虚拟机的出现,代码被保护的部分被转换为p-code,然后会转换为机器码执行。因此,原始的机器码被替换,被替换后的代码的理解难度也呈指数型增长。
下面是这个概念的一个相当简单的例子:




Oreans公司的CodeVirtualizer和StarForce等现代壳使用虚拟机这一概念来保护可执行文件。
虚拟机的解决方案显然不简单,要去分析p-code的结构及p-code如何被虚拟机翻译。并且通过获得的信息,设计反汇编程序解析p-code并将其翻译成机器码或则开发出可以理解的说明。
可以从下面的链接中找到一个开发p-code反汇编程序及关于虚拟机如何实现的例子:Defeating HyperUnpackMe2 With an IDA Processor Module, Rolf Rolles III
http://www.openrce.org/articles/full_view/28

7. 工具

本节将列举逆向工程师和恶意代码分析师用于分析壳和脱壳公开的可用工具。
免责声明:这些工具是第三方工具,本文作者不承担任何责任。工具可能会导致系统不稳定或其他可能影响系统的问题。永远建议在测试环境或则病毒分析环境测试这些工具。http://www.ollydbg.de/

7.1. OllyDbg

一个强大的ring3层调试器,经常被逆向工程师和恶意代码分析师使用。它的插件功能允许其他逆向工程师开发插件使逆向和脱壳更加容易。

7.2. Ollyscript

Ollydbg插件,允许自动设置和处理断点、patch代码和数据等功能。使用的脚本语言和汇编相似,在执行重复任务和脱壳时最有用。http://www.openrce.org/downloads/details/106/OllyScript

7.3 Olly Advanced

如果壳包含反逆向的代码,这个插件是一个逆向调试很有用的插件。它有几个选项可以绕过反调试技术和隐藏ollydbg不被壳检测到等。http://www.openrce.org/downloads/details/241/Olly_Advanced

7.4. OllyDump

成功脱壳后这个插件可用于dump和重建导入表。http://www.openrce.org/downloads/details/108/OllyDump

7.5. ImpRec

最后是另一个dump和重建导入表的工具。这是一个独立的工具,它提供了最强大的导入表重建功能。http://www.woodmann.com/crackz/Unpackers/Imprec16.zip

8. 参考

Books: Reverse Engineering, Software Protection

  • Reversing: Secrets of Reverse Engineering. E.Eilam. Wiley, 2005.
  • Crackproof Your Software, P.Cerven.No Starch Press, 2002.

Books: Windows and Processor Internals

Links: Windows Internals

Links: Reverse Engineering, Software Protection, Unpacking