进程注入
进程注入
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

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


找到notepad的PID和TID

写evil.dll
写hook函数
extern不是常用的声明变量在别的文件中,这里的extern "C"是一种链接规范,告诉编译器这个函数名用c语言的方式编译。
因为C++允许函数重载,两个名字相同的函数,编译器区分它们的方式是将它们的函数名改造成带参数类型的名字,导出表中的函数名也是这样的,导致GetProcAddress(MyhookProc)就会找不到
__declspec(dllexport)的作用是把这个函数列入dll的导出表,编译器看到这个标签就把函数放入 DLL的导出表



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

设置windowshook
- 加载evil.dll
- 获取MyHookProc的函数地址

设置WindowsHook

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


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

CreateRemoteThread注入
查找notepad进程pid
这个和之前的windowshook中的方式一样

申请权限+获取notepad的句柄

在notepad中分配内存

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


获取LoadLibraryW地址
拿到kernel32.dll句柄

拿到LoadLibraryW的函数地址

CreateRemoteThread加载evil.dll

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

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永远从系统目录加载,这些是不能劫持的

函数转发
原DLL中的导出表的函数,我们的DLL也要实现相同的功能
有两种实现方式:
方式A:运行时转发(手动调用真 DLL)
DllMain 里加载真 DLL,每个转发函数里 GetProcAddress 调真函数。
1 | // 我们的"伪装"sqlite3.dll |
特点:
- 手动控制每个函数
- 代码量大(要写每个函数)
- 可以加 hook 逻辑
方式 B:链接时转发(最优雅,编译器自动处理)
用 .def 文件声明”这个函数转发到那个 DLL 的那个函数”,编译器自动生成跳转。
步骤 1:写一个 .def 文件
1 | ; our_sqlite3.def |
含义:当程序调用我们 DLL 的 sqlite3_open 时,自动跳转到 sqlite3_real.dll 里的 sqlite3_open。
步骤 2:DllMain 只写恶意代码
1 | // 我们的"伪装"sqlite3.dll |
特点:
- 不用写函数体
- 不用手动 LoadLibrary + GetProcAddress
- 系统在 DLL 加载时自动找到
sqlite3_real.dll并转发
进程镂空(Process Hollowing)
创建一个”挂起”的合法进程,把它在内存中的代码卸载,写入我们自己的 PE文件,然后恢复执行。
复现过程遇到的问题:
- 卸载notepad是卸载的PE在内存的映像,而我们读取的evil.exe是PE文件在磁盘的格式,因此需要转换。
- 我们在目标进程申请的空间的基址和PE文件的基址是不同的,因此需要修改重定位表和入口地址
准备一个evil.exe
- 简单的弹窗功能

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

- main函数的开头调用

根据PE文件结构解析
- 获取了ntHeader的地址,后续可以凭借指针得到所需的PE信息

用CreateProcess创建挂起的notepad.exe


PROCESS_INSTRUCTION 结构体
1 | typedef struct _PROCESS_INFORMATION { |
从PEB读取notepad的ImageBase

卸载notepad原模块

VirtualAllocEx在目标进程申请空间


evil.exe磁盘布局→内存布局
一开始直接用WriteProcessMemory(peBuffer, peSize)把PE文件直接写到申请的空间
正确做法是:要先按照节表中的VA展开



重定位
remoteBase:我们VirtualAllocEx在notepad申请空间的基址
PreferBase是PE文件中的ImageBase


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

不是每条指令都需要重定位的,PE中有一批绝对地址,例如:
- 全局/静态变量里存的指针
- 虚表、一些数据结构里的地址
这些都会写在PE结构的.reloc中
根据PE结构从可选头中找到.reloc的地址


.reloc的结构:有若干个重定位块
重定位块以一个 IMAGE_BASE_RELOCATION 结构作为开始,其结构体如下:
1 | typedef struct _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 的偏移量。


dumpbin查看重定位表更清楚

外层循环:
一个 block 一个 block 遍历,计算出block中offset数组有多少元素

内层循环:
targetRVA=页RVA + offset
在内存的地址 patch= image的基址 + targetRVA
32位:
- 先读4字节到value中
- value += delta (delta是实际分配的基址和预期的基址的偏移)
- 将4字节value写回
64位:
- 同理32位,读8字节


调试这个过程:



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

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

修改入口点 + 恢复Thread
CONTEXT = CPU 寄存器的当前状态快照 一个线程被挂起时,CPU 的所有寄存器都被”冻结”保存起来。SetThreadContext 写回去。

所以ctx中的入口点是notepad的入口点,我们需要修改入口点
新的入口点=新的基址+nt头的入口点RVA


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回调函数:

入队:

用可alertable的函数触发


问题:
只有进入alertable才能触发我们的回调,因此如何触发是个问题
- 把所有线程全都QueueUserAPC,但这样做很影响目标进程效率
- 使用其他的改进技术,如earlybird技术
EarlyBird 注入
1 | 1. CreateProcess(svchost.exe, ..., CREATE_SUSPENDED) ← 挂起启动 |
每个用户态线程的第一段执行路径是固定的:
1 | LdrInitializeThunk ← ntdll,所有用户线程都从这里起跑 |
因此我们在挂起态先把我们的构造好的函数放到APC队列中,然后恢复进程,就会被启动流程中的NtTestAlert检查APC队列,执行我们的函数
我按照这个流程写了一个demo,如果注入成功会在记事本窗口出现前先出现弹窗

问题:
- 写入notepad进程的evil.dll的地址应该用绝对地址,因为notepad的地址在system32里面,和evil.dll不在一个文件夹下
- 跨进程APC注入,挂到目标进程APC队列的函数必须是目标进程地址空间里存在的函数。
我自定义了一个回调函数APCproc,功能是加载evil.dll ,这个函数的地址只在EarlyBird.exe的地址空间有效,而这个回调函数是在notepad 的线程上执行。
所以我们传入APC队列的函数必须是注入进程中存在的函数。
有哪些函数?
- kernel32.dll默认会被编译器通过加载时链接的方式链接到notepad.exe中,所以可以传入kernel32中的Loadlibrary
如何得到notepad中Loadlibrary的函数地址?
- kernel32.dll在同一启动会话里所有进程基址相同,我们在EarlyBird.exe中用 GetProcAddress拿到的地址在 notepad 里也有效。

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

进程参数投毒
1.CreateProcess
三个 case 的核心区别就是:毒数据放在 CreateProcessW 的哪个参数里。函数签名:
1 | BOOL CreateProcessW( |

2.NtQueryInformationProcess 拿到PEB 基址
1 | NTSTATUS NtQueryInformationProcess( |

3.读PEB和Parameters
读PEB结构:

读PEB → RTL_USER_PROCESS_PARAMETERS:

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

5.改内存为RX

6.主线程 RIP 重定向执行
- 检查是否进程开启CFG
- 读取当前线程上下文
- 改RIP
- 将ctx写回线程

处理空字节问题
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的解密过程

结果测试




用msf的meterpreter/reverse_https的shellcode:

