13、反调试技术(上):IsDebuggerPresent、NtGlobalFlag、PEB检测
各位同学,欢迎来到反调试技术的上半场。
说实话,反调试这个话题,我当年刚入门的时候觉得特别神秘。总觉得那些逆向大佬写出来的程序,像刺猬一样碰不得。后来自己踩了坑才明白——反调试说白了,就是程序在偷偷问系统一句话:「有人在盯着我吗?」
今天咱们就聊三种最基础、也最常用的反调试手段。它们都跟 Windows 系统底层的一个数据结构有关——PEB(进程环境块)。
13.1 什么是 PEB?为什么反调试离不开它?
每个进程在 Windows 里跑起来,系统都会给它分配一块内存区域,记录这个进程的各种信息。这块区域就叫 PEB。
你可以把 PEB 想象成进程的「身份证」加「档案袋」。里面存了进程的加载模块列表、堆信息、启动参数……还有我们今天要聊的调试状态标志。
我个人的习惯是,拿到一个未知的恶意样本,第一件事就是看看它有没有碰 PEB 里的标志位。如果有,那基本可以断定它做了反调试。
PEB 在 64 位系统里的地址可以通过 gs:[0x60] 拿到,32 位则是 fs:[0x30]。这个大家先记住,后面代码里会用到。
13.2 IsDebuggerPresent:最直白的反调试
这是 Windows 提供的一个官方 API。名字已经说明了一切——「当前进程是否被调试?」。
它的内部实现极其简单:
BOOL IsDebuggerPresent()
{
return NtCurrentPeb()->BeingDebugged;
}
没错,就是读 PEB 结构里的 BeingDebugged 字段。这个字段是 1 字节,值为 1 表示正在被调试,0 表示没有。
我见过很多新手写的反调试代码,直接这么写:
if (IsDebuggerPresent())
{
MessageBox(NULL, "别调试我!", "警告", MB_OK);
ExitProcess(-1);
}
嗯,这代码确实能防住一些刚入门的逆向分析者。但说实话,稍微有点经验的人,直接在调试器里把 BeingDebugged 改成 0,或者 hook 掉 IsDebuggerPresent 的返回,就绕过去了。
13.3 NtGlobalFlag:藏在堆里的调试痕迹
这个检测点稍微隐蔽一些。Windows 在创建进程时,如果检测到有调试器存在,会在 PEB 的 NtGlobalFlag 字段里设置一些特殊的标志位。
具体来说,NtGlobalFlag 位于 PEB 偏移 0x68(32位)或 0xBC(64位)的位置。正常情况下,这个值应该是 0。但如果被调试,它会被设置为 0x70。
为什么是 0x70?因为调试器会强制开启三个堆标志:
- FLG_HEAP_ENABLE_TAIL_CHECK (0x10)
- FLG_HEAP_ENABLE_FREE_CHECK (0x20)
- FLG_HEAP_VALIDATE_PARAMETERS (0x40)
这三个加起来,正好是 0x70。
检测代码也很简单:
BOOL CheckNtGlobalFlag()
{
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
DWORD offset = 0xBC;
#else
PPEB pPeb = (PPEB)__readfsdword(0x30);
DWORD offset = 0x68;
#endif
PDWORD pFlag = (PDWORD)((PBYTE)pPeb + offset);
return (*pFlag == 0x70);
}
GlobalFlag 注册表设置了堆标志,那 NtGlobalFlag 也可能不是 0。我曾经在一个项目里遇到过,程序本身为了调试内存泄漏开了堆检查,结果自己的反调试把自己给干掉了……所以用之前,先确认你的运行环境。
13.4 PEB.BeingDebugged 手动检测
既然 IsDebuggerPresent 本质上就是读 PEB,那我们完全可以自己动手读,不调用 API。这样能绕过一些只 hook API 的调试器。
直接上代码:
BOOL ManualPEBCheck()
{
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
#else
PPEB pPeb = (PPEB)__readfsdword(0x30);
#endif
return pPeb->BeingDebugged;
}
你看,代码量比调用 API 还少。而且很多调试器只会在 kernel32!IsDebuggerPresent 上下断点,不会去拦截你直接读 fs:[0x30] 的操作。
我个人比较喜欢这种手动方式。它更底层,也更难被静态分析工具检测到。
13.5 三种检测的对比与组合
咱们把今天讲的三种方法放在一起看看:
| 检测方法 | 检测位置 | 绕过难度 | 误报率 |
|---|---|---|---|
| IsDebuggerPresent | PEB.BeingDebugged | 低(API可hook) | 极低 |
| NtGlobalFlag | PEB+0x68/0xBC | 中(需手动改内存) | 中(受系统设置影响) |
| 手动PEB检测 | PEB.BeingDebugged | 中(需inline hook) | 极低 |
你想想看,如果只用一个 IsDebuggerPresent,那太容易被看穿了。但如果你把三种方法组合起来,在不同时机、不同位置分别检测,效果会好很多。
我见过一个样本,它在程序入口点用 IsDebuggerPresent 检测一次,然后在某个循环里用手动 PEB 检测再查一次,最后在解密关键代码前又查了一次 NtGlobalFlag。三重检测,每一层都不同。这种设计思路值得学习。
13.6 知识体系图
下面这张图帮你理清今天的内容:
13.7 避坑指南
最后,分享几个我踩过的坑:
- 别在程序入口就检测。 很多调试器会在入口点自动断下,你这时候检测,人家直接在内存里把标志改了再继续跑。我建议在程序运行一段时间后,在某个关键分支前检测。
- 检测代码别写得太明显。 比如连续三个
if判断,一眼就能看出来。可以用一些花指令或者间接跳转来混淆。 - 注意 32 位和 64 位的差异。 我刚开始写
NtGlobalFlag检测时,没注意偏移量不同,结果在 64 位系统上读出来的全是垃圾数据。后来花了半天才定位到问题。 - 别只依赖一种检测。 单一检测太容易被绕过。组合使用,并且让检测点分散在代码的不同位置,效果会好很多。
好了,今天的内容就到这里。这三种方法都是基于 PEB 的,说白了就是程序在问系统:「我是不是在被监视?」。下一节我们会聊一些更「脏」的手段——比如通过异常处理、时间差、线程隐藏等方式来反调试。那些方法更有意思,也更难对付。
核心要点回顾:
- PEB 是进程环境块,存储了调试状态信息
- IsDebuggerPresent 是最简单的 API 级检测
- NtGlobalFlag 检测堆标志位,正常为 0,调试时为 0x70
- 手动 PEB 检测通过 fs/gs 段寄存器直接读取 BeingDebugged
- 三种方法应组合使用,分散在程序不同位置