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 的返回,就绕过去了。

我的经验: 别单独用 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 知识体系图

下面这张图帮你理清今天的内容:

反调试技术(上) IsDebuggerPresent 调用 kernel32 API 读取 BeingDebugged NtGlobalFlag PEB +0x68 / +0xBC 检查是否 == 0x70 手动PEB检测 fs:[0x30] / gs:[0x60] 直接读 BeingDebugged 核心思路:检测 PEB 中的调试标志位 三种方法本质相同,但实现层次不同

13.7 避坑指南

最后,分享几个我踩过的坑:

  • 别在程序入口就检测。 很多调试器会在入口点自动断下,你这时候检测,人家直接在内存里把标志改了再继续跑。我建议在程序运行一段时间后,在某个关键分支前检测。
  • 检测代码别写得太明显。 比如连续三个 if 判断,一眼就能看出来。可以用一些花指令或者间接跳转来混淆。
  • 注意 32 位和 64 位的差异。 我刚开始写 NtGlobalFlag 检测时,没注意偏移量不同,结果在 64 位系统上读出来的全是垃圾数据。后来花了半天才定位到问题。
  • 别只依赖一种检测。 单一检测太容易被绕过。组合使用,并且让检测点分散在代码的不同位置,效果会好很多。

好了,今天的内容就到这里。这三种方法都是基于 PEB 的,说白了就是程序在问系统:「我是不是在被监视?」。下一节我们会聊一些更「脏」的手段——比如通过异常处理、时间差、线程隐藏等方式来反调试。那些方法更有意思,也更难对付。


核心要点回顾:

  • PEB 是进程环境块,存储了调试状态信息
  • IsDebuggerPresent 是最简单的 API 级检测
  • NtGlobalFlag 检测堆标志位,正常为 0,调试时为 0x70
  • 手动 PEB 检测通过 fs/gs 段寄存器直接读取 BeingDebugged
  • 三种方法应组合使用,分散在程序不同位置
公众号:蓝海资料掘金营,微信deep3321