反调试

反调试是什么?

反调试是用来检测并阻止程序被调试器(debugger)分析的一种技术,当程序意识到自己可能处于调试中的时候,可能会改变正常的执行路径或者修改自身程序让自己崩溃,从而增加调试时间和复杂度。

函数检测

函数检测就是通过 Windows 自带的公开或未公开的函数直接检测程序是否处于调试状态。

Windows API 作用
IsDebuggerPresent 检测当前进程是否被调试
CheckRemoteDebuggerPresent 检测一个远程进程是否处于调试状态
NtQueryInformationProcess() 获取进程信息,判断调试状态

IsDebuggerPresent

1
BOOL WINAPI IsDebuggerPresent(void);

该函数查询进程环境块(PEB)中的 BeingDebugged 标志,如果进程处在调试上下文中,则返回1,否则返回0。

CheckRemoteDebuggerPresent

1
2
3
4
BOOL WINAPI CheckRemoteDebuggerPresent(
_In_ HANDLE hProcess,
_Inout_ PBOOL pbDebuggerPresent
);

如果 hProcess 句柄表示的进程处于调试上下文,则设置 pbDebuggerPresent 变量被设置为 TRUE,否则被设置为 FALSE

NtQueryInformationProcess

1
2
3
4
5
6
7
NTSTATUS WINAPI NtQueryInformationProcess(
_In_ HANDLE ProcessHandle,
_In_ PROCESSINFOCLASS ProcessInformationClass,
_Out_ PVOID ProcessInformation,
_In_ ULONG ProcessInformationLength,
_Out_opt_ PULONG ReturnLength
);

第二个参数 ProcessInformationClass 给定了需要查询的进程信息类型。当给定值为 0ProcessBasicInformation)或 7ProcessDebugPort)时,就能得到相关调试信息,返回信息会写到第三个参数 ProcessInformation 指向的缓冲区中。

数据检测

数据检测是指程序通过测试一些与调试相关的关键位置的数据来判断是否处于调试状态。比如上面所说的 PEB 中的 BeingDebugged 参数。数据检测就是直接定位到这些数据地址并测试其中的数据,从而避免调用函数,使程序的行为更加隐蔽。

BeingDebugged

1
2
3
4
5
6
7
8
9
10
11
BOOL CheckDebug()
{
int BeingDebug = 0;
__asm
{
mov eax, dword ptr fs:[30h] ; 指向PEB基地址
movzx eax, byte ptr [eax+2]
mov BeingDebug, eax
}
return BeingDebug != 0;
}

这个函数采用了 汇编方式直接访问 PEB(进程环境块)结构 中的 BeingDebugged 字段来检测程序是否正在被调试器调试。如果检测到,就返回 TRUE,否则返回 FALSE

1
mov eax, dword ptr fs:[30h]   ; 指向PEB基地址

fs:[0x30]Windows 下的一个特殊寄存器段寄存器(FS段)

在 32 位进程中,fs:[0x30] 存的是 PEB(Process Environment Block) 的地址。

所以这一步:取出当前进程的 PEB 指针。

1
movzx eax, byte ptr [eax+2]

它读取的是 PEB+2 偏移的那个字节,即 BeingDebugged 字段。

它是一个 BYTE 类型,含义是:

  • 0x00 表示未被调试
  • 0x01 表示当前进程正在被调试
1
mov BeingDebug, eax

把 eax的值存入变量 BeingDebug中。

NtGlobalFlag

1
2
3
4
5
6
7
8
9
10
11
12
BOOL CheckDebug()
{
int BeingDbg = 0;
__asm
{
mov eax, dword ptr fs:[30h]
mov eax, dword ptr [eax + 68h]
and eax, 0x70
mov BeingDbg, eax
}
return BeingDbg != 0;
}

这个运行原理是调试器中启动的进程与正常启动的进程创建堆的方式有些不同,系统使用PEB结构偏移量0x68处的一个未公开的位置NtGlobalFlag,来决定如何创建堆结构。

1
mov eax, dword ptr [eax + 68h]

读取 PEB + 0x68 偏移处的值。(在32位程序中)

这个字段是:NtGlobalFlag —— 只有在进程由调试器启动时才会设置,这个值会被设置为特殊值十六进制 0x70。运行中附加并不会改变这个值。

1
and eax, 0x70

通过 0x70(即 0111 0000)保留 NtGlobalFlag 中的三个标志值:

标志值 名称 含义
0x10 FLG_HEAP_ENABLE_TAIL_CHECK 堆尾检查,调试器专用
0x20 FLG_HEAP_ENABLE_FREE_CHECK 堆释放检查,调试器专用
0x40 FLG_HEAP_VALIDATE_PARAMETERS 堆参数校验,调试器专用
1
mov BeingDbg, eax

将这几个标志保存到变量中。

1
return BeingDbg != 0;

如果这些调试相关的标志中任意一个被设置,函数就返回 TRUE(说明被调试器创建)。

进程检测

进程检测通过检测当前桌面中是否存在特定的调试进程来判断是否存在调试器,但不能判断该调试器是否正在调试该程序。

例如

1
2
3
4
5
6
7
8
BOOL CheckDebug()
{
if (FindWindowA("x32dbg", 0))
{
return 0;
}
return 1;
}

特征码检测

特征码检测枚举当前正在运行的进程,并在进程的内存空间中搜索特定调试器的代码片段。

例如 OllyDbg 有这样一段特征码:

1
2
3
4
0x41, 0x00, 0x62, 0x00, 0x6f, 0x00, 0x75, 0x00, 0x74, 0x00,
0x20, 0x00, 0x4f, 0x00, 0x6c, 0x00, 0x6c, 0x00, 0x79, 0x00,
0x44, 0x00, 0x62, 0x00, 0x67, 0x00, 0x00, 0x00, 0x4f, 0x00,
0x4b, 0x00, 0x00, 0x00
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
27
28
29
30
31
32
BOOL CheckDebug()
{
BYTE sign[] = {0x41, 0x00, 0x62, 0x00, 0x6f, 0x00, 0x75, 0x00, 0x74, 0x00,
0x20, 0x00, 0x4f, 0x00, 0x6c, 0x00, 0x6c, 0x00, 0x79, 0x00,
0x44, 0x00, 0x62, 0x00, 0x67, 0x00, 0x00, 0x00, 0x4f, 0x00,
0x4b, 0x00, 0x00, 0x00;}

PROCESSENTRY32 sentry32 = {0};
sentry32.dwSize = sizeof(sentry32);
HANDLE phsnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); //创建一个快照,遍历所有进程。

Process32First(phsnap, &sentry32);
do{
HANDLE hps = OpenProcess(MAXIMUM_ALLOWED, FALSE, sentry32.th32ProcessID);
if (hps != 0)
{
DWORD szReaded = 0;
BYTE signRemote[sizeof(sign)];
ReadProcessMemory(hps, (LPCVOID)0x4f632a, signRemote, sizeof(signRemote), &szReaded);
//试读取每个进程地址为 0x004F632A 的内存,并比较内容是否与 sign 匹配。
if (szReaded > 0)
{
if (memcmp(sign, signRemote, sizeof(sign)) == 0)
{ //如果完全匹配,则推断这个进程是 OllyDbg,或者和它内存布局类似。
CloseHandle(phsnap);
return 0;
}
}
}
}
sentry32.dwSize = sizeof(sentry32);
}while(Process32Next(phsnap, &sentry32));

这段代码扫描所有进程,读取每个进程某个特定内存地址(0x004F632A),并判断该处内容是否匹配 “About OllyDbg”(Unicode 编码)。如果匹配,就说明系统中有可能在运行 OllyDbg 调试器,从而返回异常行为。

原理:OllyDbg 在启动时其 .data 节或资源数据段中固定包含 “About OllyDbg\0OK\0” 字符串,且位置稳定

时间检测

时间检测是指在程序中通过代码感知程序处于调试时与未处于调试时的各种运行时间差异来判断程序是否处于调试状态。

例如我们在调试时步过两条指令所花费的时间远远超过CPU正常执行花费的时间,于是就可以通过rdtsc指令(汇编指令)或者GetTickCount函数(WindowsAPI)来进行测试。

rdtsc

rdtsc指令用于将时间标签计数器读入EDX:EAX寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BOOL CheckDebug()
{
int BeingDbg = 0;
_asm
{
rdtsc
mov ecx,edx
rdtsc
sub edx,ecx
mov BeingDbg,edx
}
if(BeingDbg >2)
{
return 1
}
return 0;
}

时间检测型反调试函数,通过调用两次 RDTSC 指令比较时间差,如果中间被断点(调试器)拖慢了,就判断为正在调试。

GetTickCount

GetTickCount返回从操作系统启动所经过的毫秒数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
BOOL CheckDebug()
{
DWORD time1 = GetTickCount(); //获取当前系统启动以来经过的时间(单位:毫秒)
_asm
{
mov ecx,10
mov edx,6
mov ecx,10
}
DWORD time2 = GetTickCount(); //再次读取当前时间
if(time2-time1 >0x1A) //时间相差大于26 毫秒
{
return TRUE;
}
return FALSE;
}

如果两次读取之间花费了超过 26ms,就认为程序在中间被“暂停过”,可能是调试器单步或断点。

断点检测

断点检测是根据调试器设置断点的原理来检测软件代码中是否设置了断点。调试器一般使用两者方法设置代码断点:

  • 通过修改代码指令为 INT3(机器码为0xCC)触发软件异常
  • 通过硬件调试寄存器设置硬件断点

软件断点检测

针对软件断点,检测系统会扫描比较重要的代码区域,看是否存在多余的 INT3 指令。

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
BOOL CheckDebug()
{
PIMAGE_DOS_HEADER pDosHeader;
PIMAGE_NT_HEADERS32 pNtHeaders;
PIMAGE_SECTION_HEADER pSectionHeader;
DWORD dwBaseImage = (DWORD)GetModuleHandle(NULL);
pDosHeader = (PIMAGE_DOS_HEADER)dwBaseImage;
pNtHeaders = (PIMAGE_NT_HEADERS32)((DWORD)pDosHeader + pDosHeader->e_lfanew);
pSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD)pNtHeaders + sizeof(pNtHeaders->Signature) + sizeof(IMAGE_FILE_HEADER) +
(WORD)pNtHeaders->FileHeader.SizeOfOptionalHeader);
DWORD dwAddr = pSectionHeader->VirtualAddress + dwBaseImage;
DWORD dwCodeSize = pSectionHeader->SizeOfRawData;
BOOL Found = FALSE;
__asm
{
cld
mov edi,dwAddr ;要扫描的起始地址
mov ecx,dwCodeSize ;扫描的长度
mov al,0CCH ;调试断点指令 INT 3
repne scasb ;一次扫描一个字节,直到找到 AL 中的 0xCC
jnz NotFound
mov Found,1
NotFound:
}
return Found;
}

如果找到了 0xCC,就跳转到 Found,并设置 Found = 1

硬件断点检测

而对于硬件断点,由于程序工作在保护模式下,无法访问硬件调试断点,所以一般需要构建异常程序来获取 DR 寄存器的值

1
2
3
4
5
6
7
8
9
10
11
12
BOOL CheckDebug()
{
CONTEXT context;
HANDLE hThread = GetCurrentThread();
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hThread, &context);
if (context.Dr0 != 0 || context.Dr1 != 0 || context.Dr2 != 0 || context.Dr3!=0)
{
return 1;
}
return 0;
}

Dr0~`Dr3` 是 4 个硬件断点寄存器

调试器(如 x64dbg)设置硬断点时,会在这些寄存器中写入地址

基于硬件断点的反调试检测方法,原理是检测当前线程的调试寄存器(Dr0~`Dr3`)是否被设置。如果其中任意一个寄存器不为 0,则说明可能存在硬件断点,进而推测当前程序正被调试。

题目练习

使用IDA打开后发现只有两行

查看一下爆红地方的汇编

1
2
mov     eax, offset loc_4010DE
jmp eax

这是跳转到loc_4010DE 处执行,关键是这里的指令是int 2Dh

int 2D 是一个 保留中断,在正常系统中并不会产生作用。

但在某些调试器(如 OllyDbg、SoftICE)下,这个中断可能会引起异常或特定处理逻辑。

如果程序运行在没有调试器的环境下,可能不会崩溃;但如果有调试器,它可能会:

  • 触发一个 异常(通常是非法指令)
  • 导致程序崩溃或行为异常
  • 被用来测试调试器是否存在(调试器通常会捕获异常)

这里我们直接nop掉它就行。

nop了之后,我在main的函数头进行U将这个函数解除定义,再使用P让main函数重新定义一下。但是发现F5反编译还是原来的两行代码,这里就出现问题了jmp eax 造成了控制流断裂

简单来说就是:jmp eax间接跳转,IDA 默认无法静态分析eax 的值是 loc_4010DE,所以 IDA 不会自动跟进控制流,loc_4010DE后面的逻辑并没有被反编译出来。

可以手动将 jmp eax 替换为 jmp loc_4010DE,这样就变成了一个直接跳转,IDA 就能追踪控制流。

也可以把 jmp eax 指令改为 nop 填充,意味着程序将不再跳转,而是继续顺序执行 loc_4010DE 后的代码。

把这个问题解决后,再使用U解除函数定义,然后再使用P重构一下main函数。F5反编译,这次就能反编译出来了。

进行分析,flag长度检测等价于

1
if (strlen(flag) == 0 || strlen(flag) >= 30)

所以flag的长度要大于0,小于30.

sub_4013F0(&v18)读取数据到v18中,我们需要知道具体的数据是什么,flag验证逻辑要用到它。

sub_4012A0(flag, (int)v9)是得到flag进行base64加密后的值。

进入sub_4013F0(&v18),会发现这里有很多反调试,这个函数的作用是生成一组数据供验证flag使用,而这些反调试的作用是修改这些原本正确的数据,如果使用动态调试来看数据就会被误导。

第一个反调试是数据检测BeingDebugged。先取出当前进程的 PEB 指针。然后访问 PEB+2 的位置,即 BeingDebugged 字段,BeingDebugged是一个BYTE,如果进程处于被调试状态,这个字段值是1,否则是 0。把 al 的值保存到栈上的局部变量 [ebp+var_51],再判断当前是否是调试状态(与 0 比较),对于这条指令cmp [ebp+var_51], 0,如果检测到调试,那么两者不相等,标志位ZF就设置为0,反之,如果未检测到调试,ZF就是1。(ZF是零标志位,作用是标记上一次算术或逻辑操作的结果是否为 0。)

下一条汇编指令setnz alsetnzset if not zero 的缩写,即 “设置非零”,它会检查标志寄存器中的 ZF(Zero Flag)

ZF = 0 → 说明比较结果不为零(即条件成立)→ al = 1
ZF = 1 → 比较结果为零(条件不成立)→ al = 0

最后把 0x32(十进制 50)加到 al 上:如果al=0,未调试,al=50=”2”。如果al=1,被调试,al=51=”3”。正常来看this[0]=2。

第二个是IsDebuggerPresent,如果当前进程没有被调试,IsDebuggerPresent() 返回 0,赋值 84(’T’)

否则返回 1,赋值 116(’t’)。

详细的解释:test eax, eax等价于 cmp eax, 0,检查 EAX 是否为 0,如果检测到调试了,eax是1,那么这条指令会使ZF=0,反之ZF=1。

jnz = jump if not zero → 当 ZF == 0 时跳转

所以:如果程序被调试(eax=1 → ZF=0)→ 跳转到 0x40142Dal 保持 't'

​ 如果未被调试(eax=0 → ZF=1)→ 不跳转,执行下一句al变为'T'

所以正确的this[1]=’T’

第三个是函数检测CheckRemoteDebuggerPresent,它是检查另一个进程是否在调试当前进程。通常配合 GetCurrentProcess() 用于自检。pbDebuggerPresent 是一个 BOOL 值的变量地址,函数调用后填充它:被调试就返回1,没有被调试就返回0。

那么真正的 this[2]=0+86=’V’

第四个是OutputDebugStringA + GetLastError trick,它是检测调试器是否读过调试信息。

SetLastError(12345) 设置一个错误码

OutputDebugStringA("Hello") 会让调试器接收一条调试信息

  • 如果没有调试器监听,这条调试信息没人读取,LastError 不会改变
  • 如果有调试器监听,它会清空 LastError

再次调用 GetLastError()

  • 如果是 12345说明没有调试器
  • 如果不是 12345说明有调试器

所以真正的this[3]=1+’A’=’B’

第五个是函数检测NtQueryInformationProcess,它使用 ntdll.dll 中的函数访问 ProcessDebugPort

如果程序正在被调试:ProcessDebugPort 返回非零(调试端口有效),那么v8=’N’

如果没有调试器:v17 = 0 表示未被调试,v8=’n’

所以真正的this[4]=v8=’n’

1
2
3
if ( CloseHandle((HANDLE)0x1234) || (v2 = GetLastError() == 6, v9 = 120, !v2) )
v9 = 121;
this[5] = v9;

第六个是GetLastError 检测

  • CloseHandle(0x1234):关闭一个假的(非法的)句柄。0x1234 不是一个有效的句柄。所以在正常情况下 CloseHandle 会返回 0(失败)。并且调用失败后,GetLastError() 返回的是:ERROR_INVALID_HANDLE (值为 6)
  • 逗号运算符 (A, B, C) 会依次计算 ABC,整个表达式的值是最后一个子表达式的结果。这里:
    1. v2 = (GetLastError() == 6)
      • GetLastError() 正常返回 6v2 = 1
    2. v9 = 120
      • v9 设为 120,也就是 ASCII 'x'
    3. !v2
      • v2 == 1!v2 == 0
      • 这就是整个逗号表达式的值

因此在“正常”情况下两边都为 0,if 条件 不成立不会执行 v9 = 121。最终 v9 保留为先前赋的 120 ('x')。

如果调试器或其他异常篡改 → `this[5] = ‘y’

所以真正的this[5]=v8=’x’

1
2
CurrentProcessId = GetCurrentProcessId();
this[6] = DebugActiveProcess(CurrentProcessId) + 48;

第七个是DebugActiveProcess

第一行时将获取当前正在运行的进程的 ID存入变量里,

DebugActiveProcess(CurrentProcessId): 这是一个 Windows API 函数。它的作用是附加到一个正在运行的进程,并开始调试它。也就是调试它自己。

如果程序已经被调试,返回 true,eax = 1。

如果未被调试,返回 false,eax = 0。

this[6]=0+48=48,将ascii码换成字符是0

1
2
3
4
5
6
7
8
9
10
11
12
GetStartupInfoA(&StartupInfo);
if ( StartupInfo.dwX
|| StartupInfo.dwY
|| StartupInfo.dwFillAttribute
|| StartupInfo.dwXSize
|| StartupInfo.dwYSize
|| StartupInfo.dwXCountChars
|| (v11 = 108, StartupInfo.dwYCountChars) )
{
v11 = 49;
}
this[7] = v11;

第八个是GetStartupInfoA 检测

第一行代码是获取当前进程启动时的一些信息(保存在 STARTUPINFOA 结构体中),比如窗口位置、大小、光标等。

然后检测这些结构体的字段是否非0,如果这些字段中有任何一个不为 0,就认为“启动信息被干预了”。因为正常执行程序时,这些字段 通常都是 0,那么如果都是0的话就没有检测到,v11=108,这个if条件为假。但凡有一个非零,这个if条件就是真,v11=49.

所以正确的 this[7] = v11=’l’。

1
2
3
4
5
6
7
v17 = 8;
sub_401580(this, &v17);
v12 = __rdtsc();
pbDebuggerPresent = v12; // 获取时间戳计数器
v13 = __rdtsc();
this[v17] = (unsigned int)(v13 - pbDebuggerPresent) < 0xFF ? 110 : 78;
return 1;

第九个是时间检测

主要看那个三目运算式

如果 v13 - pbDebuggerPresent < 0xFF:表示运行非常快 → 没被调试 → 'n'

否则:有调试行为(断点、单步等) → 'N'

正确的this[8] = ‘n’

第十个是进程检测

stricmp(a, b) 是大小写无关的字符串比较函数:

  • 如果 ab 相等 → 返回 0(表示找到)
  • 如果不相等 → 返回非 0

所以正常情况下,this[9]=’n’

综上我们就知道了这个真正的数据是”2TVBnx0lnn”

那我们来分析一下验证逻辑

1
if ( *((char *)&v18 + v8) != v9[2 * v8 + 1] ||v9[2 * v8] + 2 != (off_404018[v8] ^ 3) )

v8相当于i,i=9,共进行10轮循环。

奇数比较,v9[2*i+1]==V18[i]

偶数比较,v9[2*i]=(off_404018[v8] ^ 3)-2

我们也知道了off_404018[v8]

那就可以写出来exp了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import base64
v18="2TVBnx0lnn"
off_404018 = "LKd8gPYWS["
str=['']*20
for i in range(10):
str[i*2+1]=v18[i]
str[i*2]=chr((ord(off_404018[i])^3)-2)
encode_str=''.join(str)
print("构造的 Base64 字符串:", encode_str)

# Base64 解码
flag_bytes = base64.b64decode(encode_str)#解码之后返回的是一个 bytes 类型的对象(二进制形式的数据)
flag = flag_bytes.decode('utf-8')#这一步是将 字节(bytes)转换成普通的字符串(str)
print("还原得到的 flag:", f"flag{{{flag}}}")

#构造的 Base64 字符串: M2FTeV9BbnQxX0RlNnVn
#还原得到的 flag: flag{3aSy_Ant1_De6ug}