阅读时间: 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 函数的框架,实现持续监控:
1 2 3 4 5 6 7 8 9
| int main() { DWORD currentPid = GetCurrentProcessId();
while (true) { DetectSuspiciousThreads(currentPid); Sleep(1000); } return 0; }
|
检测逻辑封装在 DetectSuspiciousThreads 函数中。
检测思路回顾
我们之前分析过,检测分为三步:
- 枚举进程的所有线程
- 获取每个线程的起始地址
- 判断起始地址是否指向 LoadLibrary
但写代码时要注意:第三步需要比对 LoadLibrary 的地址
所以我们可以先准备好这个”检测标准”。
准备工作:获取 LoadLibrary 的地址
开始检测前,先获取 LoadLibrary 的地址作为检测标准。
关键API:GetProcAddress
1 2 3 4 5 6
| BOOL DetectSuspiciousThreads(DWORD dwProcessId) { 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:表示枚举所有进程的线程,不限定特定进程
1 2 3 4 5
| HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); if (hSnapshot == INVALID_HANDLE_VALUE) { return FALSE; }
|
第二步:获取每个线程的起始地址
这一步分为两个部分:准备查询工具 + 遍历查询。
2.1 准备查询工具
Windows 没有公开 API 查询线程起始地址,需要使用未公开的 NtQueryInformationThread 函数。
关键API:NtQueryInformationThread
先在文件开头定义函数指针:
1 2 3 4 5 6 7 8 9
| typedef NTSTATUS(WINAPI* pfnNtQueryInformationThread)( HANDLE ThreadHandle, LONG ThreadInformationClass, PVOID ThreadInformation, ULONG ThreadInformationLength, PULONG ReturnLength );
#define ThreadQuerySetWin32StartAddress 9
|
然后动态获取函数地址:
1 2 3 4 5 6 7 8
| HMODULE hNtdll = GetModuleHandle(L"ntdll.dll"); pfnNtQueryInformationThread NtQueryInfoThread = (pfnNtQueryInformationThread)GetProcAddress(hNtdll, "NtQueryInformationThread");
if (!NtQueryInfoThread) { CloseHandle(hSnapshot); return FALSE; }
|
2.2 遍历线程并查询起始地址
我们先初始化线程枚举结构体。
关键API:Thread32First
1 2 3 4 5 6
| THREADENTRY32 te32 = { sizeof(THREADENTRY32) };
if (!Thread32First(hSnapshot, &te32)) { CloseHandle(hSnapshot); return FALSE; }
|
THREADENTRY32 结构体用来存储线程信息,必须先设置大小再传给 Thread32First。
然后用 do-while 遍历所有线程。
关键API:Thread32Next
1 2 3 4 5
| do { if (te32.th32OwnerProcessID == dwProcessId) { } } while (Thread32Next(hSnapshot, &te32));
|
每次循环检查线程是否属于目标进程。
最后打开线程并查询起始地址。
关键API:OpenThread / NtQueryInformationThread
1 2 3 4 5 6 7 8 9 10 11 12 13
| 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 的地址比对。
1 2 3 4 5 6
| if (startAddress == pLoadLibraryA || startAddress == pLoadLibraryW) { CloseHandle(hThread); CloseHandle(hSnapshot); return TRUE; }
|
检测原理:
正常线程的起始地址应该在程序自己的代码段。如果起始地址直接指向 LoadLibrary,说明这个线程专门用来加载 DLL,这就是远程线程注入的特征。
遍历完所有线程后,如果没发现可疑的,返回 FALSE:
1 2
| CloseHandle(hSnapshot); return FALSE;
|
实现总结:
虽然思路是”三步走”,但实际写代码时要先准备工具:
- 准备工作:获取 LoadLibrary 地址
- 第一步:创建线程快照
- 第二步:准备查询工具 + 遍历查询起始地址
- 第三步:比对地址判断
这就是从思维到实现的转换。完整代码如下:
完整检测代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
| #include <windows.h> #include <tlhelp32.h> #include <iostream>
typedef NTSTATUS(WINAPI* pfnNtQueryInformationThread)( HANDLE ThreadHandle, LONG ThreadInformationClass, PVOID ThreadInformation, ULONG ThreadInformationLength, PULONG ReturnLength );
#define ThreadQuerySetWin32StartAddress 9
BOOL DetectSuspiciousThreads(DWORD dwProcessId) { 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; }
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;
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; }
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
于是,攻防对抗进入下一轮。
下节课,我们将从攻击者的角度,学习如何绕过线程起始地址检测,让防御者的检测失效。
这就是攻防对抗的魅力:没有绝对的攻击,也没有绝对的防御,只有不断的演进和对抗。
下节课见:对抗篇 - 绕过线程起始地址检测
互动讨论
欢迎在评论区讨论以下问题:
- 除了检测线程起始地址,你还能想到哪些检测进程注入的方法?
- 如果你是攻击者,你会如何绕过这个检测?
- 在实际应用中,这种检测方法的性能开销如何优化?
⚠️ 本课程内容仅供防御性安全研究与教学使用,请勿用于非法用途。