05-攻击篇-线程劫持注入
阅读时间: 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 状态呢?
举个实际场景:
1 | // 某个控制台程序的主循环(非常常见) |
这个线程永远不会进入 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 结构体保存这些寄存器:
1 | typedef struct _CONTEXT { |
为什么需要读取线程上下文?
因为线程劫持需要:
- 读取原始 RIP(保存线程当前执行到哪里)
- 修改 RIP 为 Shellcode 地址(让线程去执行我们的代码)
- Shellcode 执行完后,跳回原始 RIP(线程继续正常工作)
什么是 RIP 寄存器?
RIP (Instruction Pointer Register) 存储下一条要执行的指令地址
CPU 的工作循环:
1 | 1. 从内存地址 RIP 读取指令 |
正常执行示例:
1 | RIP = 0x00007FF812340A10 → 执行 mov rax, rbx |
线程劫持的关键:破坏 RIP 的正常执行流程
正常情况下,RIP 会按照程序的逻辑顺序自动递增:
1 | RIP = 0x00007FF812340A10 → 执行指令 A |
而我们要做的,就是强制打断这个流程:
1 | 1. 暂停线程 (SuspendThread) |
劫持后的执行流程:
1 | RIP = 0x23F4A7B0000 → 执行我们的 Shellcode! |
CPU 不关心代码的来源,只会机械地执行 RIP 指向的指令。
这就是线程劫持的威力:不创建新线程,只是”借用”现有线程执行一段代码,执行完就还回去!
完整的劫持流程图示
1 | 正常线程执行流程: |
与 APC 注入的对比分析
让我们详细对比一下两种技术:
| 维度 | APC 注入 | 线程劫持 |
|---|---|---|
| 创建新线程 | ❌ 否 | ❌ 否 |
| 执行时机 | 被动等待 Alertable 状态 | 主动控制,立即执行 |
| 成功率 | 低(线程可能永不 Alertable) | 高(强制劫持) |
| 修改线程上下文 | ❌ 不修改 | ✅ 修改 RIP 寄存器 |
| 需要 Shellcode | ❌ 直接调用 LoadLibrary | ✅ 需要保存/恢复 RIP |
| 风险 | 低(系统自动调度) | 中(需正确保存/恢复上下文) |
| 检测难度 | 中(监控 QueueUserAPC) | 中(监控 SuspendThread/SetThreadContext) |
核心差异总结:
- APC 注入:温和,但不可控
- 线程劫持:暴力,但可靠
Shellcode 设计:执行代码后跳回原位
线程劫持比 APC 注入多了一个关键步骤:必须跳回原始 RIP
为什么?
- 因为我们强制修改了线程的执行流
- 如果不跳回去,线程会崩溃
- 进程也会崩溃
完整的 Shellcode 逻辑:
1 | ; 1. 保存易失寄存器(x64 调用约定要求) |
为什么要保存/恢复 RAX 和 RCX?
这是 x64 调用约定的要求:
RAX:存放函数返回值,调用 LoadLibrary 后会被覆盖RCX:第一个参数寄存器,我们用它传 DLL 路径- 如果不恢复,线程恢复后寄存器值错误 → 崩溃!
对应的 C++ 字节码:
1 | unsigned char g_Shellcode[] = { |
注意占位符的位置:
shellcode + 16:DLL 路径地址(8 字节)shellcode + 26:LoadLibraryA 地址(8 字节)shellcode + 52:原始 RIP 地址(8 字节)
动手实现线程劫持注入
理解原理后,我们来实现完整代码。
从 main 函数开始
1 | int main() |
第一步:打开进程并验证 DLL 路径
1 | BOOL InjectDllWithThreadHijack(DWORD dwProcessId, const char* dllPath) |
权限说明:
PROCESS_VM_OPERATION:内存分配权限PROCESS_VM_WRITE:内存写入权限PROCESS_VM_READ:内存读取权限(可选)PROCESS_QUERY_INFORMATION:查询进程信息
为什么不需要 PROCESS_CREATE_THREAD?
因为线程劫持不创建新线程,这是绕过 ETW 检测的关键!
第二步:获取 LoadLibraryA 地址并分配内存
1 | // 获取 LoadLibraryA 地址 |
为什么暂时分配 PAGE_READWRITE 而不是 PAGE_EXECUTE_READWRITE?
- 降低可疑性(很多安全软件会监控可执行内存分配)
- 写入完成后再修改为可执行
第三步:枚举线程,选择劫持目标
1 | // 创建线程快照 |
为什么需要枚举线程?
因为我们需要找到一个可以劫持的线程:
- 必须属于目标进程
- 必须能成功暂停
- 必须能成功读取/修改上下文
第四步:劫持线程(核心步骤)
这是整个技术的核心,我们详细拆解每一步。
4.1 遍历线程并尝试劫持
1 | do |
权限说明:
THREAD_SUSPEND_RESUME:暂停/恢复线程THREAD_GET_CONTEXT:读取线程上下文THREAD_SET_CONTEXT:修改线程上下文
4.2 暂停线程
1 | if (SuspendThread(hThread) == (DWORD)-1) |
暂停线程的意义:
- 线程停止执行,RIP 寄存器不再变化
- 我们可以安全地读取/修改上下文
- 如果不暂停,RIP 值会不断变化,修改会失败
4.3 读取线程上下文,保存原始 RIP
1 | CONTEXT ctx = {}; |
CONTEXT_FULL 的含义:
读取完整的线程上下文,包括:
- 通用寄存器(RAX, RBX, RCX, RDX…)
- 指令指针(RIP)
- 栈指针(RSP, RBP)
- 段寄存器(CS, DS, SS…)
此时 ctx.Rip 保存了线程当前的执行位置,这是我们必须保存的值!
4.4 填充 Shellcode 占位符
1 | // 复制 Shellcode 模板 |
为什么是这三个地址?
- 偏移 16:DLL 路径地址(
mov rcx, <dllPath>的操作数位置) - 偏移 26:LoadLibraryA 地址(
mov rax, <LoadLibraryA>的操作数位置) - 偏移 52:原始 RIP(
mov r11, <originalRip>的操作数位置)
4.5 写入 Shellcode 并修改内存属性
1 | // 写入填充后的 Shellcode |
为什么要修改为 PAGE_EXECUTE_READ?
- 代码必须有执行权限才能运行
- 只读权限可以防止意外覆盖
- 去掉写权限也降低了可疑性
4.6 修改 RIP 并恢复线程
1 | // 修改 RIP 指向 Shellcode |
执行流程:
SetThreadContext修改 RIP = Shellcode 地址ResumeThread恢复线程执行- CPU 从 Shellcode 地址开始执行
- Shellcode 调用 LoadLibrary 加载 DLL
- Shellcode 跳回原始 RIP
- 线程继续正常工作,就像什么都没发生过
第五步:输出结果
1 | CloseHandle(hSnapshot); |
完整代码
1 |
|
核心要点总结
- 打开进程(不需要
PROCESS_CREATE_THREAD) - 分配内存(DLL 路径 + Shellcode)
- 枚举线程并选择目标
- 暂停线程 → 读取上下文 → 保存原始 RIP
- 填充 Shellcode 占位符(DLL 路径、LoadLibrary、原始 RIP)
- 写入 Shellcode 到远程进程
- 修改 RIP = Shellcode 地址
- 恢复线程 → 立即执行!
运行与验证
验证步骤
1. 准备测试 DLL
创建一个简单的测试 DLL:
1 |
|
编译为 C:\Injection.dll
2. 准备目标进程
创建一个简单的控制台程序(注意使用 Sleep 而不是 SleepEx):
1 |
|
3. 启动 ETW 监听器
运行远程线程检测程序来监控线程创建事件。
4. 运行线程劫持程序,观察效果

如图所示:
- 左侧:线程劫持注入程序成功运行,DLL 被加载并弹出提示框
- 右侧:ETW 监听器显示”无任何线程创建事件“,说明未创建新线程
- 任务管理器:线程数没有变化
绕过成功!而且比 APC 注入更可靠!
- ✅ 不创建新线程 → 绕过 ETW 检测
- ✅ 立即执行 → 不依赖 Alertable 状态,100% 成功率
- ✅ 强制劫持 → 比被动等待的 APC 更可靠
对比三种注入技术
| 技术 | 创建新线程 | 执行时机 | ETW 检测结果 |
|---|---|---|---|
| 远程线程 | ✅ 创建 | 立即执行 | ❌ 检测到 |
| APC 注入 | ❌ 复用 | 等待 Alertable | ✅ 绕过(但可能不执行) |
| 线程劫持 | ❌ 复用 | 立即执行 | ✅ 绕过且可靠 |
线程劫持的优势:
- 不创建新线程 → 绕过 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 注入,你会选择哪个?为什么?
🔥 关键词: 内存归属、VirtualQueryEx、MEM_PRIVATE、模块扫描





