反调试
反调试
反调试是什么?
反调试是用来检测并阻止程序被调试器(debugger)分析的一种技术,当程序意识到自己可能处于调试中的时候,可能会改变正常的执行路径或者修改自身程序让自己崩溃,从而增加调试时间和复杂度。
函数检测
函数检测就是通过 Windows 自带的公开或未公开的函数直接检测程序是否处于调试状态。
| Windows API | 作用 | 
|---|---|
| IsDebuggerPresent | 检测当前进程是否被调试 | 
| CheckRemoteDebuggerPresent | 检测一个远程进程是否处于调试状态 | 
| NtQueryInformationProcess() | 获取进程信息,判断调试状态 | 
IsDebuggerPresent
| 1 | BOOL WINAPI IsDebuggerPresent(void); | 
该函数查询进程环境块(PEB)中的 BeingDebugged 标志,如果进程处在调试上下文中,则返回1,否则返回0。
CheckRemoteDebuggerPresent
| 1 | BOOL WINAPI CheckRemoteDebuggerPresent( | 
如果 hProcess 句柄表示的进程处于调试上下文,则设置 pbDebuggerPresent 变量被设置为 TRUE,否则被设置为 FALSE。
NtQueryInformationProcess
| 1 | NTSTATUS WINAPI NtQueryInformationProcess( | 
第二个参数 ProcessInformationClass 给定了需要查询的进程信息类型。当给定值为 0(ProcessBasicInformation)或 7(ProcessDebugPort)时,就能得到相关调试信息,返回信息会写到第三个参数 ProcessInformation 指向的缓冲区中。
数据检测
数据检测是指程序通过测试一些与调试相关的关键位置的数据来判断是否处于调试状态。比如上面所说的 PEB 中的 BeingDebugged 参数。数据检测就是直接定位到这些数据地址并测试其中的数据,从而避免调用函数,使程序的行为更加隐蔽。
BeingDebugged
| 1 | BOOL CheckDebug() | 
这个函数采用了 汇编方式直接访问 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 | BOOL CheckDebug() | 
这个运行原理是调试器中启动的进程与正常启动的进程创建堆的方式有些不同,系统使用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 | BOOL CheckDebug() | 
特征码检测
特征码检测枚举当前正在运行的进程,并在进程的内存空间中搜索特定调试器的代码片段。
例如 OllyDbg 有这样一段特征码:
| 1 | 0x41, 0x00, 0x62, 0x00, 0x6f, 0x00, 0x75, 0x00, 0x74, 0x00, | 
| 1 | BOOL CheckDebug() | 
这段代码扫描所有进程,读取每个进程某个特定内存地址(0x004F632A),并判断该处内容是否匹配 “About OllyDbg”(Unicode 编码)。如果匹配,就说明系统中有可能在运行 OllyDbg 调试器,从而返回异常行为。
原理:OllyDbg 在启动时其 .data 节或资源数据段中固定包含 “About OllyDbg\0OK\0” 字符串,且位置稳定
时间检测
时间检测是指在程序中通过代码感知程序处于调试时与未处于调试时的各种运行时间差异来判断程序是否处于调试状态。
例如我们在调试时步过两条指令所花费的时间远远超过CPU正常执行花费的时间,于是就可以通过rdtsc指令(汇编指令)或者GetTickCount函数(WindowsAPI)来进行测试。
rdtsc
rdtsc指令用于将时间标签计数器读入EDX:EAX寄存器。
| 1 | BOOL CheckDebug() | 
时间检测型反调试函数,通过调用两次 RDTSC 指令比较时间差,如果中间被断点(调试器)拖慢了,就判断为正在调试。
GetTickCount
GetTickCount返回从操作系统启动所经过的毫秒数。
| 1 | BOOL CheckDebug() | 
如果两次读取之间花费了超过 26ms,就认为程序在中间被“暂停过”,可能是调试器单步或断点。
断点检测
断点检测是根据调试器设置断点的原理来检测软件代码中是否设置了断点。调试器一般使用两者方法设置代码断点:
- 通过修改代码指令为 INT3(机器码为0xCC)触发软件异常
- 通过硬件调试寄存器设置硬件断点
软件断点检测
针对软件断点,检测系统会扫描比较重要的代码区域,看是否存在多余的 INT3 指令。
| 1 | BOOL CheckDebug() | 
如果找到了 0xCC,就跳转到 Found,并设置 Found = 1 
硬件断点检测
而对于硬件断点,由于程序工作在保护模式下,无法访问硬件调试断点,所以一般需要构建异常程序来获取 DR 寄存器的值
| 1 | BOOL CheckDebug() | 
Dr0~`Dr3` 是 4 个硬件断点寄存器
调试器(如 x64dbg)设置硬断点时,会在这些寄存器中写入地址
基于硬件断点的反调试检测方法,原理是检测当前线程的调试寄存器(Dr0~`Dr3`)是否被设置。如果其中任意一个寄存器不为 0,则说明可能存在硬件断点,进而推测当前程序正被调试。
题目练习
使用IDA打开后发现只有两行

查看一下爆红地方的汇编

| 1 | mov eax, offset loc_4010DE | 
这是跳转到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 al :setnz 是 set 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)→ 跳转到 0x40142D,al 保持 '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 | if ( CloseHandle((HANDLE)0x1234) || (v2 = GetLastError() == 6, v9 = 120, !v2) ) | 
第六个是GetLastError 检测
- CloseHandle(0x1234):关闭一个假的(非法的)句柄。- 0x1234不是一个有效的句柄。所以在正常情况下- CloseHandle会返回 0(失败)。并且调用失败后,- GetLastError()返回的是:ERROR_INVALID_HANDLE (值为 6)
- 逗号运算符 (A, B, C)会依次计算A、B、C,整个表达式的值是最后一个子表达式的结果。这里:- v2 = (GetLastError() == 6)- GetLastError()正常返回- 6→- v2 = 1
 
- v9 = 120- 将 v9设为120,也就是 ASCII'x'
 
- 将 
- !v2- v2 == 1→- !v2 == 0
- 这就是整个逗号表达式的值
 
 
因此在“正常”情况下两边都为 0,if 条件 不成立,不会执行 v9 = 121。最终 v9 保留为先前赋的 120 ('x')。
如果调试器或其他异常篡改 → `this[5] = ‘y’
所以真正的this[5]=v8=’x’
| 1 | CurrentProcessId = GetCurrentProcessId(); | 
第七个是DebugActiveProcess
第一行时将获取当前正在运行的进程的 ID存入变量里,
DebugActiveProcess(CurrentProcessId): 这是一个 Windows API 函数。它的作用是附加到一个正在运行的进程,并开始调试它。也就是调试它自己。
如果程序已经被调试,返回 true,eax = 1。
如果未被调试,返回 false,eax = 0。
this[6]=0+48=48,将ascii码换成字符是0
| 1 | GetStartupInfoA(&StartupInfo); | 
第八个是GetStartupInfoA 检测
第一行代码是获取当前进程启动时的一些信息(保存在 STARTUPINFOA 结构体中),比如窗口位置、大小、光标等。
然后检测这些结构体的字段是否非0,如果这些字段中有任何一个不为 0,就认为“启动信息被干预了”。因为正常执行程序时,这些字段 通常都是 0,那么如果都是0的话就没有检测到,v11=108,这个if条件为假。但凡有一个非零,这个if条件就是真,v11=49.
所以正确的 this[7] = v11=’l’。
| 1 | v17 = 8; | 
第九个是时间检测
主要看那个三目运算式
如果 v13 - pbDebuggerPresent < 0xFF:表示运行非常快 → 没被调试 → 'n'
否则:有调试行为(断点、单步等) → 'N'
正确的this[8] = ‘n’
第十个是进程检测
 
stricmp(a, b) 是大小写无关的字符串比较函数:
- 如果 a和b相等 → 返回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 | import base64 | 



