反调试
前文写过,花指令通常干扰静态分析,而反调试与之相反,主要为了干扰动态调试。
1.反调试简介
反调试是一种用于阻碍程序动态调试的技术,首先大致说明一下反调试的工作原理。
在操作系统内部提供了一些API,用于调试器调试。当调试器调用这些API时系统就会在被调试的进程内存中留下与调试器相关的信息。一部分信息是可以被抹除的,也有一部分信息是难以抹除的。
当调试器附加到目标程序后,用户的很多行为将优先被调试器捕捉和处理。其中大部分是通过异常捕获通信的,包括断点的本质就是异常。如果调试器遇到不想处理的信息,一种方式是忽略,另一种方式是交给操作系统处理。
那么目前为止,程序就有两种方式检测自己是否被调试:
- 检测内存中是否有调试器的信息。
- 通过特定的指令或触发特定异常,检测返回结果。
通常来说,存在反调试的程序,当检测到自身处于调试状态时,就会控制程序绕过关键代码,防止关键代码被调试,或者干脆直接退出程序。
2.API反调试
Windows内部提供了一些用于检测调试器的API。
其中一个API是 IsDebuggerPresent
,原型为:
BOOL IsDebuggerPresent();
返回值为1表示当前进程被调试的状态,反之为0.
另一个常用的API是CheckRemoteDebuggerPresent
,原型为:
BOOL CheckRemoteDebuggerPresent(HANDLE hProcess, PBOOL pbDebuggerPresent);
返回值为1表示当前进程被调试的状态,反之为0.
3.PEB反调试
当程序处于3环(低权限)时, FS:[0]
寄存器指向TEB(Thread Environment Block),即线程环境块结构体,TEB向后偏移0x30字节的位置保存的是PEB(Process Environment Block ),即进程环境块的结构体地址。PEB中的部分成员是与调息相关的成员,当调试器通过 Windows提供的API调试目标程序时,Windows会将一部分调试信息写人这个结构体中。
kd>dt_TEB
nt! _TEB
...
+0x030 ProcessEnvironmentBlock :Ptr32_PEB
...
kd>dt_TEB
...
+0x002 BeingDebugged :UChar
...
+Ox018 ProcessHeap :Ptr32 Void
...
+0x068 NtGlobalF1ag :Uint4B
...
本处只介绍这两个结构体中几个重要的成员,若是想在实际调试时查看其他成员的具体内容,其中一种方法是使用WinDbg调试内核。
在PEB结构体中中,BeingDebugged
、ProcessHeap
、NtGlobalFlag
是与调试信息相关的三个重要成员。
BeingDebugged
:当进程处于被调试状态时,值为1,否则为0。ProcessHeap
:指向Heap结构体,偏移0xC处为Flags成员,偏移0x10处为ForceFlags成员。通常情况下,Flags的值为2.ForceFlags的值为0,当进程被调试时会发生改变NGlobalFlag
:占四个字节,默认值为0。当进程处于被调试状态时,第一个字节会被置为0x70。
通过FS.Base
能够定位到TEB,再通过TEB+0x30能够定位PEB。通过在内存中检测或修改相关成员的值,便可达到反试、反反调试的效果。
4.TLS反调试
TLS (Thread Local Storage),即线程局部存储是Windows提供的一种处理机制,每进行一次线程切换,便会调用一次TLS回调。它本意是想给每个线程都提供访问全局变量的机会。例如,需要统计当前程序进行了多少次线程切换,但并不想让其他线程访问到这个计数变量,使用TLS进行计数,便能够解决这个问题,一个程序能设置多个TLS.
由于进程在启动时至少需要创建一个线程来运行,因此在调用main函数前就会调用一次 TLS 回调。利用这个特点,在TLS回调中写入与反调试相关的代码,便可悄无声息地令调试器失效。
#include <windows.h>
#include <iostream>
// TLS回调函数定义
void NTAPI TLS_Callback(PVOID DllHandle, DWORD Reason, PVOID Reserved) {
if (Reason == DLL_PROCESS_ATTACH) {
// 检测是否存在调试器
if (IsDebuggerPresent()) {
std::cout<<"Debugger detected!\n";
ExitProcess(1); // 如果检测到调试器则退出
} else {
std::cout<<"No debugger detected.\n";
}
}
}
// 声明TLS回调函数
#ifdef _MSC_VER
#pragma const_seg(".CRT$XLB")
EXTERN_C const PIMAGE_TLS_CALLBACK pTLS_CALLBACK = TLS_Callback;
#pragma const_seg()
#else
__attribute__((section(".CRT$XLB"))) PIMAGE_TLS_CALLBACK pTLS_CALLBACK = TLS_Callback;
#endif
int main() {
std::cout<<"Program started.\n";
return 0;
}
-
TLS_Callback
:TLS的回调函数,每当一个新线程创建时,或者进程加载时,它都会被调用。 -
IsDebuggerPresent
:这是Windows提供的API,用来检查当前进程是否被调试。 -
#pragma const_seg
:用于指定TLS回调函数的位置,它被放在.CRT$XLB
节中,这样Windows在加载时会自动执行。
5.进程名反调试
当使用调试器调试程序时,调试器是一个独立的进程,运行在内存中。若在程序执行到某一反调试方法。阶段时遍历当前系统中的进程列表,检测是否存在与调试器相关的进程名,也不失为一种可行的方法。
示例代码如下:
#include <windows.h>
#include <tlhelp32.h>
#include <iostream>
#include <string>
// 检查是否存在指定的调试器进程
bool IsDebuggerProcessRunning() {
const char* debuggerNames[] = { "ollydbg.exe", "x64dbg.exe", "ida.exe", "windbg.exe" };
// 创建进程快照
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE) {
return false;
}
PROCESSENTRY32 pe32;
pe32.dwSize = sizeof(PROCESSENTRY32);
// 遍历进程列表
if (Process32First(hSnapshot, &pe32)) {
do {
// 遍历已知的调试器名称
for (const auto& debuggerName : debuggerNames) {
// 将进程名转换为小写以进行匹配
std::string processName = pe32.szExeFile;
for (auto& c : processName) c = tolower(c);
if (processName == debuggerName) {
CloseHandle(hSnapshot);
return true; // 找到匹配的调试器进程
}
}
} while (Process32Next(hSnapshot, &pe32));
}
CloseHandle(hSnapshot);
return false; // 未找到调试器进程
}
int main() {
if (IsDebuggerProcessRunning()) {
std::cout << "Debugger process detected! Exiting...\n";
ExitProcess(1); // 检测到调试器,退出程序
} else {
std::cout << "No debugger detected.\n";
}
// 正常程序逻辑
std::cout << "Program is running.\n";
return 0;
}
- 调试器进程名列表:在
debuggerNames
数组中列出了常见的调试器进程名(如ollydbg.exe
,x64dbg.exe
等)。可以根据需要添加更多的调试器进程名。 - CreateToolhelp32Snapshot:这是一个Windows API,用于创建系统中所有进程的快照,以便遍历这些进程。
- Process32First 和 Process32Next:这些函数用于遍历进程快照中的每一个进程。
- tolower:为了匹配时忽略大小写,将进程名全部转换为小写进行比较。
- ExitProcess:如果发现调试器进程,程序直接退出。
代码在执行时遍历系统中所有正在运行的进程,并检查是否有已知的调试器进程在运行。如果发现某个调试器进程(如ollydbg.exe
),则程序会直接退出,否则会继续运行。这种方法可以用来检测外部调试器是否正在运行,但它不是百分之百可靠,因为高级调试器可能会通过修改进程名或隐藏自己来规避检测。
6.窗口名反调试
检测已打开窗口的窗口也是一种较为常用的反调试手段。示例代码如下
#include <windows.h>
#include <iostream>
// 定义要检查的调试器窗口名称
const char* debuggerWindowNames[] = {
"OllyDbg", "x64dbg", "IDA", "WinDbg"
};
// 枚举系统中所有窗口,查找是否有调试器窗口存在
BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM lParam) {
char windowTitle[256];
GetWindowTextA(hwnd, windowTitle, sizeof(windowTitle));
// 遍历已知的调试器窗口名称
for (const auto& debuggerWindowName : debuggerWindowNames) {
if (strstr(windowTitle, debuggerWindowName)) {
std::cout << "Debugger window detected: " << windowTitle << "\n";
return FALSE; // 找到调试器窗口,停止枚举
}
}
return TRUE; // 继续枚举其他窗口
}
// 检查是否存在调试器窗口
bool IsDebuggerWindowOpen() {
return !EnumWindows(EnumWindowsProc, 0);
}
int main() {
if (IsDebuggerWindowOpen()) {
std::cout << "Debugger window detected! Exiting...\n";
ExitProcess(1); // 检测到调试器窗口,退出程序
} else {
std::cout << "No debugger window detected.\n";
}
std::cout << "Program is running.\n";
return 0;
}
- EnumWindows:这是Windows API,允许遍历系统中所有的顶层窗口。每找到一个窗口,就会调用回调函数
EnumWindowsProc
。 - EnumWindowsProc:这是枚举窗口的回调函数。通过
GetWindowTextA
函数获取窗口标题,然后使用strstr
来判断窗口名是否包含已知调试器窗口名称。 - ExitProcess:如果检测到调试器窗口,程序直接退出。
有些调试器允许用户自定义窗口名,因此该方法并非完全可靠。高级调试器同样可能会通过修改窗口名或隐藏窗口来规避检测。例如,OD在刚启动时的窗口名为“OllyDbg - [CPU]”,而加载程序后会有所改变,但前几个字节仍然为“OllyDbg”,对于这类窗口,规定字符串的检测长度往往能取得不错的效果。
7.时间戳反调试
正常情况下,CPU的执行速度是非常快的,每秒能执行数条指令,每条指令的执行时间非常短。而在调试状态下,由于软件中断、单步调试等因素,可能会造成指令间的执行间隔远大于正常时间,分别记录两条指令执行前后的时间戳,利用时间戳的差值便能够判断当前进程是否处于被调试状态。
时间戳反调试有三种常用手段。
rdtsc
: 汇编指令,能够以纳秒级记录系统启动以来的时间戳,返回值保存在EDX:EAX(高位保存到EDX,低位保存到EAX)中。QueryPerformanceCounter
:能够以微秒为单位高精度计时。GetTickCount
:返回值为自系统启动以来所经过的毫秒数。
例如:
#include <windows.h>
#include <iostream>
int main() {
DWORD time1 = GetTickCount();
int result, a = 1, b = 2;
__asm(
"movl %1, %%ebx\n\t"
"addl %%ebx, %0"
: "=r" (result)
: "r" (b), "0" (a)
);
DWORD time2 = GetTickCount();
if(time2-time1>0x10)
ExitProcess(0);
std::cout << "Program started.\n";
std::cout << result;
return 0;
}
程序执行完内联汇编后,会计算前后时间差。如果时间差超过16毫秒(0x10),程序会调用ExitProcess
强制退出。这可以用于反调试:调试器往往会减缓程序的执行速度,因此通过这种时间检测方法可以检测到调试行为。
8.硬件断点检测反调试
硬件断点是调试器常用的手段之一,它通过CPU的调试寄存器(如DR0-DR7
)设置断点。可以通过检查这些寄存器是否有断点设置来检测调试器。使用GetThreadContext
API来获取当前线程的上下文,检查调试寄存器(DR0
到DR3
)是否设置断点。
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(GetCurrentThread(), &ctx);
if (ctx.Dr0 || ctx.Dr1 || ctx.Dr2 || ctx.Dr3) {
ExitProcess(0);
}
9.异常处理反调试
调试器通常会捕获异常,反调试技术可以通过故意引发异常并检查其处理方式来检测调试器。通过引发INT 3
指令(断点中断)或其他异常(如除零异常),查看是否有异常处理程序被插入,然后使用SetUnhandledExceptionFilter
设置自定义的异常处理程序。
__try {
__asm { int 3 } // 触发断点异常
} __except (EXCEPTION_EXECUTE_HANDLER) {
// 如果捕获了异常,则说明没有调试器
std::cout << "No debugger detected." << std::endl;
}
10.单步检测反调试
单步检测反调试是一种通过检测CPU的单步执行(Trap Flag, TF)来判断是否有调试器介入的技术。当调试器单步执行目标程序时,CPU的TF
标志会被设置为1,这会导致在每条指令执行完后触发一个调试中断。因此,程序可以通过监控TF
标志的变化来检测调试行为。
Trap Flag(TF):当EFLAGS
寄存器中的TF
标志被置为1时,CPU会进入单步模式,每执行一条指令后都会产生一个调试中断(INT 1
)。通过检查和控制TF
标志,可以判断程序是否被调试器单步执行。如果调试器处于单步调试模式,TF
标志会被置1,程序可以利用这一特性检测调试行为。
例如:
#include <iostream>
#include <windows.h>
int main() {
// 保存原来的EFLAGS寄存器值
unsigned int eflags;
__asm {
pushfd // 将EFLAGS压入栈中
pop eax // 将栈顶的EFLAGS值弹出到EAX寄存器
mov eflags, eax // 保存EFLAGS寄存器到eflags变量
or eax, 0x100 // 设置TF(Trap Flag)位为1,启用单步调试模式
push eax // 将修改后的EFLAGS值压回栈
popfd // 恢复EFLAGS寄存器,使Trap Flag生效
}
// 执行单步调试后检测
__asm {
nop // 一个空操作,用于单步执行检测
pushfd // 将当前的EFLAGS寄存器值压入栈
pop eax // 弹出EFLAGS到EAX
mov eflags, eax // 保存当前的EFLAGS值
}
// 检测Trap Flag是否被清除(如果有调试器在调试,该标志可能被复位)
if (eflags & 0x100) {
std::cout << "No debugger detected (TF still set)." << std::endl;
} else {
std::cout << "Debugger detected (TF cleared)." << std::endl;
ExitProcess(0); // 检测到调试器,退出程序
}
std::cout << "Program continues running..." << std::endl;
return 0;
}
如果程序在执行nop
指令后,TF
标志依然保持为1,则说明程序未被调试,输出“No debugger detected (TF still set)”。如果程序发现TF
标志被清除(调试器可能重置了该标志),则输出“Debugger detected (TF cleared)”,并终止程序执行。