第十八章 反调试技术(三):反附加、父进程检测、断点扫描与完整性校验
各位好,欢迎来到第十八讲。今天聊的这几个技术,说白了就是「让调试器进不来、待不住、藏不了」。我在做恶意软件分析那几年,见过不少样本把这几个手法玩得炉火纯青。咱们一个一个拆开看。
一、反附加:让调试器无法绑定进程
调试器附加进程,本质上是调用 ptrace(Linux)或 DebugActiveProcess(Windows)。反附加的思路很简单——我先占住这个坑,你就别想再进来。
1.1 Linux 下的 ptrace 自占坑
Linux 下一个进程只能被一个 ptrace 跟踪。所以程序启动后,自己先 fork 一个子进程,让子进程 ptrace 父进程。这样调试器再来 ptrace,就会返回 -1。
// 反附加示例:ptrace 自跟踪
#include <sys/ptrace.h>
#include <unistd.h>
void anti_attach_ptrace() {
pid_t child = fork();
if (child == 0) {
// 子进程
pid_t parent = getppid();
ptrace(PTRACE_ATTACH, parent, NULL, NULL);
// 子进程挂起,父进程被跟踪
pause();
} else {
// 父进程继续执行
// 此时任何调试器都无法附加
}
}
嗯,这里有个坑。如果调试器先于你的代码附加,那 fork 出来的子进程反而会被调试器跟踪。所以这个手法通常放在程序入口最早的位置。
1.2 Windows 下的 NtSetInformationProcess
Windows 上更底层的方式是调用 NtSetInformationProcess,把进程的 ProcessDebugFlags 置为 0。这样内核会认为该进程不能被调试。
// Windows 反附加(内核级)
typedef NTSTATUS (NTAPI *pNtSetInformationProcess)(
HANDLE ProcessHandle,
PROCESS_INFORMATION_CLASS ProcessInformationClass,
PVOID ProcessInformation,
ULONG ProcessInformationLength
);
void anti_attach_win() {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
pNtSetInformationProcess NtSetInformationProcess =
(pNtSetInformationProcess)GetProcAddress(hNtdll, "NtSetInformationProcess");
ULONG flags = 0;
NtSetInformationProcess(GetCurrentProcess(),
(PROCESS_INFORMATION_CLASS)0x1F, // ProcessDebugFlags
&flags, sizeof(flags));
}
说实话,这个手法现在有点老了。Windows 10 以后的版本对这类调用做了限制,很多杀软也会拦截。我个人更推荐用下面要讲的父进程检测。
二、父进程检测:你是谁生的?
正常用户双击启动的程序,父进程是 explorer.exe。如果父进程是 cmd.exe、powershell.exe 或者调试器进程,那八成有问题。
2.1 Linux 下的 /proc 检查
#include <stdio.h>
#include <string.h>
int check_parent_linux() {
char path[256];
char buf[256];
FILE *fp;
// 读取 /proc/self/status 中的 PPid
fp = fopen("/proc/self/status", "r");
while (fgets(buf, sizeof(buf), fp)) {
if (strncmp(buf, "PPid:", 5) == 0) {
int ppid = atoi(buf + 6);
// 通过 ppid 读取父进程名称
snprintf(path, sizeof(path), "/proc/%d/comm", ppid);
FILE *fp2 = fopen(path, "r");
fgets(buf, sizeof(buf), fp2);
fclose(fp2);
// 检查父进程名
if (strstr(buf, "gdb") || strstr(buf, "strace")) {
return 1; // 被调试
}
break;
}
}
fclose(fp);
return 0;
}
我曾经遇到过一种情况:恶意软件把父进程伪装成 explorer.exe,但仔细看进程 ID 发现不对——真正的 explorer 的 PID 通常很小,而伪造的 PID 很大。所以光看名字不够,最好再校验一下 PID 的合理性。
2.2 Windows 下的 CreateToolhelp32Snapshot
// Windows 父进程检测
BOOL check_parent_win() {
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe = { sizeof(PROCESSENTRY32) };
// 获取当前进程的父进程 PID
DWORD ppid = GetParentProcessId(); // 需要自己实现
if (Process32First(hSnapshot, &pe)) {
do {
if (pe.th32ProcessID == ppid) {
// 检查父进程名
if (_stricmp(pe.szExeFile, "explorer.exe") != 0) {
return TRUE; // 父进程异常
}
break;
}
} while (Process32Next(hSnapshot, &pe));
}
CloseHandle(hSnapshot);
return FALSE;
}
三、断点扫描:检查代码有没有被改
调试器下断点的本质,是把目标地址的第一个字节改成 0xCC(int3)。那我们扫描一下代码段,看看有没有不该出现的 0xCC,不就知道了?
3.1 扫描代码段
#include <windows.h>
#include <stdio.h>
BOOL scan_for_cc() {
// 获取当前模块基址
HMODULE hMod = GetModuleHandle(NULL);
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hMod;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hMod + dos->e_lfanew);
// 获取代码段信息
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(nt);
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (memcmp(section[i].Name, ".text", 5) == 0) {
BYTE *code = (BYTE*)hMod + section[i].VirtualAddress;
DWORD size = section[i].SizeOfRawData;
// 扫描 0xCC
for (DWORD j = 0; j < size; j++) {
if (code[j] == 0xCC) {
// 排除正常的 int3 指令(比如编译器生成的)
// 这里需要更复杂的启发式判断
return TRUE;
}
}
}
}
return FALSE;
}
等等,这里有个问题。编译器有时候也会生成 0xCC 作为对齐填充。你想想看,如果误把编译器填充的 0xCC 当成断点,那就误报了。我建议的做法是:只扫描函数入口处的前几个字节,因为调试器通常把断点下在函数开头。
3.2 硬件断点检测
软件断点好查,硬件断点呢?硬件断点存在 DR0-DR3 寄存器里,不修改代码。检测方法是用 GetThreadContext 读取寄存器。
BOOL check_hardware_bp() {
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (GetThreadContext(GetCurrentThread(), &ctx)) {
if (ctx.Dr0 || ctx.Dr1 || ctx.Dr2 || ctx.Dr3) {
return TRUE; // 发现硬件断点
}
}
return FALSE;
}
四、完整性校验:确保没人动过我的代码
完整性校验,说白了就是给代码算个哈希。如果哈希变了,说明代码被修改了——可能是被打了补丁,也可能是被下了断点。
4.1 计算代码段哈希
#include <windows.h>
#include <wincrypt.h>
BOOL verify_integrity() {
HMODULE hMod = GetModuleHandle(NULL);
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hMod;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hMod + dos->e_lfanew);
// 找到 .text 段
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(nt);
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (memcmp(section[i].Name, ".text", 5) == 0) {
BYTE *code = (BYTE*)hMod + section[i].VirtualAddress;
DWORD size = section[i].SizeOfRawData;
// 计算 SHA256
HCRYPTPROV hProv;
HCRYPTHASH hHash;
BYTE hash[32];
DWORD hashLen = 32;
CryptAcquireContext(&hProv, NULL, NULL, PROV_RSA_AES, CRYPT_VERIFYCONTEXT);
CryptCreateHash(hProv, CALG_SHA_256, 0, 0, &hHash);
CryptHashData(hHash, code, size, 0);
CryptGetHashParam(hHash, HP_HASHVAL, hash, &hashLen, 0);
// 与预存哈希比较
// 注意:预存哈希不能放在代码段里,否则会被一起改
// 建议放在资源段或单独加密存储
return memcmp(hash, expected_hash, 32) == 0;
}
}
return FALSE;
}
1. 把哈希放在资源段,并加密
2. 或者从远程服务器获取
3. 或者用公钥验证签名(私钥不在程序里)
4.2 动态校验 vs 静态校验
| 类型 | 优点 | 缺点 |
|---|---|---|
| 静态校验(启动时一次) | 速度快,不影响运行时性能 | 调试器可以在校验通过后再修改代码 |
| 动态校验(定时检查) | 能检测到运行时的修改 | 影响性能,容易被绕过(比如暂停校验线程) |
| 混合校验 | 兼顾安全性和性能 | 实现复杂 |
我个人习惯用混合校验:启动时做一次全量校验,运行时随机抽查几个关键函数。这样既不会太慢,又能覆盖大部分攻击场景。
五、知识体系总览
下面这张图把本章涉及的技术和它们之间的关系梳理了一下。你可以看到,反调试不是单一技术,而是一套组合拳。
六、避坑指南与实战建议
做了这么多年逆向,我总结了几条关于反调试的「血泪教训」:
- 不要过度依赖单一技术。我曾经见过一个保护方案只用了 ptrace 自跟踪,结果攻击者用
LD_PRELOAD劫持了 ptrace 函数,直接失效。 - 反调试代码本身也要保护。如果你把反调试逻辑写得明明白白,攻击者直接 patch 掉就完了。建议把关键判断逻辑用混淆或虚拟化保护起来。
- 考虑兼容性。有些反调试手法在 Wine 或 Proton 环境下会崩溃。如果你的软件要跨平台运行,务必测试。
- 性能开销要可控。完整性校验如果每 10ms 算一次 SHA256,CPU 直接拉满。我建议把校验间隔设在 500ms-2s 之间,随机抖动一下时间点。
好了,这一讲的内容就到这里。反调试是个攻防博弈的过程,没有绝对的安全。但每增加一层保护,就能筛掉一批低水平的攻击者。咱们的目标不是让系统攻不破,而是让攻击成本高到对方放弃。
公众号:蓝海资料掘金营,微信deep3321