阅读时间: 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
为什么选择这个特征?
- 最直接 – 远程线程注入必然创建新线程
- 最明显 – 线程起始地址直接指向
LoadLibrary - 检测简单 – 只需枚举线程并查询起始地址
- 误报率低 – 正常程序很少有线程从
LoadLibrary启动
检测逻辑
正常情况下,进程的线程起始地址应该在:
- 程序自己的代码段(.text 段)
- 系统线程函数(如
kernel32!BaseThreadInitThunk)
如果发现有线程的起始地址指向 LoadLibrary,那很可能就是远程线程注入!
动手实现检测代码
理解了检测原理之后,我们来动手写代码。
从 main 函数开始
先写 main 函数的框架,实现持续监控:
int main() {
DWORD currentPid = GetCurrentProcessId();
while (true) {
DetectSuspiciousThreads(currentPid);
Sleep(1000); // 每隔 1 秒检测一次
}
return 0;
}检测逻辑封装在 DetectSuspiciousThreads 函数中。
检测思路回顾
我们之前分析过,检测分为三步:
- 枚举进程的所有线程
- 获取每个线程的起始地址
- 判断起始地址是否指向 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;实现总结:
虽然思路是”三步走”,但实际写代码时要先准备工具:
- 准备工作:获取 LoadLibrary 地址
- 第一步:创建线程快照
- 第二步:准备查询工具 + 遍历查询起始地址
- 第三步:比对地址判断
这就是从思维到实现的转换。完整代码如下:
完整检测代码
#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
于是,攻防对抗进入下一轮。
下节课,我们将从攻击者的角度,学习如何绕过线程起始地址检测,让防御者的检测失效。
这就是攻防对抗的魅力:没有绝对的攻击,也没有绝对的防御,只有不断的演进和对抗。
下节课见:对抗篇 – 绕过线程起始地址检测
互动讨论
欢迎在评论区讨论以下问题:
- 除了检测线程起始地址,你还能想到哪些检测进程注入的方法?
- 如果你是攻击者,你会如何绕过这个检测?
- 在实际应用中,这种检测方法的性能开销如何优化?
⚠️ 本课程内容仅供防御性安全研究与教学使用,请勿用于非法用途。
