一种基于创建线程时延的反调试方案
一、引言
程序在被调试时,某些操作的速度会发生可观测的变化——这种变化由调试器运行机制客观带来,无法通过简单修改几个内存标志位来掩盖,几乎所有的时间差检测方法都基于此。
本文讨论的是一种基于时序观测的方法:通过测量大量线程创建操作的总耗时,观察是否存在调试器介入导致的延迟。本文范围仅限于用户态(Ring 3)实现。需要说明的是,时序观测本身存在不确定性——系统负载、CPU 频率变化等因素都会影响测量结果——因此它更适合作为一种参考信号,而非唯一的判定依据。
二、现有常见的时差反调试方法
在具体展开本文方案之前,有必要先梳理一下业界已有的几种主流时差反调试手段。了解它们各自的思路和局限,有助于理解为什么后来的方案会走向"高频批量 + 统计建模"的方向。
2.1 基于 RDTSC 指令的检测
RDTSC(Read Time-Stamp Counter)是 x86 架构提供的一条汇编指令,用于读取 CPU 自启动以来累计的时钟周期数。由于它的精度极高(纳秒级),早期很多反调试方案直接在关键代码段前后嵌入 RDTSC 指令,通过比较两次读数的差值来判断是否存在调试延迟。
典型的检测逻辑如下:
rdtsc ; 读取起始周期
mov start, eax
; ... 执行一段敏感操作 ...
rdtsc ; 读取结束周期
sub eax, start
cmp eax, threshold ; 若差值超过阈值则判定为被调试
这个方法一度非常流行(甚至现在也很流行),因为它不需要调用任何 WinAPI,完全在指令层面工作。但它有几个明显的缺点:
- 多核不同步:在多核处理器上,不同核心的 TSC 初始值可能不一致,导致读数跳变
- 指令乱序:CPU 可能会在
RDTSC 之前或之后重新排序其他指令,影响测量准确性
- 时机固定:只能在固定的运行流程进行检测
2.2 基于 GetTickCount 的循环耗时检测
另一个常用方案是利用 GetTickCount 测量一段循环的总耗时。例如,执行一个固定次数的空循环,比较循环前后的系统滴答计数:
DWORD start = GetTickCount();
for (int i = 0; i < 1000000; i++) {
// 空操作
}
DWORD end = GetTickCount();
if (end - start > EXPECTED_MS) {
// 可能被调试
}
这个方案假设调试器在单步执行时会大幅减慢循环速度。它的优点是实现极其简单。但问题也很突出:
- 精度太低:
GetTickCount 的精度通常只有 10~16 毫秒,对于现代 CPU 执行百万次空循环(可能只需几毫秒)来说完全不够用
- 极易被 Patch:攻击者可以直接修改
GetTickCount 的返回值比如0,甚至都无需考虑曲线变动
- 阈值硬编码:使用固定阈值,受系统负载波动、CPU 降频等因素都可能导致耗时增加/减少
2.3 更多基于时间检测的方法
请详见参考资料Anti-Debug: Timing
2.4 本节小结
上述传统时差检测方法各有特色,但有几个共同的局限:
| 问题 |
表现 |
| 单点测量 |
只测一次或几次,检测时机固定 |
| 精度不足 |
GetTickCount 级别精度无法捕捉微秒级差异 |
| 阈值硬编码 |
不同硬件环境下绝对耗时差异巨大,固定阈值极易产生误报 |
这些问题的共同根源在于:用一次或少数几次测量来推断一个复杂系统的状态,本质上是不稳健的。如果要提升可靠性,笔者认为可以从"单次测量"走向"批量统计"——这正是本文后续方案的设计方向。
三、时序观测的可靠性问题
在深入技术细节之前,有必要先讨论一个根本问题:时序观测到底可不可靠?
3.1 时序信息的不确定性
计算机系统的执行时间受到众多因素影响,其中很多因素与调试器无关:
- 操作系统调度:时间片轮转、线程优先级、CPU 亲和性
- 硬件状态:CPU 频率缩放、缓存命中率、分支预测
- 系统负载:其他进程占用 CPU、磁盘 I/O、内存压力
- 虚拟化环境:VMM 对敏感指令的拦截
这些因素叠加在一起,使得任何单次时序测量都充满了噪声。即使在同一台机器上连续运行相同的代码,测量结果也可能有 ±10% 甚至更大的波动。
3.2 时序观测的本质
与其将时序方法称为"检测",不如称之为"观测"——它提供的是一个异常信号,而非确定的结论。时序观测能告诉我们的只是"这次操作花费的时间比平时长",至于是因为调试器介入还是因为系统卡顿,单凭时序数据无法确定。
因此,时序观测的价值在于"提供异常信号" ,合理的方案应将其作为众多参考信号之一,而非唯一的判定依据。
3.3 减少误判的常用方法
虽然无法完全消除噪声,但以下工程实践可以有效降低误判率:
- 批量测量:单次测量不可靠,但多次测量的累积耗时具有统计意义
- 相对比较而非绝对比较:使用动态基线而非固定阈值,让程序自行适应当前环境
- 连续命中:要求多次异常才触发判定,过滤偶发性波动
上述方法能在一定程度上提升观测的可靠性,但它们解决的只是数据处理层面的问题。要真正理解观测到的延迟从何而来,还需要深入底层机制——为什么调试器的存在会影响线程创建的速度?
四、Windows 调试事件与同步开销
要理解调试器为何会影响线程创建的耗时,需要先了解 Windows 调试子系统的基本工作机制。
4.1 调试对象(DEBUG_OBJECT)
当调试器附加到目标进程时,内核会创建一个 DEBUG_OBJECT 结构体。该结构体定义如下:
typedef struct _DEBUG_OBJECT {
KEVENT EventsPresent; // 用于指示有调试事件发生的信号量
FAST_MUTEX Mutex; // 保护 EventList 的互斥锁
LIST_ENTRY EventList; // 调试事件链表
ULONG Flags;
} DEBUG_OBJECT;
调试器持有该对象的句柄(存放在 TEB 的 DbgSsReserved 字段),而被调试进程的 EPROCESS.DebugPort 字段则指向同一个调试对象。通过这个共享的内核对象,调试器和被调试进程建立了通信通道。
4.2 调试器的两种启动方式
用户态调试器建立调试关系有两种方式:
方式一:通过 CreateProcess 创建新进程
调试器调用 CreateProcess 时,在 dwCreationFlags 参数中传入 DEBUG_PROCESS 或 DEBUG_ONLY_THIS_PROCESS 标志:
CreateProcess(..., DEBUG_PROCESS, ...);
此时内核中的进程创建函数 PspCreateProcess 会检查传入的 DebugPort 句柄是否为空,若不为空,则将其转换为 DEBUG_OBJECT 指针并存储于新进程 EPROCESS 的 DebugPort 字段中。新进程在用户态代码执行之前,系统会向其调试器发送 CREATE_PROCESS_DEBUG_EVENT 调试事件。
方式二:通过 DebugActiveProcess 附加到运行中的进程
调试器调用 DebugActiveProcess(dwProcessId),系统会挂起目标进程中的所有线程,并向调试器发送一个 CREATE_PROCESS_DEBUG_EVENT 调试事件。该事件标识了目标进程及其主线程。
两种方式的共同点
无论采用哪种方式,一旦调试关系建立,后续的调试事件处理流程完全相同:
- 被调试进程的
EPROCESS.DebugPort 指向同一个 DEBUG_OBJECT
- 调试器进入调试循环:
WaitForDebugEvent → 处理事件 → ContinueDebugEvent
- 任何触发调试事件的操作(线程创建、异常、DLL加载等)都会经历相同的同步等待流程
因此,本文讨论的线程创建时延观测,无论目标进程是被调试器"创建"还是"附加",其底层机制完全一致——线程创建时都会触发 CREATE_THREAD_DEBUG_EVENT,并等待调试器的 ContinueDebugEvent。
4.3 调试事件的产生
在被调试进程中,某些操作会触发调试事件:
| 事件类型 |
触发时机 |
CREATE_THREAD_DEBUG_EVENT |
创建新线程 |
EXIT_THREAD_DEBUG_EVENT |
线程退出 |
CREATE_PROCESS_DEBUG_EVENT |
创建新进程 |
EXIT_PROCESS_DEBUG_EVENT |
进程退出 |
EXCEPTION_DEBUG_EVENT |
发生异常 |
LOAD_DLL_DEBUG_EVENT / UNLOAD_DLL_DEBUG_EVENT |
加载/卸载 DLL |
当这些事件发生时,内核中的相关函数(如 DbgkCreateThread)会检查当前进程的 DebugPort 是否为空。若非空,则构造 DEBUG_EVENT 结构体,挂入 EventList 链表,然后通过 EventsPresent 信号量唤醒调试器。
4.4 调试事件的完整处理流程
以线程创建为例,完整流程如下:
被调试进程侧:
- 调用
CreateThread → ntdll!NtCreateThreadEx → 系统调用陷入内核
- 内核创建线程对象,
DbgkCreateThread 检查 DebugPort,若非空则创建 DEBUG_EVENT 并入队
- 置位
EventsPresent 信号量,唤醒调试器
- 调用
KeWaitForSingleObject,等待调试器的 ContinueEvent 信号
调试器侧:
- 调试器在
WaitForDebugEvent 中被唤醒
- 从
EventList 中取出调试事件
- 调试器处理事件(更新界面、记录线程信息等)
- 调用
ContinueDebugEvent 发送继续信号
关键点:在被调试进程收到 ContinueEvent 之前,CreateThread 的系统调用不会返回。这个强制性的同步等待环节,就是调试器施加的可观测延迟。
4.5 调试事件处理流程图
┌─────────────────────────────────────────────────────────────────┐
│ 调试器 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 调试循环 │ │
│ │ ┌─────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ WaitForDebugEvent│───▶│ 处理调试事件 │ │ │
│ │ │ (等待调试事件) │ │ (更新UI/记录线程等) │ │ │
│ │ └─────────────────┘ └───────────┬─────────────┘ │ │
│ │ ▲ │ │ │
│ │ │ ▼ │ │
│ │ │ ┌─────────────────────────┐ │ │
│ │ │ │ ContinueDebugEvent │ │ │
│ │ │ │ (恢复被调试进程执行) │ │ │
│ │ │ └───────────┬─────────────┘ │ │
│ │ │ │ │ │
│ └──────────────┼─────────────────────┼─────────────────────┘ │
│ │ │ │
└─────────────────┼─────────────────────┼─────────────────────────┘
│ │
EventsPresent 信号量 ContinueEvent 信号量
(有调试事件待处理) (调试器已处理完毕)
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ 被调试进程 │
│ │
│ 触发调试事件(如 CreateThread) │
│ │ │
│ ▼ │
│ 内核检查 DebugPort 是否为空 │
│ │ │
│ ▼ │
│ 若非空:构造 DEBUG_EVENT 并入 EventList 链表 │
│ │ │
│ ▼ │
│ 置位 EventsPresent 信号量,唤醒调试器 │
│ │ │
│ ▼ │
│ 调用 KeWaitForSingleObject,等待 ContinueEvent 信号 │
│ │ │
│ ▼ │
│ 收到 ContinueEvent 信号后恢复执行 │
│ │
└─────────────────────────────────────────────────────────────────┘
4.6 时间差从何而来
不被调试时,CreateThread 的执行是纯粹的内核调度流程,耗时通常在微秒级别。
被调试时,返回被插入了一个同步等待环节——等调试器处理完事件并调用 ContinueDebugEvent 才能继续。
这个"创建事件 → 通知调试器 → 等待处理 → 恢复执行"的往返过程,就是时序观测量试图捕捉的调试器同步开销。
五、常用计时方式
既然要观测时间差,选择合适的计时工具是第一步。
| 计时方式 |
精度 |
调用开销 |
特点 |
GetTickCount / GetTickCount64 |
~10-16ms |
极低 |
适合粗粒度计时,不适合微秒级测量 |
GetSystemTimeAsFileTime |
100ns(理论) |
较高 |
涉及用户态到内核态的转换 |
QueryPerformanceCounter |
<1μs |
低 |
基于硬件计数器,精度高且开销低 |
RDTSC 指令 |
~ns |
极低 |
CPU 级别精度,但存在多核同步问题 |
QueryPerformanceCounter 自 Windows 2000 起引入,在大多数现代实现中基于硬件计数器,不强制陷入内核态。微软官方文档建议使用 QueryPerformanceCounter 而非直接使用 RDTSC。
RDTSC 的问题在于:多核处理器不保证各核心的计数器同步,且现代电源管理技术可能导致核心间不同步。微软强烈不建议在应用层直接使用 RDTSC 进行高精度计时。
综合来看,QueryPerformanceCounter 是在用户态进行微秒级时序观测的合适选择。
六、设计方案
6.1 基本思路
基于前文分析,观测思路如下:
在一个循环中多次调用 CreateThread 创建临时线程,并用高精度计时器测量整轮循环的总耗时。如果当前进程被调试,每个线程创建都需要等待调试器处理 CREATE_THREAD_DEBUG_EVENT,累积的同步开销会使总耗时显著增加。
单次线程创建的延迟增量可能只有几十微秒,但通过批量操作可将差异放大到易于观测的程度。
6.2 操作流程
一轮观测的定义:连续执行 100 次线程创建操作(包括创建、等待结束、关闭句柄),记为 1 组。
每组观测的具体步骤:
- 调用
QueryPerformanceCounter 记录本组起始时间
- 循环执行 100 次以下操作:
- 调用
CreateThread 创建一个执行空函数的线程
- 调用
WaitForSingleObject 等待该线程结束
- 调用
CloseHandle 关闭线程句柄
- 调用
QueryPerformanceCounter 记录本组结束时间
- 计算本组总耗时
在连续运行多组观测时,可以记录每组的总耗时。
此外,也可以选择记录每组内部 100 次操作的单次耗时平均值(即总耗时除以 100),以及该组内的最大单次耗时、最小单次耗时,作为辅助参考数据。
还可以记录相邻两组总耗时之间的差值(即 |Tᵢ − Tᵢ₋₁|),用于判断耗时是否存在突发性跳变——调试器在批量处理调试事件时可能产生不稳定的延迟,导致相邻两组的耗时差异显著大于正常波动范围。
这些辅助统计量(平均值、最大值、最小值、相邻差值)有利于在不同轮次之间做横向对比,减少因每组总耗时受系统负载偶然波动的影响。
将所有组的耗时数据按时间顺序排列,即可得到一组时序序列 T₁, T₂, T₃, ..., Tₙ,供后续的统计模型分析使用。
6.3 观测数据的分析方法
单轮观测的耗时数值受系统负载、CPU 频率等因素影响,单次测量不可靠。更稳健的做法是运行多轮观测,从多个角度分析数据。
设第 i 轮观测值为 Tᵢ,历史数据的均值为 T̄、标准差为 σ,当前批次的均值为 μ,则可以从以下几个维度观察数据特征:
1. 与历史均值的偏差
D₁ = (Tᵢ − T̄) / T̄
当前值偏离历史平均水平的相对程度。正值表示偏慢,负值表示偏快。
2. 相邻轮次的变化幅度
D₂ = |Tᵢ − Tᵢ₋₁| / T̄
相邻两轮之间的波动大小。如果某轮耗时突然大幅跳变,可能暗示调试器在处理积压事件。
3. 极值与当前均值的比值
D₃ = T_max / μ
历史最大耗时相对于当前平均水平的倍数。如果出现过高的极值,说明存在偶发的严重延迟。
4. 数据的离散程度
D₄ = σ / μ
变异系数,衡量数据的相对波动大小。调试环境下,由于调试器事件处理的不稳定性,耗时的波动通常会更大。
5. 偏离历史均值的程度(标准差倍数)
D₅ = (Tᵢ − T̄) / σ
用历史标准差作为"尺子",衡量当前值偏离均值的程度。D₅ 越大,说明当前值越异常。
6. 近期趋势
采用指数加权移动平均(EWMA)观察近期走势:
Eᵢ = α · Tᵢ + (1 − α) · Eᵢ₋₁
D₆ = Eᵢ / μ
其中 α 取 0.2 左右。如果近期趋势持续高于整体均值,说明可能存在持续的外部干扰。
各维度汇总:
| 维度 |
含义 |
计算公式 |
| 与历史均值的偏差 |
当前值相对历史均值的偏离程度 |
(Tᵢ − T̄) / T̄ |
| 相邻轮次变化幅度 |
相邻两轮之间的波动 |
|Tᵢ − Tᵢ₋₁|/T̄ |
| 极值与均值比值 |
历史极值的相对倍数 |
T_max / μ |
| 数据离散程度 |
当前批次的波动大小 |
σ / μ |
| 偏离历史均值的程度 |
异常程度(标准差单位) |
(Tᵢ − T̄) / σ |
| 近期趋势 |
指数移动平均与均值的对比 |
Eᵢ / μ |
将各维度的数值与经验参考范围比较,多个维度同时指向异常时,信号的可信度更高。
具体判断范围,笔者建议是先写一个计时例程手动记录观察各种不同状态下的数据变动情况,有条件甚至可以画一下统计图进行观察。
6.4 综合评分
在获得六个维度的观测值后,可进一步合成为一个综合评分。
基本思路是:对每个维度计算其偏离"理想状态"的程度,经适当缩放后按权重加权求和。不同维度可赋予不同权重——例如,数据离散程度的变化可能比极值变化更能反映调试器影响,可赋予更高权重。
综合评分作为一个整体信号,比单独看任何一个维度都更稳定、更具参考价值。
6.5 分段连续观测
在连续运行多组观测时,如果长时间不间断地执行线程创建循环,可能会引入一个隐蔽的干扰因素:操作本身的开销会逐渐累积。
具体来说,CreateThread → WaitForSingleObject → CloseHandle 这一组操作并非零成本。每次线程创建都涉及内核对象分配、栈空间分配、调度器入队等操作。当连续执行成千上万次时,这些操作本身的累积耗时就已经相当可观。更关键的是,长时间运行会导致:
- 内存碎片化:频繁分配和释放线程内核对象,可能导致内核内存池碎片化,使后续创建操作变慢
- 缓存污染:大量线程创建和销毁操作会不断刷新 CPU 缓存,影响后续操作的执行速度
- 系统负载升高:持续的线程创建活动会增加系统整体负载,进而影响计时精度
在这些因素的影响下,如果仅仅统计“从开始到结束的总耗时”,数值会随时间推移逐渐膨胀,导致历史基线不断上移,最终使观测信号失真——调试器带来的延迟增量可能被淹没在操作本身的开销中。
为解决这一问题,实践中常采用分段连续观测策略:
- 将整个观测过程分为多个连续段落
- 每段落包含固定轮次(如 100 轮 × 100 次创建 = 10000 次)
- 每段落结束后,强制调用
Sleep 让出 CPU 时间片,释放内存池碎片
- 每段落结束后计算该段落的统计值和综合评分
- 每个段落的统计独立于其他段落,仅当连续多个段落的综合评分均偏高时,才触发后续动作
这种分段设计的核心好处在于:每一段都在一个“相对干净”的环境下执行,段与段之间通过短暂停顿重置系统状态,避免了长时间连续运行导致的累积误差。而“连续命中”策略则有效过滤了偶发性的系统负载波动,提高了异常信号的可靠性。
七、实测效果
理论分析得再多,最终还是要看实际效果。笔者在多种调试器环境下对该方案进行了测试,结果如下:
测试对象:
- x32dbg May 27 2026 搭配 ScyllaHide v1.4.750 + SharpOD x64 ver 0.6(e) 插件
- OllyDbg 搭配 SharpOD x64 ver 0.6(e)插件
- Cheat Engine 7.7 的 VEH 调试器模式
- Cheat Engine 7.7 的 Windows 调试器模式
测试结果:
| 测试环境 |
观测结果 |
| 正常执行(无调试器) |
⚠️ 偶现误报 |
| x32dbg + ScyllaHide + SharpOD |
✅️稳定触发 |
| OllyDbg + SharpOD |
✅️稳定触发 |
| Cheat Engine(VEH 调试器) |
✅️只要附加调试器,等待一段时间稳定触发 |
| Cheat Engine(Windows 调试器) |
✅️使用谁访问/改写该地址功能时稳定触发 |
有趣的是每种调试工具可能命中维度,命中次数,都是不一样的,例如OllyDbg只需要判断平均峰值,Cheat Engine(VEH 调试器)则是会不断拉高峰值比例。理论上只要能收集足够多的数据,甚至可以大致判断命中某一类调试器。
| 真实测试数据 |
| 正常执行 |
| 评分:96.58 \ 状态:正常 \ 分项得分:F1=96.20, F2=96.20, F3=100.00, F4=95.60, F5=99.00, F6=96.00 |
| Cheat Engine(VEH 调试器) |
| 评分:16.80 \ 状态:异常 \ 分项得分:F1=62.95, F2=40.15, F3=0.00, F4=0.00, F5=92.00, F6=26.00 |
| Cheat Engine(Windows 调试器) |
| 评分:44.25 \ 状态:异常 \ 分项得分:F1=90.50, F2=100.00, F3=100.00, F4=0.00, F5=100.00, F6=68.00 |
| x32dbg+ ScyllaHide + SharpOD |
| 评分:43.42 \ 状态:异常 \分项得分:F1=81.95, F2=71.50, F3=100.00, F4=0.00, F5=99.00, F6=78.00 |
| OllyDbg + SharpOD |
| 因平均峰值极大超出正常范围,未进入最终评分阶段即可直接划入异常 |
不能忽略的是,在测试过程中的确有观察到误报现象,尤其是在系统本身比较卡顿,打开任务管理器,资源监视器之类的程序时会变的更加频发,但是依旧与正常执行存在明显差异,同时需要说明,这里的误报仅限于笔者使用的测试程序中设置的阈值和计算参数。
在实际应用本方案时,最佳选择是重新建立基线并调整阈值,以获得更优的观测效果,这也是本文未能提供具体阈值参数的原因。
八、结语
需要再次强调的是时序观测本身存在不确定性——系统负载、硬件状态、虚拟化环境等因素都会对测量结果产生干扰。因此,它更适合作为反调试体系中的一个信号源,而非唯一的判定依据。在实际应用中,它通常需要与其他观测手段配合使用。
因笔者水平有限,文中多处有参考各位大牛文章,另文中如有疏漏或不足之处,欢迎指正。
九、参考资料
[1] Anti-Debug: Timing
[2] 软件调试 - OneTrainee - 博客园
[3] 调试进程与被调试进程之间的桥梁 - psj00 - 博客园