阅读时间: 3-4 分钟
前置知识: 理解远程线程注入原理、Windows API 基础
学习目标: 掌握进程注入检测技术,实现实时防御系统


📺 配套视频教程

本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:

💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!

视频链接: https://www.bilibili.com/video/BV1NWHTziE8Q/


防御者的视角

上节课,我们学习了远程线程注入的攻击技术。

这节课,我们切换到防御者的角度去思考。

如果你是游戏的反外挂系统开发者,你会如何检测这种远程线程注入?

思考一下,把你的方法写在评论区


解析远程线程注入的攻击特征

作为防御者,我们首先要分析攻击者的行为,找出所有可能的检测点。

远程线程注入会在目标进程中留下以下攻击特征

特征1:存在于进程的模块链表中

注入的 DLL 会被加载到进程中,出现在模块链表(PEB->Ldr)里。

通过枚举进程模块,可以发现新加载的 DLL。

特征2:有完整的 PE 头信息

注入的 DLL 作为一个完整的 PE 文件被加载,有标准的 PE 结构:

  • DOS 头(MZ 标记)
  • NT 头(PE 标记)
  • 节表(.text, .data, .rdata 等)

可以通过扫描内存中的 PE 头来发现注入的模块。

特征3:有模块对应的可执行内存

DLL 的代码段会被标记为可执行(PAGE_EXECUTE_READ)

可以扫描可执行内存区域,检查是否有未知模块。

特征4:有线程创建行为

远程线程注入必须调用 CreateRemoteThread 创建一个新线程。

这个线程的起始地址通常是 kernel32!LoadLibrary

特征5:有内存分配行为

攻击者需要在目标进程中分配内存存放 DLL 路径。

这块内存通常是可读写的(PAGE_READWRITE),内容是字符串路径。


本节课的检测思路

这节课,我们聚焦最明显、最容易检测的特征

检测线程起始地址是否指向 LoadLibrary

为什么选择这个特征?

  1. 最直接 – 远程线程注入必然创建新线程
  2. 最明显 – 线程起始地址直接指向 LoadLibrary
  3. 检测简单 – 只需枚举线程并查询起始地址
  4. 误报率低 – 正常程序很少有线程从 LoadLibrary 启动

检测逻辑

正常情况下,进程的线程起始地址应该在:

  • 程序自己的代码段(.text 段)
  • 系统线程函数(如 kernel32!BaseThreadInitThunk

如果发现有线程的起始地址指向 LoadLibrary,那很可能就是远程线程注入!


动手实现检测代码

理解了检测原理之后,我们来动手写代码。

从 main 函数开始

先写 main 函数的框架,实现持续监控:

int main() {
    DWORD currentPid = GetCurrentProcessId();

    while (true) {
        DetectSuspiciousThreads(currentPid);
        Sleep(1000);  // 每隔 1 秒检测一次
    }
    return 0;
}

检测逻辑封装在 DetectSuspiciousThreads 函数中。

检测思路回顾

我们之前分析过,检测分为三步:

  1. 枚举进程的所有线程
  2. 获取每个线程的起始地址
  3. 判断起始地址是否指向 LoadLibrary

但写代码时要注意:第三步需要比对 LoadLibrary 的地址
所以我们可以先准备好这个”检测标准”。


准备工作:获取 LoadLibrary 的地址

开始检测前,先获取 LoadLibrary 的地址作为检测标准。

关键API:GetProcAddress

BOOL DetectSuspiciousThreads(DWORD dwProcessId) {
    // 获取 LoadLibrary 的地址
    HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
    LPVOID pLoadLibraryA = GetProcAddress(hKernel32, "LoadLibraryA");
    LPVOID pLoadLibraryW = GetProcAddress(hKernel32, "LoadLibraryW");
}

这里获取了两个版本:LoadLibraryA(ANSI)和 LoadLibraryW(Unicode),因为攻击者可能使用任意一个。


第一步:枚举进程的所有线程

使用快照技术获取系统中的所有线程。

关键API:CreateToolhelp32Snapshot

这个函数的作用是给系统拍个”快照”,记录下当前所有线程的状态。

它有两个参数:

  • 第一个参数 TH32CS_SNAPTHREAD:指定要拍摄线程快照
  • 第二个参数 0:表示枚举所有进程的线程,不限定特定进程
// 创建线程快照
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnapshot == INVALID_HANDLE_VALUE) {
    return FALSE;
}

第二步:获取每个线程的起始地址

这一步分为两个部分:准备查询工具 + 遍历查询。

2.1 准备查询工具

Windows 没有公开 API 查询线程起始地址,需要使用未公开的 NtQueryInformationThread 函数。

关键API:NtQueryInformationThread

先在文件开头定义函数指针:

typedef NTSTATUS(WINAPI* pfnNtQueryInformationThread)(
    HANDLE ThreadHandle,
    LONG ThreadInformationClass,
    PVOID ThreadInformation,
    ULONG ThreadInformationLength,
    PULONG ReturnLength
);

#define ThreadQuerySetWin32StartAddress 9

然后动态获取函数地址:

HMODULE hNtdll = GetModuleHandle(L"ntdll.dll");
pfnNtQueryInformationThread NtQueryInfoThread =
    (pfnNtQueryInformationThread)GetProcAddress(hNtdll, "NtQueryInformationThread");

if (!NtQueryInfoThread) {
    CloseHandle(hSnapshot);
    return FALSE;
}

2.2 遍历线程并查询起始地址

我们先初始化线程枚举结构体。

关键API:Thread32First

THREADENTRY32 te32 = { sizeof(THREADENTRY32) };

if (!Thread32First(hSnapshot, &te32)) {
    CloseHandle(hSnapshot);
    return FALSE;
}

THREADENTRY32 结构体用来存储线程信息,必须先设置大小再传给 Thread32First


然后用 do-while 遍历所有线程。

关键API:Thread32Next

do {
    if (te32.th32OwnerProcessID == dwProcessId) {
        // 找到属于目标进程的线程
    }
} while (Thread32Next(hSnapshot, &te32));

每次循环检查线程是否属于目标进程。


最后打开线程并查询起始地址。

关键API:OpenThread / NtQueryInformationThread

// 在上面的 if 判断内:
HANDLE hThread = OpenThread(THREAD_QUERY_INFORMATION, FALSE, te32.th32ThreadID);
if (hThread) {
    PVOID startAddress = NULL;

    // 查询线程起始地址
    NtQueryInfoThread(hThread, ThreadQuerySetWin32StartAddress,
        &startAddress, sizeof(startAddress), NULL);

    // 接下来判断是否可疑...

    CloseHandle(hThread);
}

OpenThread 打开线程句柄,NtQueryInfoThread 查询起始地址,用完后关闭句柄。


第三步:判断起始地址是否指向 LoadLibrary

拿到线程起始地址后,直接和 LoadLibrary 的地址比对。

// 在上面的遍历循环中,查询到 startAddress 后:
if (startAddress == pLoadLibraryA || startAddress == pLoadLibraryW) {
    CloseHandle(hThread);
    CloseHandle(hSnapshot);
    return TRUE;  // 发现可疑线程
}

检测原理:

正常线程的起始地址应该在程序自己的代码段。如果起始地址直接指向 LoadLibrary,说明这个线程专门用来加载 DLL,这就是远程线程注入的特征。

遍历完所有线程后,如果没发现可疑的,返回 FALSE:

CloseHandle(hSnapshot);
return FALSE;

实现总结:

虽然思路是”三步走”,但实际写代码时要先准备工具:

  1. 准备工作:获取 LoadLibrary 地址
  2. 第一步:创建线程快照
  3. 第二步:准备查询工具 + 遍历查询起始地址
  4. 第三步:比对地址判断

这就是从思维到实现的转换。完整代码如下:


完整检测代码

#include <windows.h>
#include <tlhelp32.h>
#include <iostream>

/**
 * @brief NtQueryInformationThread 函数指针类型
 * @details 用于动态调用 ntdll.dll 中的未公开函数,查询线程详细信息
 */
typedef NTSTATUS(WINAPI* pfnNtQueryInformationThread)(
    HANDLE ThreadHandle,
    LONG ThreadInformationClass,
    PVOID ThreadInformation,
    ULONG ThreadInformationLength,
    PULONG ReturnLength
);

/// 线程信息类:查询线程起始地址
#define ThreadQuerySetWin32StartAddress 9

/**
 * @brief 检测进程中是否存在可疑线程
 * @param dwProcessId 目标进程ID
 * @return 如果检测到可疑线程返回 TRUE,否则返回 FALSE
 * @details 通过检查线程起始地址是否指向 LoadLibrary 来判断是否存在远程线程注入
 */
BOOL DetectSuspiciousThreads(DWORD dwProcessId) {
    // 获取 LoadLibrary 函数地址作为检测特征
    HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
    LPVOID pLoadLibraryA = GetProcAddress(hKernel32, "LoadLibraryA");
    LPVOID pLoadLibraryW = GetProcAddress(hKernel32, "LoadLibraryW");

    std::cout << "LoadLibraryA: 0x" << std::hex << pLoadLibraryA << std::endl;
    std::cout << "LoadLibraryW: 0x" << std::hex << pLoadLibraryW << std::endl;
    std::cout << "========================" << std::endl;

    // 创建线程快照
    HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
    if (hSnapshot == INVALID_HANDLE_VALUE) {
        std::cout << "创建快照失败" << std::endl;
        return FALSE;
    }

    // 获取 NtQueryInformationThread 函数地址
    HMODULE hNtdll = GetModuleHandle(L"ntdll.dll");
    pfnNtQueryInformationThread NtQueryInfoThread =
        (pfnNtQueryInformationThread)GetProcAddress(hNtdll, "NtQueryInformationThread");

    if (!NtQueryInfoThread) {
        CloseHandle(hSnapshot);
        return FALSE;
    }

    THREADENTRY32 te32 = { sizeof(THREADENTRY32) };
    BOOL bFoundSuspicious = FALSE;

    if (!Thread32First(hSnapshot, &te32)) {
        CloseHandle(hSnapshot);
        return FALSE;
    }

    // 遍历所有线程
    do {
        if (te32.th32OwnerProcessID == dwProcessId) {
            HANDLE hThread = OpenThread(THREAD_QUERY_INFORMATION, FALSE, te32.th32ThreadID);
            if (hThread) {
                PVOID startAddress = NULL;

                // 查询线程起始地址
                if (NtQueryInfoThread(hThread, ThreadQuerySetWin32StartAddress,
                    &startAddress, sizeof(startAddress), NULL) == 0) {

                    std::cout << "线程 ID: " << std::dec << te32.th32ThreadID
                              << " 起始地址: 0x" << std::hex << startAddress;

                    // 检测是否指向 LoadLibrary
                    if (startAddress == pLoadLibraryA || startAddress == pLoadLibraryW) {
                        // 设置红色输出
                        HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
                        SetConsoleTextAttribute(hConsole, FOREGROUND_RED | FOREGROUND_INTENSITY);
                        std::cout << " [可疑!]" << std::endl;
                        SetConsoleTextAttribute(hConsole, FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE);
                        bFoundSuspicious = TRUE;
                    } else {
                        std::cout << " [正常]" << std::endl;
                    }
                }
                CloseHandle(hThread);
            }
        }
    } while (Thread32Next(hSnapshot, &te32));

    CloseHandle(hSnapshot);
    return bFoundSuspicious;
}

/**
 * @brief 程序入口
 * @return 程序退出码
 */
int main() {
    DWORD currentPid = GetCurrentProcessId();
    HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);

    while (true) {
        system("cls");  // 清屏

        std::cout << "检测当前进程: " << std::dec << currentPid << std::endl;
        std::cout << "按 Ctrl+C 退出..." << std::endl;
        std::cout << "========================" << std::endl;

        if (DetectSuspiciousThreads(currentPid)) {
            std::cout << "========================" << std::endl;
            SetConsoleTextAttribute(hConsole, FOREGROUND_RED | FOREGROUND_INTENSITY);
            std::cout << "警告:检测到远程线程注入!" << std::endl;
            SetConsoleTextAttribute(hConsole, FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE);
        } else {
            std::cout << "========================" << std::endl;
            std::cout << "未检测到可疑线程" << std::endl;
        }

        std::cout << "n等待 1 秒后再次检测..." << std::endl;
        Sleep(1000);
    }

    return 0;
}

检测效果演示

检测效果验证:

如图所示,程序能够成功检测到:

  • ✅ 正常进程的所有线程均显示 [正常]
  • ✅ 被注入进程的可疑线程会被标记为 [可疑!可能是远程线程注入]
  • ✅ 检测到注入后会显示红色警告信息

检测的优势与局限

优势

  • ✅ 实现简单,性能开销小
  • ✅ 能够检测经典的远程线程注入
  • ✅ 实时性好,可以定时扫描

局限

  • ❌ 只能检测到正在进行的注入(线程还存在时)
  • ❌ 如果注入的 DLL 加载完成后线程就退出了,就检测不到了
  • ❌ 只能检测起始地址是 LoadLibrary 的情况

实战优化建议

1. 增强检测范围

除了 LoadLibrary,还可以检测其他可疑的起始地址:

  • kernel32!LoadLibraryExW
  • 指向 ntdll.dll 中的函数
  • 不在任何已知模块中的地址

2. 模块白名单

建立合法模块的白名单,只有在白名单中的模块才是可信的。

3. 持续监控

定时扫描,而不是只检测一次。

4. 日志记录

记录检测到的可疑行为,方便后续分析。


检测的局限性

虽然我们成功检测到了经典的远程线程注入,但这个方法有明显的局限:

局限1:只能检测起始地址是 LoadLibrary 的情况

如果攻击者不使用 LoadLibrary 作为线程起始地址呢?

局限2:只能检测到正在进行的注入

如果 DLL 加载完成后,注入线程就退出了,我们就检测不到了。

局限3:特征过于明显

攻击者很容易意识到这个特征,并想办法绕过。


下节课预告

现在,我们成功检测到了远程线程注入。

攻击者看到这个检测方法后,开始思考:

“防御者检测的是线程起始地址指向 LoadLibrary 这个特征,那我换一个起始地址不就行了?”

攻击者有很多绕过方法:

  • 使用自己的 LoadLibrary 实现,不直接调用系统的
  • 先创建线程指向 shellcode,再由 shellcode 调用 LoadLibrary
  • 甚至完全不用 LoadLibrary,直接手动加载 DLL

于是,攻防对抗进入下一轮。

下节课,我们将从攻击者的角度,学习如何绕过线程起始地址检测,让防御者的检测失效。

这就是攻防对抗的魅力:没有绝对的攻击,也没有绝对的防御,只有不断的演进和对抗。

下节课见:对抗篇 – 绕过线程起始地址检测


互动讨论

欢迎在评论区讨论以下问题:

  1. 除了检测线程起始地址,你还能想到哪些检测进程注入的方法?
  2. 如果你是攻击者,你会如何绕过这个检测?
  3. 在实际应用中,这种检测方法的性能开销如何优化?

⚠️ 本课程内容仅供防御性安全研究与教学使用,请勿用于非法用途。

By UD2

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注