阅读时间: 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 函数的框架,实现持续监控:

1
2
3
4
5
6
7
8
9
int main() {
DWORD currentPid = GetCurrentProcessId();

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

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

检测思路回顾

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

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

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


准备工作:获取 LoadLibrary 的地址

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

关键API:GetProcAddress

1
2
3
4
5
6
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:表示枚举所有进程的线程,不限定特定进程
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
// 在上面的 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 的地址比对。

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

检测原理:

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

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

1
2
CloseHandle(hSnapshot);
return FALSE;

实现总结:

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

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

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


完整检测代码

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>

/**
* @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. 在实际应用中,这种检测方法的性能开销如何优化?

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