进程注入

WindowsHook注入

我的错误理解: 设置钩子 → 等键盘消息触发hook → 这时系统才加载 DLL → 然后恶意代码执行

实际的顺序:

flowchart TD
    Start(["🧑‍💻 攻击者进程<br/>(launcher.exe)"]) --> CallHook["① 调用 SetWindowsHookEx<br/>idHook, lpfn, hMod, dwThreadId"]
    CallHook --> Record["② Windows 内部记录钩子信息<br/>• 回调函数地址 lpfn<br/>• DLL 模块句柄 hMod<br/>• 目标线程 ID dwThreadId"]
    Record --> NotLoaded["⚠️ 关键点:此时 evil.dll<br/>尚未被加载到目标进程"]
    NotLoaded --> WaitMsg{"③ 目标线程<br/>处理消息?"}
    WaitMsg -->|"否 (等待中)"| WaitMsg
    WaitMsg -->|"是"| GetMsg["④ 目标线程调用<br/>GetMessage / PeekMessage"]
    GetMsg --> CheckHook["⑤ user32.dll 检查<br/>该线程的钩子链表"]
    CheckHook --> HasHook{"⑥ 存在<br/>钩子?"}
    HasHook -->|"否"| NormalFlow["正常消息处理<br/>继续消息循环"]
    HasHook -->|"是"| LoadLib["⑦ user32.dll 调用<br/>LoadLibraryW 加载 evil.dll"]
    LoadLib --> Map["⑧ evil.dll 映射到<br/>目标进程地址空间"]
    Map --> DllMain["⑨ 调用 evil.dll 的 DllMain<br/>DLL_PROCESS_ATTACH"]
    DllMain --> Exec["⭐ 恶意代码执行<br/>(RunShellcode 等)"]
    Exec --> CallBack["⑩ user32.dll 调用<br/>钩子回调 HookProc"]
    CallBack --> CallNext["⑪ HookProc 内部调用<br/>CallNextHookEx"]
    CallNext --> Continue["⑫ 传递给下一个钩子<br/>或目标窗口过程"]
    Continue --> Loop["⑬ 目标线程继续<br/>消息循环"]
    Loop --> WaitMsg

    style Start fill:#e1f5ff,stroke:#0066cc
    style NotLoaded fill:#ffe1e1,stroke:#cc0000
    style LoadLib fill:#fff4e1,stroke:#ff8800
    style DllMain fill:#ffcccc,stroke:#cc0000
    style Exec fill:#cc0000,color:#fff,stroke:#000
    style CallBack fill:#e1f5ff,stroke:#0066cc

所以钩子的回调函数其实是个”装饰品”

为什么会把dll映射到目标进程: 因为钩子回调要在目标进程的地址空间里执行,而不同进程的地址空间是隔离的。

这里有个局限性:被注入的进程,例如notepad.exe必须要先启动,否则我们获取不到它的主线程地址,这个问题是后面的 CreateRemoteThread解决的核心问题

找PID和TID

寻找notepad的PID

  • 用CreateToolhelp32Snapshot获取所有进程的快照
  • 遍历所有进程名称,找到notepad.exe,返回PID

image-20260714133221560

寻找TID

  • 用EnumWindows遍历所有窗口,传入我们自己的回调函数FindTopWindowsProc
  • FindTopWindowsProc用于筛选是否是我们要的GUI窗口的线程,返回TID

image-20260714141411690

image-20260714141745061

找到notepad的PID和TID

image-20260714142043853

写evil.dll

写hook函数

extern不是常用的声明变量在别的文件中,这里的extern "C"是一种链接规范,告诉编译器这个函数名用c语言的方式编译。

因为C++允许函数重载,两个名字相同的函数,编译器区分它们的方式是将它们的函数名改造成带参数类型的名字,导出表中的函数名也是这样的,导致GetProcAddress(MyhookProc)就会找不到

__declspec(dllexport)的作用是把这个函数列入dll的导出表,编译器看到这个标签就把函数放入 DLL的导出表

image-20260714155238049

image-20260714160502961

image-20260714160231121

写DLLMain

弹出一个窗口,显示进程的PID

image-20260714170554332

设置windowshook

  • 加载evil.dll
  • 获取MyHookProc的函数地址

image-20260714171833216

  • 设置WindowsHook

    image-20260714171947550

问题

这里有个问题,用setwindowsHook设置evil.dll中的回调函数,需要先加载evil.dll,这样会导致直接触发了我们dllmain中的恶意代码

image-20260714172427911

image-20260714172510045

所以我们需要在dllmain中加判断,如果是notepad的进程才执行,否则不执行恶意代码

image-20260714183322825

CreateRemoteThread注入

查找notepad进程pid

这个和之前的windowshook中的方式一样

image-20260714191257993

申请权限+获取notepad的句柄

image-20260714191324970

在notepad中分配内存

image-20260714192736905

获取evil.dll的路径+写入notepad内存

image-20260714193856190

image-20260714193938664

获取LoadLibraryW地址

  • 拿到kernel32.dll句柄

    image-20260715092419344

  • 拿到LoadLibraryW的函数地址

    image-20260715092532180

CreateRemoteThread加载evil.dll

image-20260715093633383

CreateRemoteThread在notepad.exe创建远程线程,执行LoadLibraryW函数,线程参数就是函数加载的DLL的路径的地址

image-20260715103606483

DLL劫持

DLL的加载顺序

Windows 加载 DLL 时按以下顺序查找:

 1. 应用程序所在的目录                          
 2. 系统目录 (C:\Windows\System32)
 3. 16 位系统目录 (C:\Windows\System)
 4. Windows 目录 (C:\Windows)
 5. 当前工作目录
 6. PATH 环境变量中的目录

注册表 HKLM\System\CurrentControlSet\Control\SessionManager\KnownDLLs下的DLL永远从系统目录加载,这些是不能劫持的

image-20260715111011567

函数转发

原DLL中的导出表的函数,我们的DLL也要实现相同的功能

有两种实现方式:

方式A:运行时转发(手动调用真 DLL)

DllMain 里加载真 DLL,每个转发函数里 GetProcAddress 调真函数。

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
// 我们的"伪装"sqlite3.dll

// 静态变量:保存真 sqlite3 的句柄和函数指针
static HMODULE g_hRealSqlite3 = nullptr;
static void* g_pfnOpen = nullptr;

// DllMain:加载真正的 sqlite3.dll
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) {
if (reason == DLL_PROCESS_ATTACH) {
// 加载真正的 sqlite3.dll(用绝对路径,绕过我们的"伪装")
wchar_t sysDir[MAX_PATH];
GetSystemDirectoryW(sysDir, MAX_PATH);
wchar_t realPath[MAX_PATH];
swprintf_s(realPath, MAX_PATH, L"%s\\sqlite3.dll", sysDir);
g_hRealSqlite3 = LoadLibraryW(realPath);

// 解析函数指针
g_pfnOpen = (void*)GetProcAddress(g_hRealSqlite3, "sqlite3_open");

// 恶意代码
MessageBoxW(NULL, L"DLL 劫持成功!", L"evil", 0);
}
return TRUE;
}

// 转发 sqlite3_open
extern "C" __declspec(dllexport)
int sqlite3_open(const char* filename, void** ppDb) {
typedef int (*Fn)(const char*, void**);
return ((Fn)g_pfnOpen)(filename, ppDb);
}

特点

  • 手动控制每个函数
  • 代码量大(要写每个函数)
  • 可以加 hook 逻辑

方式 B:链接时转发(最优雅,编译器自动处理)

.def 文件声明”这个函数转发到那个 DLL 的那个函数”,编译器自动生成跳转。

步骤 1:写一个 .def 文件

1
2
3
4
5
6
7
; our_sqlite3.def
LIBRARY "sqlite3"
EXPORTS
sqlite3_open = sqlite3_real.sqlite3_open
sqlite3_close = sqlite3_real.sqlite3_close
sqlite3_exec = sqlite3_real.sqlite3_exec
; ... 其他函数

含义:当程序调用我们 DLL 的 sqlite3_open 时,自动跳转到 sqlite3_real.dll 里的 sqlite3_open

步骤 2:DllMain 只写恶意代码

1
2
3
4
5
6
7
8
9
10
11
// 我们的"伪装"sqlite3.dll
#include <windows.h>

BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) {
if (reason == DLL_PROCESS_ATTACH) {
// 恶意代码
MessageBoxW(NULL, L"DLL 劫持成功!", L"evil", 0);
}
return TRUE;
}
// 没有 sqlite3_open 等函数体——链接器根据 .def 自动生成转发

特点

  • 不用写函数体
  • 不用手动 LoadLibrary + GetProcAddress
  • 系统在 DLL 加载时自动找到 sqlite3_real.dll 并转发

进程镂空(Process Hollowing)

创建一个”挂起”的合法进程,把它在内存中的代码卸载,写入我们自己的 PE文件,然后恢复执行。

复现过程遇到的问题:

  • 卸载notepad是卸载的PE在内存的映像,而我们读取的evil.exe是PE文件在磁盘的格式,因此需要转换。
  • 我们在目标进程申请的空间的基址和PE文件的基址是不同的,因此需要修改重定位表和入口地址

准备一个evil.exe

  • 简单的弹窗功能

image-20260715134833227

读取evil.exe的PE文件

  • 用CreateFileW打开evil.exe,获取文件句柄
  • 用GetFileSize获取文件大小
  • 调用ReadFile,根据文件句柄、文件大小,把文件的数据存放到vector中
  • vector buffer存放了PE文件的所有数据,但函数结束vector会被析构,用静态变量延长生命周期

image-20260715135011351

  • main函数的开头调用

image-20260715135804992

根据PE文件结构解析

  • 获取了ntHeader的地址,后续可以凭借指针得到所需的PE信息

image-20260715135853770

用CreateProcess创建挂起的notepad.exe

image-20260715141514420

image-20260715142545642

PROCESS_INSTRUCTION 结构体

1
2
3
4
5
6
typedef struct _PROCESS_INFORMATION {
HANDLE hProcess; // 进程句柄
HANDLE hThread; // 主线程句柄
DWORD dwProcessId; // 进程 ID(PID)
DWORD dwThreadId; // 主线程 ID(TID)
} PROCESS_INFORMATION;

从PEB读取notepad的ImageBase

image-20260715143056380

卸载notepad原模块

image-20260715144143454

VirtualAllocEx在目标进程申请空间

image-20260715145155408image-20260715145715422

evil.exe磁盘布局→内存布局

一开始直接用WriteProcessMemory(peBuffer, peSize)把PE文件直接写到申请的空间

正确做法是:要先按照节表中的VA展开

image-20260722100444910

image-20260722100523356

image-20260722095825878

重定位

remoteBase:我们VirtualAllocEx在notepad申请空间的基址

PreferBase是PE文件中的ImageBase

image-20260717125628630

image-20260717125608956

由于Windows有ALSR,基址是随机化的

image-20260717125813816

不是每条指令都需要重定位的,PE中有一批绝对地址,例如:

  • 全局/静态变量里存的指针
  • 虚表、一些数据结构里的地址

这些都会写在PE结构的.reloc

根据PE结构从可选头中找到.reloc的地址

image-20260717132547540

image-20260717132534848

.reloc的结构:有若干个重定位块

重定位块以一个 IMAGE_BASE_RELOCATION 结构作为开始,其结构体如下:

1
2
3
4
5
6
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 重定位页的 RVA
DWORD SizeOfBlock; // 重定位块的大小
WORD TypeOffset; // 重定位条目的类型于偏移

} _IMAGE_BASE_RELOCATION;
  • **VirtualAddress 重定位页 RVA。**基址加上页 RVA 的和作为被加数,再加上重定位项对应的 offset 就能得到其在内存中实际的 VA。

  • SizeOfBlock 基址重定位块的大小。包括 VirtualAddress,SizeOfBlock,以及后面 TypeOffset 的大小。

  • TypeOffset 一个数组。数组中每个元素大小为 2 个字节,即 16 位。

    • **type 高 4 位用于表示重定位的类型。**0=ABS 对齐填充, 3=HIGHLOW 按32位绝对地址改, 10=DIR64 按 64 位绝对地址改
    • offset 低 12 位用于表示重定位数据位置相对于页 RVA 的偏移量。

image-20260717133010538

image-20260717140535008

dumpbin查看重定位表更清楚

image-20260717141922178

外层循环:

一个 block 一个 block 遍历,计算出block中offset数组有多少元素

image-20260717142647194

内层循环:

targetRVA=页RVA + offset

在内存的地址 patch= image的基址 + targetRVA

32位:

  • 先读4字节到value中
  • value += delta (delta是实际分配的基址和预期的基址的偏移)
  • 将4字节value写回

64位:

  • 同理32位,读8字节

image-20260717143926847

image-20260717143247927

调试这个过程:

image-20260717150808414

image-20260717150955380

image-20260717151707560

把这值放到value中,然后加上delta,就是重定位后的地址

image-20260717151912912

然后将重定位好的地址写回

image-20260717152136077

修改入口点 + 恢复Thread

CONTEXT = CPU 寄存器的当前状态快照 一个线程被挂起时,CPU 的所有寄存器都被”冻结”保存起来。SetThreadContext 写回去。

image-20260715151831998

所以ctx中的入口点是notepad的入口点,我们需要修改入口点

新的入口点=新的基址+nt头的入口点RVA

image-20260717154119987

image-20260717155523924

APC注入

APC(Asynchronous Procedure Call,异步过程调用) 是 Windows 内核异步体系的一部分。每个线程都有一个 APC 队列(FIFO)。可以把一个函数指针 +
参数放进这个队列。线程不会立刻执行,只有在它进入 Alertable(可告警)状态 时,内核才会检查该线程的 APC 队列若非空 → 按 FIFO 依次执行

一些能进入Alertable状态的API:

API 本职功能 何时 alertable
SleepEx(ms, bAlertable) 睡指定毫秒 bAlertable == TRUE
WaitForSingleObjectEx(h, ms, bAlertable) 等一个内核对象(事件/互斥/进程/线程…) bAlertable == TRUE
WaitForMultipleObjectsEx(...) 等多个对象中的一个/全部 bAlertable == TRUE
SignalObjectAndWait(h1, h2, ms, bAlertable) 先 signal 一个对象,再 wait 另一个(原子组合) bAlertable == TRUE
MsgWaitForMultipleObjectsEx(...) 等对象 或 窗口消息(GUI 常用) MWMO_ALERTABLE
GetQueuedCompletionStatusEx 从 I/O 完成端口取完成包 可 alertable 等待

APC回调函数:

image-20260717174718176

入队:

image-20260717174809131

用可alertable的函数触发

image-20260717174943790

image-20260717175007856

问题:

只有进入alertable才能触发我们的回调,因此如何触发是个问题

  • 把所有线程全都QueueUserAPC,但这样做很影响目标进程效率
  • 使用其他的改进技术,如earlybird技术

EarlyBird 注入

1
2
3
4
1. CreateProcess(svchost.exe, ..., CREATE_SUSPENDED)   ← 挂起启动
2. VirtualAllocEx + WriteProcessMemory ← 写恶意代码
3. QueueUserAPC(NtQueueApcThread) → 主线程 ← 入队
4. ResumeThread ← 唤醒

每个用户态线程的第一段执行路径是固定的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
LdrInitializeThunk        ← ntdll,所有用户线程都从这里起跑


LdrpInitialize


_LdrpInitialize


NtTestAlert ← 检查本线程 APC 队列,有就告知内核


内核准备返回用户态时跳到 KiUserApcDispatcher


执行我们 QueueUserAPC 塞进去的代码 ← 这里跑恶意逻辑


继续走正常的进程初始化(CRT 入口、main……)

因此我们在挂起态先把我们的构造好的函数放到APC队列中,然后恢复进程,就会被启动流程中的NtTestAlert检查APC队列,执行我们的函数

我按照这个流程写了一个demo,如果注入成功会在记事本窗口出现前先出现弹窗

image-20260720135815673

问题:

  1. 写入notepad进程的evil.dll的地址应该用绝对地址,因为notepad的地址在system32里面,和evil.dll不在一个文件夹下
  2. 跨进程APC注入,挂到目标进程APC队列的函数必须是目标进程地址空间里存在的函数。

我自定义了一个回调函数APCproc,功能是加载evil.dll ,这个函数的地址只在EarlyBird.exe的地址空间有效,而这个回调函数是在notepad 的线程上执行。

所以我们传入APC队列的函数必须是注入进程中存在的函数。

有哪些函数?

  • kernel32.dll默认会被编译器通过加载时链接的方式链接到notepad.exe中,所以可以传入kernel32中的Loadlibrary

如何得到notepad中Loadlibrary的函数地址?

  • kernel32.dll在同一启动会话里所有进程基址相同,我们在EarlyBird.exe中用 GetProcAddress拿到的地址在 notepad 里也有效。

image-20260720171538281

可以看到弹窗出现,而此时notepad还没有初始化,在任务管理器看不到

image-20260720171846289

进程参数投毒

https://github.com/Orange-Cyberdefense/p3-loader

1.CreateProcess

三个 case 的核心区别就是:毒数据放在 CreateProcessW 的哪个参数里。函数签名:

1
2
3
4
5
6
7
8
9
10
11
12
BOOL CreateProcessW(
LPCWSTR lpApplicationName, // ① 程序路径
LPWSTR lpCommandLine, // ② 命令行
LPSECURITY_ATTRIBUTES lpProcessAttributes, // ③ 进程安全属性
LPSECURITY_ATTRIBUTES lpThreadAttributes, // ④ 线程安全属性
BOOL bInheritHandles, // ⑤ 句柄继承
DWORD dwCreationFlags, // ⑥ 创建标志
LPVOID lpEnvironment, // ⑦ 环境变量块
LPCWSTR lpCurrentDirectory, // ⑧ 工作目录
LPSTARTUPINFOW lpStartupInfo, // ⑨ 启动信息(内含 ShellInfo)
LPPROCESS_INFORMATION lpProcessInformation // ⑩ 进程信息(输出)
);

image-20260721142222775

2.NtQueryInformationProcess 拿到PEB 基址

1
2
3
4
5
6
7
NTSTATUS NtQueryInformationProcess(
HANDLE hProcess, // ① 目标进程句柄
PROCESSINFOCLASS ProcessInformationClass, // ② 要查什么信息
PVOID ProcessInformation, // ③ 输出缓冲区
ULONG ProcessInformationLength, // ④ 缓冲区大小
PULONG ReturnLength // ⑤ 实际返回字节数
);

image-20260721145252973

3.读PEB和Parameters

读PEB结构:

image-20260721145840603

读PEB → RTL_USER_PROCESS_PARAMETERS:

image-20260721150016220

4.找到shellcode

三者最终都落入 PEB → RTL_USER_PROCESS_PARAMETERS 的不同字段,但后续定位 Shellcode 偏移的逻辑完全一样(都是 Buffer地址 + 4 + 4096)。

image-20260721150354667

5.改内存为RX

image-20260721153253822

6.主线程 RIP 重定向执行

  • 检查是否进程开启CFG
  • 读取当前线程上下文
  • 改RIP
  • 将ctx写回线程

image-20260721153835804

处理空字节问题

Windows 的 CreateProcessW 会把参数数据(命令行、环境变量、ShellInfo 等)作为 UTF-16 宽字符串 拷贝到子进程PEB。拷贝逻辑在遇到两个连续的 0x00 字节(即宽字符的 null terminator)时就会停止。如果 Shellcode 机器码中有 \x00\x00 出现在偶数对齐位置,后面的数据全部丢失。

解决方法:

重写shellcode:

  • 将shellcode的每8字节和/x01/x01/x01/x01/x01/x01/x01/x01异或,得到无空字节的8个字节
  • 新的shellcode每8字节用汇编写xor的解密过程
  • image-20260721155036781

结果测试

image-20260721095625346

image-20260721100220789

image-20260721100557333

image-20260721105654867

用msf的meterpreter/reverse_https的shellcode:

image-20260721155552980

image-20260721155722358