盗版行为日益猖獗,严重影响到软件开发者和开发商的知识产权及利益,反盗版技术的重要性也越来越引起人们的重视。在反盗版技术中,起最大作用的当属反调试技术。然而传统的反调试技术都存在一个弱点:他们都在程序真正开始执行之后才采取反调试手段。实际上在反调试代码被执行前,调试器有大量的时间来影响程序的执行,甚至可以在程序入口处插入断点命令来调试程序。对于使用C/C++语言编译的程序来说,问题通常会更严重,在执行到main()函数之前,会执行C/C++编译器插入的很大一段代码,这也给调试器带来影响程序执行的机会。
本文讨论一种利用Windows提供的线程访问互斥机制,来实现一种在程序入口之前就执行反调试代码的技术。技术本身不会影响程序的执行,但能有效地防止调试器的调试。
1 TLS技术简介TLS全称为Thread Local Storage,是Windows为解决一个进程中多个线程同时访问全局变量而提供的机制。TLS可以简单地由操作系统代为完成整个互斥过程,也可以由用户自己编写控制信号量的函数。当进程中的线程访问预先制定的内存空间时,操作系统会调用系统默认的或用户自定义的信号量函数,保证数据的完整性与正确性[1]。
1.1 TLS回调函数当用户选择使用自己编写的信号量函数时,在应用程序初始化阶段,系统将要调用一个由用户编写的初始化函数以完成信号量的初始化以及其他的一些初始化工作。此调用必须在程序真正开始执行到入口点之前就完成,以保证程序执行的正确性。
TLS回调函数具有如下的函数原型:
void NTAPI TlsCallBackFunction(PVOID Handle, DWORD Reason, PVOID Reserve);
1.2 TLS的数据结构
Windows的可执行文件为PE格式,在PE格式中,专门为TLS数据开辟了一段空间,具体位置为IMAGE_NT_HEADERS.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]。其中DataDirectory的元素具有如下结构:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
对于TLS的DataDirectory元素,VirtualAddress成员指向一个结构体,结构体中定义了访问需要互斥的内存地址、TLS回调函数地址以及其他一些信息[2]。
2 具体实现及原理充分利用TLS回调函数在程序入口点之前就能获得程序控制权的特性,在TLS回调函数中进行反调试操作比传统的反调试技术有更好的效果。
2.1 在程序中使用TLSMicrosoft提供的VC编译器都支持直接在程序中使用TLS,下文都将使用VC进行操作。
要在程序中使用TLS,必须为TLS数据单独建一个数据段,用相关数据填充此段,并通知链接器为TLS数据在PE文件头中添加数据。为此,需要在程序源文件中添加如下代码[3]:
#pragma comment(linker, "/INCLUDE:__tls_used")
#pragma data_seg(".CRT$XLB")
PIMAGE_TLS_CALLBACK TlsCallBackArray[] = {
TlsCallBackFunction1,
TlsCallBackFunction2,
......
NULL
};
#pragma data_seg()
其中TlsCallBackArray数组中保存了所有的TLS回调函数指针。值得指出的是,数组必须以NULL指针结束,且数组中的每一个回调函数在程序初始化时都会被调用,程序员可按需要添加。但程序员不应当假设操作系统已何种顺序调用回调函数。如此则要求在TLS回调函数中进行反调试操作需要一定的独立性。
2.2 回调函数的具体实现 2.2.1 检测调试器在程序入口插入的断点PE可执行文件在初始化阶段,PE文件都会被完整地加载进入内存。通过分析PE文件头来获取程序的入口点,并对入口点的前一个或多个字节进行判断,以阻止调试器在程序入口点下断。在下面代码中,使用GetModuleHandle(NULL)来获得应用程序的加载基址;也可以模拟GetModuleHandle()手动读取程序的PEB来得到。但为了程序的通用性,这里仍然使用GetModuleHandle()。获得程序加载基址后,将其强制类转换为相关指针,并进行计算,最终得到程序的入口点在内存中的具体地址[3]:
IMAGE_DOS_HEADER *dos_head=(IMAGE_DOS_HEADER *)GetModuleHandle(NULL);
PIMAGE_NT_HEADERS32 nt_head=(PIMAGE_NT_HEADERS32)((DWORD)dos_head+(DWORD)dos_head->e_lfanew);
BYTE*OEP=(BYTE*)(nt_head->OptionalHeader.AddressOfEntryPoint+(DWORD)dos_head);
下面的代码则通过扫描程序入口点的20字节,判断其中有无调试断点,如有,则退出进程。
for(unsigned long index=0;index<20;index++){
If(OEP[index]==0xcc){
ExitProcess(0);
}
}
需要指出的是,在TLS回调函数执行时,VC运行库msvcrt.dll,mfc.dll等并未载入,不能使用C库的函数。如果有需要使用,应该使用LoadLibrary()函数载入相应的库并使用GetProcAddress()获得函数地址。但此类操作可能会导致调试器的相关事件触发,不建议进行此类操作。
2.2.2 使调试器窗口无效此处使用FindWindow()查找指定的窗口,并使用SetWindowsLong()将其超类化为不可用,具体代码见下:
HWND hd_od=FindWindow("ollydbg",NULL);
SetWindowLong(hd_od,GWL_STYLE,WS_DISABLED);
.........处理其他类型的调试器
这里需要说明的是,此类方法仅仅对ring3调试器才起作用。如SoftICE,WinDebug之类的内核调试器,因为根本不存在窗口,此种方法对其无效。
2.2.3 为程序执行所必需的元素进行初始化或分配空间这个操作的目的是为了防止盗版者通过直接将TLS数据清除的方法来避开反调试。当程序正常运行所需要的内存空间或数据必需经过TLS回调函数初始化时,盗版者不可以将程序的TLS数据清除。因为那样做带来的后果是程序运行根本不正常。
另外,如果这种初始化或分配空间的操作分散在各个TLS回调函数中完成,效果会更好。
2.2.4 堵塞输入此功能主要是为了应对一些未知调试器和一些不在程序入口点下断的调试器。
函数将首先检查user32.dll导出的BlockInput()函数,如果函数代码是被调试器修改过的,那么将直接退出,代码如下[4]:
BYTE *address=(BYTE *)GetProcAddress(LoadLibrary("user32.dll"),"BlockInput");;
bool modify=true;
for(int x=0;x<20;x++){
if(address[x]==0xff&&address[x+1]!=0xff){
modify=false;
break;
}
if(modify) ExitProcess(0);
检查过BlockInput()函数正确性之后,函数将调用BlockInput(TRUE),阻塞用户的鼠标和键盘输入。但函数接下来并不立即取消阻塞,而在main()函数中发起一个异常后再取消阻塞。
这么处理的理由很充分,在main()函数中发起异常将导致调试器捕获异常,并暂停等待用户输入。而此时用户输入是被锁定的,那么程序就相当于被变相锁死了,没有办法继续调试。而当调试器不存在时,代码中的__except()部分将直接获得执行权,并取消阻塞,程序正常运行。main函数中的具体代码如下:
__try{
__asm{
xor eax,eax
div eax,eax
xor eax,eax
}
ExitProcess(0);
}
__except(1,1){
BlockInput(FALSE);
}
值得说明的是,此次在main()函数中调用BlockInput()而不检查也是有原因的。如果此时的BlockInput()函数被调试器修改过,那么取消输入锁定将完全不能工作,那么之后的整个过程也是无法调试的。
如果进一步深入,还可以测试异常返回所用的时间,不论过长或者过短,都能够说明调试器的存在。此处不继续展开了。
2.2.5 创建监视线程此步目的是为了防止OllyDbg等调试器以进程附加的形式对程序进行调试。程序此步将创建一个子线程,监视调试器窗口的出现,如果发现调试器窗口,将其超类化为不可用。子线程的代码如下:
DWORD WINAPI Monitor(LPVOID s){
while(sign==TRUE){
HWND hd=FindWindow("ollydbg",NULL);
SetWindowLong(hd,GWL_STYLE,WS_DISABLED);
.......处理其它调试器窗口
Sleep(50);
}
return 0;
}
3 实际测试 3.1 测试直接执行
测试方法为直接在资源管理器中双击执行。
3.2 测试用调试器加载测试方法为分别使用VC自带的调试器加载调试和使用OllyDebug加载生成的程序进行调试。其中OllyDbg使用从看雪论坛下载的OllyDebug 1.10 CHS,并打开了所有的隐藏插件。
测试结果:两者调试的进程都直接退出,且没有输出任何信息。
图3 使用OllyDebug 1.10,打开全部反反调试插件调试,程序直接退出
测试方法为先在资源管理器中双击程序运行,然后打开OllyDebug试图附加进程。
测试结果:OllyDebug窗口直接失效。
4 总 结通过使用TLS技术作为反调试技术的载体,可以大大增强程序软件的反盗版能力。如果能结合传统技术,将使反调试技术发展至一新高度。
===============================================
在上篇中,我们会为读者介绍恶意软件常用来的反仿真技术。本文中,我们将向读者介绍恶意软件用以阻碍对其进行逆向工程的各种反调试技术,以帮助读者很好的理解这些技术,从而能够更有效地对恶意软件进行动态检测和分析。
一、反调试技术
反调试技术是一种常见的反检测技术,因为恶意软件总是企图监视自己的代码以检测是否自己正在被调试。为做到这一点,恶意软件可以检查自己代码是否被设置了断点,或者直接通过系统调用来检测调试器。
1.断点
为了检测其代码是否被设置断点,恶意软件可以查找指令操作码0xcc(调试器会使用该指令在断点处取得恶意软件的控制权),它会引起一个SIGTRAP。如果恶意软件代码本身建立了一个单独的处理程序的话,恶意软件也可以设置伪断点。用这种方法恶意软件可以在被设置断点的情况下继续执行其指令。
恶意软件也可以设法覆盖断点,例如有的病毒采用了反向解密循环来覆盖病毒中的断点。相反,还有的病毒则使用汉明码自我纠正自身的代码。汉明码使得程序可以检测并修改错误,但是在这里却使病毒能够检测并清除在它的代码中的断点。
2.计算校验和
恶意软件也可以计算自身的校验和,如果校验和发生变化,那么病毒会假定它正在被调试,并且其代码内部已被放置断点。VAMPiRE是一款抗反调试工具,可用来逃避断点的检测。VaMPiRE通过在内存中维护一张断点表来达到目的,该表记录已被设置的所有断点。该程序由一个页故障处理程序(PFH),一个通用保护故障处理程序(GPFH),一个单步处理程序和一个框架API组成。当一个断点被触发的时候,控制权要么传给PFH(处理设置在代码、数据或者内存映射I/O中的断点),要么传给GPFH(处理遗留的I/O断点)。单步处理程序用于存放断点,使断点可以多次使用。
3.检测调试器
在Linux系统上检测调试器有一个简单的方法,只要调用Ptrace即可,因为对于一个特定的进程而言无法连续地调用Ptrace两次以上。在Windows中,如果程序目前处于被调试状态的话,系统调用isDebuggerPresent将返回1,否则返回0。这个系统调用简单检查一个标志位,当调试器正在运行时该标志位被置1。直接通过进程环境块的第二个字节就可以完成这项检查,以下代码为大家展示的就是这种技术:
mov eax, fs:[30h]
move eax, byte [eax+2]
test eax, eax
jne @DdebuggerDetected
在上面的代码中,eax被设置为PEB(进程环境块),然后访问PEB的第二个字节,并将该字节的内容移入eax。通过查看eax是否为零,即可完成这项检测。如果为零,则不存在调试器;否则,说明存在一个调试器。
如果某个进程为提前运行的调试器所创建的,那么系统就会给ntdll.dll中的堆操作例程设置某些标志,这些标志分别是FLG_HEAP_ENABLE_TAIL_CHECK、FLG_HEAP_ENABLE_FREE_CHECK和FLG_HEAP_VALIDATE_PARAMETERS。我们可以通过下列代码来检查这些标志:
mov eax, fs:[30h]
mov eax, [eax+68h]
and eax, 0x70
test eax, eax
jne @DebuggerDetected
在上面的代码中,我们还是访问PEB,然后通过将PEB的地址加上偏移量68h到达堆操作例程所使用的这些标志的起始位置,通过检查这些标志就能知道是否存在调试器。
检查堆头部内诸如ForceFlags之类的标志也能检测是否有调试器在运行,如下所示:
mov eax, fs:[30h]
mov eax, [eax+18h] ;process heap
mov eax, [eax+10h] ;heap flags
test eax, eax
jne @DebuggerDetected
上面的代码向我们展示了如何通过PEB的偏移量来访问进程的堆及堆标志,通过检查这些内容,我们就能知道Force标志是否已经被当前运行的调试器提前设置为1了。
另一种检测调试器的方法是,使用NtQueryInformationProcess这个系统调用。我们可以将ProcessInformationClass设为7来调用该函数,这样会引用ProcessDebugPort,如果该进程正在被调试的话,该函数将返回-1。示例代码如下所示。
push 0
push 4
push offset isdebugged
push 7 ;
ProcessDebugPort
push -1
call NtQueryInformationProcess
test eax, eax
jne @ExitError
cmp isdebugged, 0
jne @DebuggerDetected
在本例中,首先把NtQueryInformationProcess的参数压入堆栈。这些参数介绍如下:第一个是句柄(在本例中是0),第二个是进程信息的长度(在本例中为4字节),接下来是进程信息类别(在本例中是7,表示ProcessDebugPort),下一个是一个变量,用于返回是否存在调试器的信息。如果该值为非零值,那么说明该进程正运行在一个调试器下;否则,说明一切正常。最后一个参数是返回长度。使用这些参数调用NtQueryInformationProcess后的返回值位于isdebugged中。随后测试该返回值是否为0即可。
另外,还有其他一些检测调试器的方法,如检查设备列表是否含有调试器的名称,检查是否存在用于调试器的注册表键,以及通过扫描内存以检查其中是否含有调试器的代码等。
另一种非常类似于EPO的方法是,通知PE加载器通过PE头部中的线程局部存储器(TLS)表项来引用程序的入口点。这会导致首先执行TLS中的代码,而不是先去读取程序的入口点。因此,TLS在程序启动就可以完成反调试所需检测。从TLS启动时,使得病毒得以能够在调试器启动之前就开始运行,因为一些调试器是在程序的主入口点处切入的。