第十八章 反调试技术(三):反附加、父进程检测、断点扫描与完整性校验

各位好,欢迎来到第十八讲。今天聊的这几个技术,说白了就是「让调试器进不来、待不住、藏不了」。我在做恶意软件分析那几年,见过不少样本把这几个手法玩得炉火纯青。咱们一个一个拆开看。

一、反附加:让调试器无法绑定进程

调试器附加进程,本质上是调用 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 出来的子进程反而会被调试器跟踪。所以这个手法通常放在程序入口最早的位置。

⚠ 注意: 这种手法在容器化环境或某些内核配置下可能失效。我曾在 Docker 里跑过,发现 ptrace 权限被 seccomp 过滤掉了,程序直接崩溃。

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.exepowershell.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;
}
💡 小技巧: 父进程检测最好结合「预期父进程列表」来做白名单。比如你的程序如果是通过服务启动的,父进程应该是 services.exe。别一刀切,否则自己把自己干掉了。

三、断点扫描:检查代码有没有被改

调试器下断点的本质,是把目标地址的第一个字节改成 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;
}
🔍 核心思路: 断点扫描不是一次性检查,而是周期性扫描。调试器可以在你扫描完后立刻下断点。所以很多保护方案会开一个定时器线程,每 100ms 扫一次。

四、完整性校验:确保没人动过我的代码

完整性校验,说白了就是给代码算个哈希。如果哈希变了,说明代码被修改了——可能是被打了补丁,也可能是被下了断点。

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;
}
⚠ 重要提醒: 哈希值不能硬编码在代码段里!我见过一个样本,哈希值就放在 .text 段末尾,结果攻击者改了代码后顺手把哈希也改了。正确的做法是:
1. 把哈希放在资源段,并加密
2. 或者从远程服务器获取
3. 或者用公钥验证签名(私钥不在程序里)

4.2 动态校验 vs 静态校验

类型 优点 缺点
静态校验(启动时一次) 速度快,不影响运行时性能 调试器可以在校验通过后再修改代码
动态校验(定时检查) 能检测到运行时的修改 影响性能,容易被绕过(比如暂停校验线程)
混合校验 兼顾安全性和性能 实现复杂

我个人习惯用混合校验:启动时做一次全量校验,运行时随机抽查几个关键函数。这样既不会太慢,又能覆盖大部分攻击场景。

五、知识体系总览

下面这张图把本章涉及的技术和它们之间的关系梳理了一下。你可以看到,反调试不是单一技术,而是一套组合拳。

反调试技术(三):核心知识体系 反附加 父进程检测 断点扫描 完整性校验 ptrace 自占坑 NtSetInfoProcess /proc 检查 Toolhelp32Snapshot 0xCC 扫描 硬件断点检测 静态哈希校验 动态定时校验 组合使用效果最佳,单一手法容易被绕过 注意:所有反调试技术都可以被绕过,只是增加攻击者成本

六、避坑指南与实战建议

做了这么多年逆向,我总结了几条关于反调试的「血泪教训」:

  • 不要过度依赖单一技术。我曾经见过一个保护方案只用了 ptrace 自跟踪,结果攻击者用 LD_PRELOAD 劫持了 ptrace 函数,直接失效。
  • 反调试代码本身也要保护。如果你把反调试逻辑写得明明白白,攻击者直接 patch 掉就完了。建议把关键判断逻辑用混淆或虚拟化保护起来。
  • 考虑兼容性。有些反调试手法在 Wine 或 Proton 环境下会崩溃。如果你的软件要跨平台运行,务必测试。
  • 性能开销要可控。完整性校验如果每 10ms 算一次 SHA256,CPU 直接拉满。我建议把校验间隔设在 500ms-2s 之间,随机抖动一下时间点。
💡 我的个人习惯: 在开发阶段,我会把所有反调试逻辑放在一个独立的模块里,用条件编译开关控制。调试时关掉,发布时打开。这样既不影响开发效率,又能保证发布版本的安全性。

好了,这一讲的内容就到这里。反调试是个攻防博弈的过程,没有绝对的安全。但每增加一层保护,就能筛掉一批低水平的攻击者。咱们的目标不是让系统攻不破,而是让攻击成本高到对方放弃。


公众号:蓝海资料掘金营,微信deep3321