本文主要面向逆向工程师和恶意程序分析人员,分类并列举了一些基于Windows操作系统的反调试技术。
反调试技术给程序提供了检测自身是否运行在调试器下的方法,所以经常被商业性质的可执行文件保护器、加壳程序,甚至恶意程序用来保护或者减缓逆向工程的过程。在本文中,我们假设所有的程序都是在Ring3级调试器(如Windows平台下的OllyDbg)下分析,同时也请注意我们只会谈及纯粹的、通用的反调试和反跟踪技术,而具体的调试器探测,如窗口或进程枚举、注册表搜索等,本文不会涉及。
反调试和反跟踪技术
1.基于内存差异的反调试技术
(1) kernel32!IsDebuggerPresent
IsDebuggerPresent可以检测当前进程是否处于被调试状态,是则返回1,否则返回0。该API通过简单的读取PEB(进程环境块)结构里的BeingDebugged字节标志(偏移为2)来确定调试状态。但是,我们通常很容易就可以把PEB里的BeingDebugged标志设置为0,从而绕过这种技术。比如:
view plainprint?
call IsDebuggerPresent
test eax, eax
jne @DebuggerDetected
……
(2) PEB!IsDebugged
这个标志域根据当前进程PEB的第2个字节的值来设置。当进程被调试时,系统会设置这个变量;如果该标志被重置为0,也不会对程序的执行过程造成什么影响,所以这只是一个信息标志。比如:
view plainprint?
mov eax, fs:[30h]
mov eax, byte [eax+2]
test eax, eax
jne @DebuggerDetected
……
(3) PEB!NtGlobalFlags
当进程被创建时,系统会设置一些与描述API对程序的行为有关的标志位。这些标志位都可以从PEB中偏移为0x68的DWORD读取出来。缺省情况下,不同标志位的设置情况依赖于当前进程在调试器下打开与否。如果进程被调试,一些在ntdll中负责堆管理的标志位会被设置,例如 FLG_HEAP_ENABLE_TAIL_CHECK、FLG_HEAP_ENABLE_FREE_CHECK和 FLG_HEAP_VALIDATE_PARAMETERS。但是,这种反调试技术可以通过重置NtGlobalFlags的变量值而使其失效。比如:
view plainprint?
mov eax, fs:[30h]
mov eax, [eax+68h]
and eax, 0x70
test eax, eax
jne @DebuggerDetected
……
(4) Heap flags
如前所述,NtGlobalFlags负责控制堆管理程序的行为。虽然修改PEB结构中的变量是比较容易的,但程序未被调试时,如果堆内存和它应该执行的操作不一致,就会带来很多问题。进程的堆空间数量众多,而且其块也会受到FLG_HEAP_*等标志的影响,就决定了这是一种强大的反调试方法。除了堆的尾部以外,堆空间的首部也会受到影响,例如,堆空间首部的ForceFlags标志(偏移为0x10)可以用来检测调试器的存在。但是,有两种办法也可以让这种技术失效。一是创建一个非调试进程,在进程创建完就用调试器附加(Attach)上去。(一条简单的途径是创建一个挂起进程,一直运行到入口点,打补丁到无限循环,再恢复进程,附加调试器,最后还原原来的入口点。)二是强制使用需要调试的进程的NtGlobalFlags标志位。在注册表主键“HKLM\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution Options”下以需要调试的进程名新建1个子键,并在该子键下新建1个值为空的字符串类型变量“GlobalFlags”。比如:
view plainprint?
mov eax, fs:[30h]
mov eax, [eax+18h] ;process heap
mov eax, [eax+10h] ;heap flags
test eax, eax
jne @DebuggerDetected
……
(5) Vista 反调试技术(未命名)
在Windows Vista 32位、SP0、English version环境下,我通过对比运行于调试和非调试状态下的进程的内存转储(Dump)信息,发现了一种反调试技术。虽然尚还不确定它的可靠性,但我觉得还是值得一提。
当进程被调试时,在其主线程的TEB(线程环境块)偏移为0xBFC处有1个指向系统Dll中的UNICODE字符串的指针,该字符串的内容紧随其后,位于偏移为0xC00的地方。如果进程没有被调试,这个指针就被设置为NULL,字符串的值当然也不复存在了。比如:
view plainprint?
call GetVersion
cmp al, 6
jne @NotVista
push offset _seh
push dword fs:[0]
mov fs:[0], esp
mov eax, fs:[18h] ; teb
add eax, 0BFCh
mov ebx, [eax] ; pointer to a unicode string
test ebx, ebx ; (ntdll.dll, gdi32.dll,...)
je @DebuggerNotFound
sub ebx, eax ; the unicode string follows the
sub ebx, 4 ; pointer
jne @DebuggerNotFound
;debugger detected if it reaches this point
;……
2.基于系统差异的反调试技术
(1) NtQueryInformationProcess
ntdll里的NtQueryInformationProcess是对ZwQueryInformationProcess这个系统调用的包装,其原型如下:
NTSYSAPI NTSTATUS NTAPI NtQueryInformationProcess(
IN HANDLE ProcessHandle,
IN PROCESS_INFORMATION_CLASS ProcessInformationClass,
OUT PVOID ProcessInformation,
IN ULONG ProcessInformationLength,
OUT PULONG ReturnLength
);
调用函数时,设置参数ProcessInformationClass为7(ProcessDebugPort constant),若进程运行在调试状态,系统会将ProcessInformation设置为-1。
这也是一种强大的反调试方法,至今未有简单的办法可以绕过它的保护。然而,如果程序被跟踪了,ProcessInformation变量可能在系统调用返回时被修改。另一种解决方法是使用可以挂钩到ZwNtQueryInformationProcess上的系统级驱动,更改相关变量的值就可。
如果绕开了NtQueryInformationProcess,那也可以绕开很多其它的反调试方法。如 CheckRemoteDebuggerPresent和UnhandledExceptionFilter等,在后面的文章中会具体讲解。
view plainprint?
push 0
push 4
push offset isdebugged
push 7 ;ProcessDebugPort
push -1
call NtQueryInformationProcess
test eax, eax
jne @ExitError
cmp isdebugged, 0
jne @DebuggerDetected
……
(2) kernel32!CheckRemoteDebuggerPresent
这个API需要两个参数:1个进程句柄和1个指向DWORD型变量的指针。如果函数调用成功,进程被调试时,第二个参数,指向的DWORD型变量的值会被设为1。通过探究函数内部,我们发现这个API会以设为7(ProcessDebugPort constant)的ProcessInformationClass变量为参数调用ntdll里的NtQueryInformationProcess 函数,所以绕过了NtQueryInformationProcess也就绕过了这个API。比如:
view plainprint?
push offset isdebugged
push -1
call CheckRemoteDebuggerPresent
test eax, eax
jne @DebuggerDetected
……
(3) UnhandledExceptionFilter
异常处理机制非常重要,在Windows XP SP>=2、Windows 2003和Windows Vista等环境下,操作系统处理异常的方式通常为:
- 如果发生异常,将处理的控制权传给每个进程的向量化异常处理句柄。
- 如果异常没有得到处理,把处理权传给每个线程顶层的SEH句柄,该句柄由产生异常的线程中的FS:[0]指出。SEH是异常处理程序的调用链,当前面的处理程序没有处理异常时,会顺次调用后面的函数。
- 如果没有任何一个处理程序处理该异常,最后的SEH句柄(由系统设置)会调用kernel32里的UnhandledExceptionFilter。这个函数会根据进程是否处于调试状态而决定下一步的动作。
- 如果没有被调试,它会调用用户通过kernel32中的SetUnhandledExceptionFilter函数设置的用户级异常过滤函数。
- 如果被调试,程序会被终止。
需要说明的是,UnhandledExceptionFilter对调试器的检测是通过ntdll里的NtQueryInformationProcess函数来完成的。比如:
view plainprint?
push @not_debugged
call SetUnhandledExceptionFilter
xor eax, eax
mov eax, dword [eax] ; trigger exception
;program terminated if debugged
;...
@not_debugged:
;process the exception
;continue the execution
;……
(4) NtSetInformationThread
Ntdll里的NtSetInformationThread是对系统调用ZwSetInformationThread的包装,其原型如下:
NTSYSAPI NTSTATUS NTAPI NtSetInformationThread(
IN HANDLE ThreadHandle,
IN THREAD_INFORMATION_CLASS ThreadInformationClass,
IN PVOID ThreadInformation,
IN ULONG ThreadInformationLength
);
调用时把参数ThreadInformationClass设为0x11(ThreadHideFromDebugger constant),该线程会从调试器中分离出来(Detached)。
和ZwQueryInformationProcess类似,想要绕过这种反调试方法,或者在函数调用前修改ZwSetInformationThread的参数,或者用内核驱动直接挂钩其内部的系统调用,以修改相关变量。比如:
view plainprint?
push 0
push 0 push 11h ;ThreadHideFromDebugger
push -2
call NtSetInformationThread
;thread detached if debugged
;……
(5) kernel32!CloseHandle和NtClose
利用了ZwClose系统调用的一些API也可以用来检测调试器的存在,包括CloseHandle等。进程被调试时,用一个无效句柄作参数调用ZwClose会产生1个STATUS_INVALID_HANDLE(0xC0000008)异常。
回想前文,几乎所有的反调试方法都是直接从内核(通过系统调用)获得相关信息,那么要想绕开“CloseHandle”之类的反调试方法,也是在Ring3下修改系统调用时的参数,或者是设置内核钩子。
需要注意的是,虽然这种反调试技术非常强大,但实际上在恶意程序中并没有广泛使用。 下面给个例子。
view plainprint?
push offset @not_debugged
push dword fs:[0]
mov fs:[0], esp
push 1234h ;invalid handle
call CloseHandle
; if fall here, process is debugged
;……
@not_debugged:
;……
(6) 自调试(Self-debugging)
进程也可以通过自调试的方法来检测调试器的存在。我们可以新建1个进程,然后在父进程中调用kernel32里的DebugActiveProcess(pid)就可以实现,pid是父进程的ID。
按照调用顺序,DebugActiveProcess会调用ntdll的DbgUiDebugActiveProcess, 后者又调用ZwDebugActiveProcess的系统调用。如果进程已经被调试,这个系统调用会返回失败。
(7) 内核模式计时器(Kernel-mode timers)
使用kernel32的QueryPerformanceCounter也是1种有效的反调试手段。这个API调用ntdll的 NtQueryPerformanceCounter,后者又包装了对ZwQueryPerformanceCounter的调用。同样,也找不到简单的方法可以绕过这种技术。
(8) 用户模式计时器(User-mode timers)
像kernel32里的GetTickCount函数,会返回系统启动以来经过的毫秒数。但有趣的是,它并没有使用任何与内核相关的服务来完成此项任务。每一个用户模式的进程有1个这样的计数器映射到其地址空间,对于8Gb用户模式空间来说,其返回值是:d[0x7FFE0000]*d[0x7FFE0004]/(2^24)
(9) kernel32!OutputDebugStringA
这种反调试方法比较原始。我至今只碰到过一次,是在用ReCrypt v0.80加壳的文件中。这种方法需要以1个有效的ASCII字符串作为参数,调用OutputDebugStringA。程序在调试器下运行,函数返回值是作为参数传递给该函数的字符串的地址;非调试情况下,返回值则是1。比如:
view plainprint?
xor eax, eax
push offset szHello
call OutputDebugStringA
cmp eax, 1
jne @DebuggerDetected
……
(10) Ctrl-C
当控制台(Console)程序被调试时,Ctrl-C的键盘组合会抛出1个EXCEPTION_CTL_C类型的异常。但在未调试状态,这个信号的处理句柄会直接被调用。 比如:
view plainprint?
push offset exhandler
push 1
call RtlAddVectoredExceptionHandler
push 1
push sighandler
call SetConsoleCtrlHandler
push 0
push CTRL_C_EVENT
call GenerateConsoleCtrlEvent
push 10000
call Sleep
push 0
call ExitProcess
exhandler:
;check if EXCEPTION_CTL_C, if it is,
;debugger detected, should exit process
;……
sighandler:
;continue
;……
3.基于CPU的反调试技术(CPU anti-debug)
(1) 无赖的Int3(Rogue Int3)
对于一些功能不够强大的调试器来说,这是一种经典的反调试方法,只需在有效的指令序列中插入1段INT3的机器码即可。当这条INT3指令执行时,没有被调试的程序会把控制权交给起保护作用的异常处理句柄,当然也就可以继续向前执行。
INT3指令被调试器用来设置软件断点,故我们可以用插入的INT3机器码来欺骗调试器,让它相信这里是一个断点。调试器会遵循每一个它设置的软件断点,这个当然也不例外。此时,控制权就不会转到异常处理程序,程序的流程就会被更改。需要注意的是,INT3的机器码有两种形式:0xCD,或者 0x03。比如:
view plainprint?
push offset @handler
push dword fs:[0]
mov fs:[0], esp
;……
db 0CCh
;if fall here, debugged
;……
@handler:
;continue execution
;……
(2) “Ice”断点("Ice" Breakpoint)
所谓的“Ice breakpoint”,是Intel未公开的指令中的一个,其机器码为0xF1,用来检测跟踪程序。
执行这条指令会产生1个SINGLE_STEP异常。当程序在被调试器跟踪时,调试器会认为当前异常是在标志寄存器(Flags Registers)的SingleStep(单步)标志被置位的情况下,产生的一个普通异常,所以相关的异常处理函数也不会执行,整个程序的执行流程也不会和预期的一样了。
然而,要绕开这种反调试方法也是很容易的。只要知道是使用这条指令产生了异常,就会调用相应的异常处理程序,而非通常的单步调试那样,让调试器把控制权传到异常处理句柄就可以了。比如:
view plainprint?
push offset @handler
push dword fs:[0]
mov fs:[0], esp
;……
db 0F1h
;if fall here, traced
;……
@handler:
;continue execution
;……
(3) 2Dh中断(Interrupt 2Dh)
在未调试情况下执行这个中断,程序会抛出一个断点(Breakpoint)异常。在调试状态,且跟踪标志(Trace Flag)没有置位时,就不会有异常产生,程序可以正常执行。如果调试时设置了跟踪标志,调试器会跳过后面的1个字节而继续执行。由此看来,使用INT 2Dh,可以作为一种强大的反调试和反跟踪的手段。比如:
view plainprint?
push offset @handler
push dword fs:[0]
mov fs:[0], esp
;……
db 02Dh
mov eax, 1 ;anti-tracing
;……
@handler:
;continue execution
;……
(4) 时间戳计数器(Timestamp counters)
时间戳计数器是一种高精度计数器,用来存储机器自启动以来执行过的CPU周期数,通过RDTSC指令可以查询其中的数值。在经典的反调试方法里,有一招是计算程序执行的关键部分所用的时间,就可以用这个计数器值前后两次的差值来测量。我们选取的关键部位大多是异常处理函数,如果执行时间太长,那么很有可能程序就是在调试器的控制下运行,因为在调试器的下处理函数并把控制权交还给被调试程序确实是一个相当费时的过程。比如:
view plainprint?
push offset handler
push dword ptr fs:[0]
mov fs:[0],esp
rdtsc
push eax
xor eax, eax
div eax ;trigger exception
rdtsc
sub eax, [esp] ;ticks delta
add esp, 4
pop fs:[0]
add esp, 4 cmp eax, 10000h ;threshold
jb @not_debugged
@debugged:
... @not_debugged:
……
handler:
mov ecx, [esp+0Ch]
add dword ptr [ecx+0B8h], 2 ;skip div
xor eax, eax
ret
(5) Popf和陷阱标志(Popf and the trap flag)
陷阱标志位于标志寄存器内,用来控制对程序的跟踪。当标志被设置时,执行1条指令就会产生1个SINGLE_STEP异常。该标志能用来阻碍调试器的跟踪。下面的指令序列可以设置陷阱标志:
pushf
mov dword [esp], 0x100
popf
如果程序被跟踪,上面的指令序列不会对标志寄存器造成实质上的影响,调试器会处理异常,认为它就来源于正常的跟踪例程,相应的异常处理函数也不会被调用。要绕过这种技术,只需要简单的跳过“pushf”指令就可。
(6) 堆栈段寄存器(Stack Segment register)
这是我曾经在一个叫“MarCrypt”的加壳程序里碰到过的一种比较原始的反跟踪方法。即便如此,但我相信很少有人知道它,就更别谈使用了。通常,它会有下面的一段指令序列:
push ss
pop ss
pushf
nop
当跟踪过“pop ss”指令时,下一条指令会被执行,而调试器却无法停下来,也即调试器会停在下下条指令的位置,在这里就是nop指令的地方。Marcrypt是这样使用这种技术的:
view plainprint?
push ss
; junk
pop ss
pushf
; junk
pop eax
and eax, 0x100
or eax, eax
jnz @debugged
; carry on normal execution
如果调试器跟踪这串指令,“pushf”会隐式执行,且调试器没办法更改压入栈中的陷阱标志的设置。保护的时候,只需检查陷阱标志,在发现被设置的后终止程序就可以了。不过也有一种简单的方法可以绕开它,在“popf”的地方设置断点,然后直接运行下面的程序,只要避免使用陷阱标志就可。
(7)调试寄存器(Debug registers manipulation)
调试寄存器(DR0……DR7)用来设置硬件断点。我们可以采用一种保护方法来设置这些寄存器,或者检测已经被设置的硬件断点,或者把它们设置为特殊的值留作日后的代码检查之用。有一个叫tElock的加壳程序,就是利用这些寄存器来阻止逆向工程的。
从用户模式看,虽然不能使用该特权级下的“mov drx, ...”指令来设置调试寄存器,但还是有一些其它的方法:
- 异常产生时,线程上下文(context)会被修改,其中包括产生异常时的CPU寄存器状态。恢复正常执行时,线程上下文会被替换为新的内容,这时就可以修改调试寄存器的内容了。
- kernel32里的GetThreadContext和SetThreadContext这两个API来访问和设置线程上下文,它们分别包装了NtGetContextThread和NtSetContextThread的系统调用。
大多数保护程序都使用第一种途径。
view plainprint?
push offset handler
push dword ptr fs:[0]
mov fs:[0],esp
xor eax, eax
div eax ;generate exception
pop fs:[0]
add esp, 4
;continue execution
;……
handler:
mov ecx, [esp+0Ch] ;skip div
add dword ptr [ecx+0B8h], 2 ;skip div
mov dword ptr [ecx+04h], 0 ;clean dr0
mov dword ptr [ecx+08h], 0 ;clean dr1
mov dword ptr [ecx+0Ch], 0 ;clean dr2
mov dword ptr [ecx+10h], 0 ;clean dr3
mov dword ptr [ecx+14h], 0 ;clean dr6
mov dword ptr [ecx+18h], 0 ;clean dr7
xor eax, eax
ret
(8) 修改上下文(Context modification)
就像利用调试寄存器一样,程序执行时的上下文还可以通过其他的非传统方式修改。调试器很容易就被我们搞得晕头转向。这里请注意另外一个系统调用NtContinue,可以用来为当前线程加载新的上下文,通常,异常句柄管理器就是使用的这个系统调用。
4.尚未分类的反调试技术(Uncategorized anti-debug)
(1) TLS-回调(TLS-callback)
几年前,这种反调试技术尚还不为人所知。它会引导PE加载器,告诉程序的第一入口点在TLS(Thread Local Storage entry,位于PE的optional header结构DataDirectory的第10项)。这样,程序的入口点就不会被首先执行。TLS入口就可以采用秘密的方式来检测调试器的存在。目前这种技术在实践中并没有被广泛使用。TLS对老一辈的调试器(包括OllyDbg)都是透明的,即调试器不能得知TLS的存在,但添加了合适的插件以后,调试器可以有所对策。(译注:TLS=Thread Local Storage,线程局部存储。如果一个变量不想使多个线程共享访问,可以通过这一机制分配变量,使每个当前线程有一个该变量的实例。)
(2) CC扫描(CC scanning)
CC扫描循环是加壳程序使用的一个基本保护特征,用于检测调试器在软件中设置的软件断点。如果调试器想逃过CC扫描这一劫,可以使用硬件断点或者自定义类型的软件断点。CLI(0xFA)是一个不错的选择,可以用其替代经典的INT3指令。但执行这条指令还有额外的要求:要占用1个字节的存储空间;在Ring3级执行时会产生特权指令异常(privileged instruction exception)。
(3) 入口点RVA归0
一些程序在加壳之后,程序入口点的RVA会被设置为0,这意味着程序会从加载完成后文件最前端的“MZ...”处开始执行,这就相当于执行了 “dec ebx / pop edx ...”指令。这本身并不构成一项反调试技术,但足以给那些想在入口点下软件断点的调试器带来不小的麻烦。
如果将进程挂起,在RVA为0的地方设置INT3断点,那么魔数“MZ”中的“M”就会被覆盖。进程在创建时会检查魔数,ntdll在进程恢复时还会再次检查,这样就回到了程序的入口点,一个INVALID_IMAGE_FORMAT异常会被抛出。如果你想自己实现一个调试器或跟踪程序,使用硬件中断就可以屏蔽这个问题。
结论
对逆向工程师来说,了解一些保护软件或恶意程序采用的(非)常用的反调试和反跟踪技术是非常有用的。在任何情况下,一个程序总有办法找出它是否运行于调试器之下,况且这方面的应用已经延伸到了虚拟或模拟环境中。但另一方面,Ring3级调试器又是最常用的程序分析工具之一,了解了这些反调试技巧,并且知道如何绕过它们,同样也是大有益处的。
阅读(2763) | 评论(0) | 转发(0) |