第3篇:注入的艺术 — Ghost Proxifier 核心架构拆解

17 阅读7分钟

第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 桥接

两条路的差异不只在技术,更在哲学:

维度SetThreadContextCreateRemoteThread
谁来干活目标进程的主线程我们新造一个线程
时机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()
        程序正式启动,对刚才发生的一切毫无察觉

有两个值得说的设计:

函数地址嵌入数据段LoadLibraryWGetProcAddress 这些函数的地址不是硬编码在 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 还是别的方案?有没有遇到过注入后目标进程莫名其妙崩溃的经历?