第3篇:注入的艺术 — Ghost Proxifier 核心架构拆解
系列:《从0到1搭建一个自己的Proxifier》 上一篇:第2篇《Winsock API Hook — 在应用层精确动刀》 下一篇预告:第4篇《进程树的递归追踪 — 让代理自动"传染"》
一、代码不会自己跑进别人家
前两篇我们选定了 API Hook 作为武器,也定好了要 Hook 的所有函数。MinHook 也架起来了。
但有一个根本问题我一直在绕开:你的 Hook 代码,是怎么跑到目标进程的地址空间里去的?
不是魔法。你得把一段代码(DLL)塞进别人的进程。对方不仅不邀请你,甚至不知道你的存在。你要找到它的门、撬开锁、放下东西、悄然离开——而且不能让它崩溃。
做这件事有两种主流方式,对应两种截然不同的哲学。Ghost Proxifier 两个都用了,但有明确的优先级。下面展开。
二、先看全貌:三层架构
在深入注入细节之前,先看一眼整体架构。Ghost Proxifier 分为三层:
┌────────────────────────────────────────────────────┐
│ UI 层 │
│ ghost-proxifier.exe (WebView2 前端) │
│ │
│ 职责: 界面交互、进程选择、配置管理 │
│ 它不进入目标进程 │
└────────────────────────┬───────────────────────────┘
│ 通过环境变量 + 事件同步
│
┌────────────────────────▼───────────────────────────┐
│ 注入层 │
│ ghost_injector (同架构直接注入) │
│ ghost_launcher_x64.exe / ghost_launcher_x86.exe │
│ │
│ 职责: 把 DLL 塞进目标进程 │
│ 核心代码不到 200 行 │
└────────────────────────┬───────────────────────────┘
│ VirtualAllocEx / WriteProcessMemory
│ SetThreadContext / CreateRemoteThread
│
┌────────────────────────▼───────────────────────────┐
│ Hook 层 │
│ ghost_core_x64.dll / ghost_core_x86.dll │
│ │
│ 职责: 跑在目标进程内部,Hook 所有网络函数 │
│ HTTP CONNECT 流量重定向、DNS 代理、进程树追踪 │
└────────────────────────────────────────────────────┘
UI 层和 Hook 层之间绝不直接通信。它们通过环境变量传配置、通过命名事件做同步。这个设计让三层可以独立迭代——前端换 UI 框架,核心完全不用动。
三、两种注入哲学
注入器在决策时走这样一棵树:
目标进程架构?
│
├─ x64 → 首选 SetThreadContext (EntryDetour)
│ │
│ ├─ 成功 → 完美 ✅
│ └─ 失败 → 回退到 CreateRemoteThread
│
└─ x86 → 同架构?
├─ 是 → CreateRemoteThread
└─ 否 → spawn ghost_launcher_{arch}.exe 桥接
两条路的差异不只在技术,更在哲学:
| 维度 | SetThreadContext | CreateRemoteThread |
|---|---|---|
| 谁来干活 | 目标进程的主线程 | 我们新造一个线程 |
| 时机 | DLL 加载完成后、main 之前 | 任意时刻 |
| Cygwin | ✅ 天生兼容 | ❌ 野指针崩溃 |
| 杀软感知 | 低(无新线程创建) | 中(CreateRemoteThread 被监控) |
| x86 | 不可用 | 主要手段 |
SetThreadContext 更精巧,也是这个项目最让我骄傲的技术决策。下面完整拆解。
四、SetThreadContext:借尸还魂
前置知识:Windows 进程启动的秘密
很多人都以为 CreateProcess 之后程序就直接跑 main() 了。实际情况是——中间有一段"隐藏剧情"。
当进程以 CREATE_SUSPENDED 创建时,主线程的 CPU 寄存器是这样的:
RIP → LdrInitializeThunk ← 线程醒来后执行的第一条指令
RCX → mainCRTStartup 地址 ← EXE 的真正入口
LdrInitializeThunk 是 Windows 用户态进程的真正起点。它按依赖顺序加载所有静态导入的 DLL(kernel32.dll、ws2_32.dll、cygwin1.dll……),依次调用每个 DLL 的 DllMain,全部完成后——jmp RCX——跳转到 EXE 入口,于是 main() 开始执行。
我们的主意就打在这个 jmp RCX 上。
如果把 RCX 改成指向我们的 shellcode,LdrInitializeThunk 跑完后就会自动跳转到我们的代码,而不是 EXE 的 main。
关键:RIP 不动。因为跳过 LdrInitializeThunk 等于跳过所有 DLL 的加载和初始化——进程当场死亡。
完整流程:一张图说完七步
父进程 (注入器) 子进程 (刚出生,挂起)
──────────────── ────────────────────
CreateProcessW(CREATE_SUSPENDED)
→ RIP = LdrInitializeThunk
→ RCX = EXE 入口
→ 线程暂停 ⏸
① GetThreadContext(hThread)
→ 读取 RCX 的值 (EXE 入口) (仍然挂起)
② VirtualAllocEx(hProcess)
→ 在子进程里申请一块 RWX 内存 (仍然挂起)
③ 构建 shellcode buffer (仍然挂起)
┌──────────┬──────┬─────────────────┐
│ 87 字节 │ 指令 │ 可执行机器码 │
│ 8 字节 │ 数据 │ EXE原始入口(保存) │
│ 8 字节 │ 地址 │ LoadLibraryW │
│ 8 字节 │ 地址 │ GetProcAddress │
│ 8 字节 │ 地址 │ WaitForSingleObj │
│ 8 字节 │ 句柄 │ hChildEvent │
│ 10 字节 │ 字符串│ "GhostInit\0" │
│ n 字节 │ 路径 │ DLL 完整路径 │
└──────────┴──────┴─────────────────┘
④ WriteProcessMemory (仍然挂起)
→ 整块 buffer 写入子进程
⑤ SetThreadContext ★
→ ctx.RCX = shellcode 地址 (仍然挂起)
→ ctx.RIP = 不变
⑥ ResumeThread
─────────────────────────────────→ 线程恢复!▶
│
▼
LdrInitializeThunk
│
├─ 加载 kernel32.dll
├─ 加载 ws2_32.dll
├─ 加载 cygwin1.dll
│ └─ DllMain → 初始化 cygtls ★
│
├─ 加载 ghost_core_x64.dll
│ └─ DllMain → GhostInit()
│ └─ SetupThreadInternal()
│ ├─ 读配置 (环境变量)
│ ├─ MH_Init + CreateHook × 25+
│ ├─ MH_EnableHook(ALL) ★
│ └─ SetEvent → 通知父进程
│
├─ 所有 DLL 加载完毕
│
└─ jmp RCX → 进入 shellcode
│
├─ LoadLibraryW(dll)
│ → 已加载, 幂等返回
├─ GetProcAddress
│ → "GhostInit"
├─ call GhostInit
│ → no-op (已初始化)
├─ WaitForSingleObject
│ → 立即返回
├─ 恢复寄存器
└─ jmp EXE入口
→ main()!
⑦ WaitForSingleObject(hEvent)
→ 返回,确认子进程就绪 ✅
Shellcode 到底干了什么(不贴汇编,用流程图)
Shellcode 总共只有 87 个字节。做的事浓缩成一张图:
进入 shellcode
│
├─ 1. 保存现场
│ 把 RCX(EXE入口) 和 RDX R8 R9 推入栈
│ "一会儿还要跳回 EXE,别把门牌号丢了"
│
├─ 2. LoadLibraryW(ghost_core_x64.dll)
│ 如果 DLL 还没加载 → 加载它 (不会,LdrInitializeThunk 已经加载过了)
│ 如果已加载 → 返回模块句柄 (走了这条路)
│ 不管怎样这个调用都是幂等的
│
├─ 3. GetProcAddress(hModule, "GhostInit")
│ 找到 GhostInit 函数指针
│
├─ 4. 调用 GhostInit()
│ g_Initialized == true → 直接返回 (幂等守卫)
│ "你已经初始化过了,我只是过来看看"
│
├─ 5. WaitForSingleObject(hEvent, 5000ms)
│ 等内核线程确认一切就绪
│ 实际上 < 1ms 就返回了 (LoadLibraryW 是同步的)
│ "以防万一的等待,实际从来不真等"
│
└─ 6. 恢复现场 → jmp EXE 入口
从栈里弹出 RDX R8 R9 RCX
jmp RCX = jmp mainCRTStartup → main()
程序正式启动,对刚才发生的一切毫无察觉
有两个值得说的设计:
函数地址嵌入数据段:LoadLibraryW、GetProcAddress 这些函数的地址不是硬编码在 shellcode 里的——每个进程、每个 Windows 版本它们都不一样。注入时,注入器动态获取这些地址,填入 shellcode 的数据段。shellcode 只需要 [rip+偏移] 就能取到。
GhostInit 被调两次但无所谓:DllMain 自动调一次,shellcode 显式调一次。第二次检测到 g_Initialized == true,直接返回。这不是 bug,是防御——shellcode 不假设 DLL 加载顺序。
五、GhostInit 内部长什么样
当 DllMain(DLL_PROCESS_ATTACH) 被触发——这件事发生在 LdrInitializeThunk 加载 ghost_core.dll 时,在主线程中同步执行——GhostInit 立刻被调用。它的内部流程:
GhostInit()
│
├─ g_Initialized == true ? → return (幂等)
│
└─ SetupThreadInternal()
│
├─ ① LoadConfigFromEnv()
│ 读 GHOST_PROXY_ADDR / GHOST_DNS_MODE 等环境变量
│ "父进程给我留了什么配置?"
│
├─ ② MH_Initialize()
│ 初始化 MinHook 引擎
│
├─ ③ 获取所有目标函数地址
│ GetProcAddress(ws2_32, "connect") → real_connect
│ GetProcAddress(ws2_32, "send") → real_send
│ ... × 25+
│
├─ ④ MH_CreateHook × 25+
│ connect → hook_connect → trampoline_connect
│ WSAConnect → hook_WSAConnect → trampoline_WSAConnect
│ send → hook_send → trampoline_send
│ CreateProcessW → hook_CreateProcessW → ...
│ ... 逐一注册,全都暂不激活
│
├─ ⑤ StartStats()
│ 启动 DelayedInitThread
│ "等 2 秒让主线程跑稳,然后做 WSAStartup + NetLog 握手"
│
├─ ⑥ MH_EnableHook(MH_ALL_HOOKS) ★
│ 一次性激活全部 25+ 个 Hook
│ "开火"
│
├─ ⑦ g_Initialized = true (原子写)
│
├─ ⑧ EnvInjectFromConfig()
│ 把当前配置写回 GHOST_* 环境变量
│ "以后我的子进程读到这些,就知道该走哪个代理"
│
└─ ⑨ SetEvent(hReadyEvent) ★
"爸,我准备好了"
⑥ 的一次性激活值得单独说。如果逐条激活——先激活 connect 的 Hook,过一会儿激活 send 的 Hook——中间存在一个窗口:connect 被重定向了但 send 还没接管。应用在这个窗口里调 send,数据直接裸发。一次性激活彻底消除了这个窗口。
六、关于跨架构
64 位进程无法直接操作 32 位进程的地址空间(反之亦然)。当架构不匹配时,我们启动一个对应架构的中间进程:
64位 注入器
│
└─→ CreateProcess("ghost_launcher_x86.exe")
│
└─→ 这个 32 位 launcher 再注入 32 位目标的 DLL
ghost_launcher 是一个极简的 exe——接收两个参数(目标 PID + DLL 路径),调用 LoadLibrary 注入,然后退出。它自己不含任何 Hook 逻辑。
七、两种路径、两种命运
SetThreadContext 的优雅之处在于:一切都在主线程中完成。
这个设计在 99% 的场景下看起来只是"另一种注入方式"。但对于 Cygwin 程序来说,它是生与死的差别——因为 Cygwin 只为接收 DLL_PROCESS_ATTACH 的那一个线程初始化 TLS。CreateRemoteThread 造的新线程没有这个初始化,访问 cygtls 就是野指针,野指针就是 SIGSEGV。
这个故事的完整版,包括我那三天的调试记录,放在第五篇。这里先记住一句话:在 DLL 注入的世界里,"哪个线程在执行"比"执行了什么"更重要。
八、下一步
单个进程的注入链路打通了。但用户不会只开一个进程——Chrome 主进程会 spawn 出 Network Service,IDE 会 fork 出 language server。代理怎么自动跟上?
下一篇,进程树的自动传播。
讨论:你尝试过 DLL 注入吗?用的是 CreateRemoteThread 还是别的方案?有没有遇到过注入后目标进程莫名其妙崩溃的经历?