首页
社区
课程
招聘
[原创]一个针对某开放世界二游私有壳外部注入工具的逆向分析
发表于: 6天前 1249

[原创]一个针对某开放世界二游私有壳外部注入工具的逆向分析

6天前
1249

一个针对某开放世界二游私有壳外部注入工具的逆向分析

写在前面

最近在某短视频平台看到一个「游戏功能扩展工具」的爱发电项目,第一眼吸引我的其实不是功能,而是它的本体体积——一个 exe 加一个 dll 分别做到了 40MB 和 50MB 左右,对于一个「读配置+注入+悬浮菜单」体量的小工具来说这个大小相当反常,于是就顺手拖进去看看里面到底塞了什么,结果一路从静态 PE 结构扒到动态调试实锤出完整的注入链条,中间也踩了几个坑、被动态实测推翻过一次静态推断。整个过程记录下来,一是给自己留档,二是分享一下分析思路,尤其是如何在完全不接触受驱动保护的目标进程本体的前提下,只调试样本自身进程,就把整条注入链条实锤出来的方法——这个技巧应该对分析同类"外部注入型"工具都有参考价值。

声明:游戏名称、样本文件名、本地路径均已脱敏(游戏统一称"某神",主程序称 loader.exe,DLL 称 payload.dll,本地目录统一替换为 X:\Sample\,不影响分析的完整性和准确性);本帖只实锤了「注入手法」本身——DLL 是怎么被写进游戏进程、线程是怎么被拉起来的,不涉及、也未验证该工具是否真的能规避反作弊检测,注入手法和是否被检测到是两件完全不同的事,请勿混为一谈;不提供样本、不提供成品工具,仅作技术交流与逆向学习记录。


0x00 样本信息

文件 大小 类型 时间戳
loader.exe 41,371,096 字节 PE32+ (x64) EXE 2026-02-26 01:53:24
payload.dll 39,855,064 字节 PE32+ (x64) DLL 2026-07-06 02:51:02
config 57 字节 纯文本,内容为游戏路径 X:\XXX Launcher\games\XXX Game\SomeGame.exe

从目录结构就能看出这是一个典型的「loader.exe 读取 config 里的游戏路径 → 注入 payload.dll → DLL 在游戏内弹出菜单(Insert 键开关)」的外部注入式游戏功能扩展工具,架构上和市面上常见的同类外挂/工具高度相似(exe 只管找路径 + 注入,dll 才是真正的 hook + GUI 菜单主体),但这层私有壳用的是什么加密/加壳方案,所以拿来做逆向研究样本。

0x01 静态分析环境

  • IDA(对 payload.dll 建库分析,体积大 + 加密段占比高,建库速度非常慢,具体分析进度和发现见后面 0x08 节)
  • 手写 PE 解析脚本(没有 dumpbin/CFF Explorer,自己实现节表、数据目录、导入表、TLS、熵值、证书链解析)
  • 系统自带的证书链解析工具

0x02 PE 基本结构——节表全员"看起来正常但其实是空的"

loader.exe

  • Machine: 0x8664 (AMD64),PE32+,9 个节
  • EntryPoint RVA: 0x0288A8B5
  • CLR(.NET) Header: 无(原生程序)
  • ImageBase: 0x0000000140000000

节表:

节名 VSize VAddr RawSize RawPtr 备注
.text 0x291D3 0x1000 0 0 全零填充,运行时才会有代码
.rdata 0xCAB0 0x2B000 0 0 同上
.data 0x1150 0x38000 0 0 同上
.pdata 0x1FB0 0x3A000 0 0 同上(异常展开表,证明是 MSVC 编译)
.mv) 0x279BF4C (~41.4MB) 0x3C000 0 0 巨大的纯虚拟保留区,无文件数据——解包目的缓冲区
.V v 0x2A0 0x27D8000 0x400 0x400 实际是 IAT(见 0x03)
.SmE 0x2771A2C (~41.36MB) 0x27D9000 0x2771C00 0x800 载荷段:入口点与 TLS 回调均落在此处,熵 7.981/8.0
.rsrc 0x1E0 0x4F4B000 0x200 0x2772400 正常清单资源(见 0x06)
.reloc 0xB8 0x4F4C000 0x200 0x2772600 基址重定位表

关键异常点.text/.rdata/.data/.pdata 四个"看起来很正常"的节,RawSize 全部是 0——磁盘文件里根本没有对应的数据,加载时全是零页。也就是说这个 exe 文件本体里没有任何一行"看起来正常"的代码,真正的程序体只存在于运行时,由 .SmE 段里的小型 loader 解压/解密后动态写入这些区域。这是一种相当彻底的**单阶段内存解包(manual-mapping 风格)**设计。

节名 .mv).V v.SmE 本身就是随机乱码字符串(含右括号、空格等非常规字符),不匹配任何已知商业壳(UPX/VMProtect/Themida/ASPack/PECompact/Enigma 等)的标准节名,判断是私有/自制壳,随机命名节表就是为了躲开基于节名的壳签名检测(PEiD/DIE 一类工具最常用的招)。

payload.dll

  • Machine: 0x8664,PE32+,10 个节,EntryPoint RVA 0x04291205
  • ImageBase: 0x0000000180000000
  • 结构与 exe 高度类似(同一套壳生成器产物):
节名 VSize RawSize 备注
.text/.rdata/.data/.pdata 正常大小 0 同样全零
_RDATA 0x1D0 0 全零
.A,7 0x112CCE1 (~17.2MB) 0 巨大虚拟保留区
.JkP 0x418 0x600 IAT
.JAt 0x25FF704 (~39.9MB) 0x25FF800 载荷段,入口点落于此,熵 7.137/8.0
.rsrc 声明 0xD02816 (~13.6MB) 仅 0x200 (512字节) 严重不一致,见 0x06
.reloc 0xB8 0x200 正常

0x03 导入表 / IAT 分析——裁剪到了极限

两个文件的 IAT 数据目录 RVA 精确对应节表里那个"莫名其妙"的小节(.V v / .JkP),证实这两个乱码节其实就是标准 IAT,只是被壳的节表生成器随机改了名字。

导入的 DLL 列表:

  • loader.exeKERNEL32 / USER32 / MSVCP140 / CRYPT32 / VCRUNTIME140_1 / VCRUNTIME140 / api-ms-win-crt-*(运行时、堆、字符串、时间、文件系统、数学、locale 等 UCRT 转发 DLL)/ ole32 / OLEAUT32 / api-ms-win-core-winrt-error
  • payload.dll:以上全部 + GDI32 / ADVAPI32 / SHELL32 / WS2_32(网络)/ IMM32 / dwmapi / msi / ntdll / WINMM / imagehlp / api-ms-win-shcore-scaling / api-ms-win-core-winrt

先从 DLL 名单本身能看出几点判断(这几点站得住,但导入表本身还有一个更大的坑,是靠逐字节静态核对发现的、不是动态调试测出来的——见下方勘误):

  • VCRUNTIME140_1.dll + __CxxFrameHandler4 → 编译器是较新版本 Visual Studio(VS2019 16.8+/VS2022 新版 C++ EH)。
  • WS2_32(网络)+ CRYPT32(加密/证书)→ DLL 内部很可能有联网校验/证书校验逻辑。
  • SHELL32(ShellExecuteW) + dwmapi(DWM 模糊/透明) + IMM32(输入法) → 符合一个基于 ImGui 类框架的悬浮菜单 overlay 窗口的典型依赖特征,跟前面 0x00 节提到的 Insert 键开关悬浮菜单能对得上。
  • imagehlp.dll(ImageRvaToVa) → DLL 运行时会自己解析 PE 结构,常见于手写 hook/inline patch 或自解析导出表定位 API。

0x03-勘误:每个 DLL 静态可见的函数只有 1 个

最初解析每个 DLL 都只打出 1 个函数名,一开始以为是脚本 bug,逐个核对 OriginalFirstThunk 的 RVA 间距后,确认这是真实数据

exe:KERNEL32.dll ILT=0x02837D88 gapToNext=16
     USER32.dll   ILT=0x02837D98 gapToNext=16
     MSVCP140.dll ILT=0x02837DA8 gapToNext=16
     ...(17个DLL,全部 gapToNext=16,无一例外)

dll:34 个 DLL,同样全部 gapToNext=16,无一例外

每个 DLL 的 OriginalFirstThunk 数组固定占 16 字节 = 1 个函数指针(8字节) + 1 个全零结束符(8字节),exe 17 个 DLL / dll 34 个 DLL 无一超出这个模式。这是极端裁剪的导入表:链接进来的系统 DLL 列表很长(涵盖 UCRT 全家桶、CRYPT32、WS2_32、SHELL32、dwmapi、ntdll、imagehlp 等),但每个 DLL 静态可见、会被 Windows 加载器直接解析填入 IAT 的函数只有 1 个"占位符"

合理解释:这 1 个函数唯一的作用就是强迫 Windows 加载器在进程创建阶段把该 DLL 映射进地址空间(PE 加载器处理导入表时,只要某个 DLL 出现在导入描述符里,就一定会 LoadLibrary 它,不管实际用了几个函数)。真正会被调用的绝大多数 API——尤其是进程操作类CreateProcess/OpenProcess/VirtualAllocEx/WriteProcessMemory/SetThreadContext/ResumeThread 这类做「创建 → 注入 → 恢复」全套操作必需的函数——完全不出现在静态导入表里,只能是 TLS 回调解包出来的真代码在运行时手动 GetProcAddress(甚至更绝,直接手写 PEB→Ldr模块链→导出表哈希匹配,连 GetProcAddress 都不调)解析出来的。

这说明作者是冲着"绕过基于导入表/API调用扫描的检测"专门做的设计,而不是通用壳模板的副产品,是相当有针对性的工程。当然这只是基于静态证据的推断,具体注入手法是什么样,需要动态验证(见 0x0A,动态实测后发现比这里推断的更简洁)。

0x04 熵值分析

文件 载荷段 采样熵(满值8.0)
exe .SmE 7.981
dll .JAt 7.137

exe 的载荷段熵值接近理论最大值,几乎可以确定经过强压缩或加密;dll 载荷段熵值略低,可能混合了未压缩/弱压缩的数据结构,或采用了不同的处理强度。

0x05 OEP 与 TLS 回调分析——核心壳分发机制

两个文件的 TLS 回调声明的入口点 都落在各自的载荷段内(.SmE / .JAt),而不是常规的 .text(下面 0x0A 动态调试会直接实锤这一点)。

TLS 回调函数开头字节(两文件几乎逐字节一致,仅跳转偏移不同):

81 FA 01 00 00 00      cmp edx, 1          ; edx = Reason 参数
0F 85 05 00 00 00      jnz +5              ; 不是 DLL_PROCESS_ATTACH(1) 就跳过
E9 xx xx xx xx         jmp <real_handler>  ; 是 PROCESS_ATTACH 才跳进真正的解包/初始化逻辑
C3                     ret

这是标准的 TLS 回调分发器模板:只在进程/线程首次建立(Reason=1)时才触发真正逻辑。由于 Windows 加载器会在调用主入口点之前先执行 TLS 回调,壳作者利用这一点把"真正的解包代码"藏在 TLS 回调里,比放在声明的 OEP 更早执行、更隐蔽(很多自动化脚本/调试器默认只在 OEP 下断点,会错过这一步)。

两个文件用几乎相同的字节模板,证明 exe 和 dll 是同一个自制壳生成器批量处理产出的,不是两个独立保护方案。

跳转目标之后的代码(dll 侧)呈现出典型的**指令级混淆(多态变形)**特征:

9C                     pushfq
48 B9 xx xx xx xx xx xx xx xx   mov rcx, imm64
48 81 C1 xx xx xx xx  add rcx, imm32
...
48 8B 4C 24 10         mov rcx, [rsp+10h]
48 C7 44 24 10 xx xx xx xx  mov qword [rsp+10h], imm32
9D                     popfq
48 8D 64 ...           lea rsp, ...

"保存标志位 → 用大随机立即数做运算 → 操作栈上的值 → 恢复标志位",目的是让每次生成的壳 stub 字节码都长得不一样(对抗特征码扫描),而不是简单的固定 XOR 循环。说明这个私有壳除了"加密+随机节名"外,stub 本身也带一层指令级混淆,工程量不小。

0x06 资源段(.rsrc)分析

exe:正常、完整

512 字节原始数据中除标准资源目录头外,直接能读出应用程序清单 XML:

<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<assembly xmlns='urn:schemas-microsoft-com:asm.v1' manifestVersion='1.0'>
  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
    <security>
      <requestedPrivileges>
        <requestedExecutionLevel level='requireAdministrator' uiAccess='false' />
      </requestedPrivileges>
    </security>
  </trustInfo>
</assembly>

requireAdministrator → exe 启动即请求 UAC 管理员权限(符合需要打开游戏进程句柄/注入 DLL 的需求)。

dll:数据目录严重不一致(可能的反解析陷阱)

数据目录里 .rsrc 声明的 VirtualSize 高达 0xD02816(约13.6MB),但 RawSize 只有 0x200(512字节)。512 字节范围内能看到看起来正常的资源目录树结构,以及两个自定义命名资源类型的 UTF-16 字符串:"DAT" 和 "TXT"(而非标准 RT_ICON/RT_MANIFEST 等数字类型),暗示原始程序可能内嵌了自定义二进制数据块。但由于文件里实际只给了 512 字节,任何试图按声明的 13MB 去解析该资源树的工具都会读出边界外的垃圾数据甚至崩溃——这既可能是构建工具的 bug,也可能是故意留下的反解析陷阱

0x07 全文件关键字符串扫描

对整份文件做了逐字节字符串扫描,检索了一批常见特征词:已知壳名(UPX/VMProtect/Themida/ASPack/Enigma...)、常见压缩库(zlib/lz4/zstd/aplib)、游戏引擎特征、hook 框架(MinHook/Detours)、反调试/虚拟机特征等。

结果:除了 Authenticode 证书链里的合法内容之外,没有任何一条命中是可读的、有意义的字符串。零星命中的"UPX"/"zlib"等词,上下文全是不可打印乱码,属于 40MB 高熵数据里长度 3~4 的子串按概率必然出现的统计噪声,不代表真的存在这些开源库或壳。

这是一条重要的反向证据:进一步印证载荷段是真正意义上的高熵加密/压缩数据,且壳自身没有在明文里留下任何自我标识信息——是一个做得比较干净的私有壳。

0x08 IDA 静态分析现状

payload.dll 建库分析仍在进行中(.id1 分析状态位图已达 159MB,.nam 命名索引仅 16KB,符合"40MB 里 90%+ 高熵数据、IDA 自动分析效率很低"的现象)。目前函数列表只识别出 5 个函数:4 个疑似解包相关的函数 + 1 个 TLS 回调TlsCallback_0);反汇编视图里通过交叉引用又额外挖出 TlsCallback_1~3 三个 TLS 回调入口,说明这个 DLL 实际注册了4 个 TLS 回调,比手写脚本最初只检测到的单个入口更多,值得后续深入。IDA 头部自动识别的编译器/时间戳信息与静态手写解析完全吻合,交叉验证成功。

由于本帖的核心成果(完整注入链条)已经通过下面的动态调试实测拿到,载荷段的静态解包逻辑作为后续补充内容,不影响本次结论的完整性。

0x09 现阶段整体判断

  1. 不是任何已知商业壳/保护方案(VMProtect、Themida、UPX、ASPack、Enigma 等特征均不匹配),是一个私有自制的原生 PE 壳,exe 和 dll 由同一套生成器批量处理。
  2. 壳的核心思路:节名混淆 + 单阶段内存解包(大量 RawSize=0 的虚拟保留段)+ 导入表裁剪到极限(每 DLL 仅留 1 个占位函数)+ TLS 回调先行触发解密 + 指令级混淆的 stub 代码 + 高熵压缩/加密的载荷段,工程复杂度中高,是长期迭代过的私有工具而非临时脚本。
  3. 宿主程序本体是一个用较新版 MSVC 编译的原生 C++ 外部注入型游戏功能扩展工具:exe 负责定位游戏路径 → 请求管理员权限 → 注入 DLL;DLL 内部含 ImGui 风格悬浮菜单(Insert 键开关)、网络能力、证书/加密能力、DWM/输入法相关调用(悬浮窗渲染与输入)。
  4. 注入机制:静态阶段一度怀疑是"挂起注入",但被下面的动态实测证伪并修正为"竞速注入(race injection)+ 自定义二段式远程线程 stub",详见 0x0A——这是本帖的重点章节。

0x0A 动态调试实录:只碰样本自身进程,完整实锤注入链条

这是本次分析里最有意思的部分。核心思路是:全程只用调试器挂载 loader.exe 自身,从不 attach 或修改游戏客户端进程,因为游戏受驱动级反作弊保护,直接调试它风险高、也容易被检测到;但 loader.exe 是外部注入工具本体,调试它本身没有任何限制。只要在 loader.exe 里对着 CreateProcessW/VirtualAllocEx/WriteProcessMemory/CreateRemoteThread 这些 Win32 API 的导出函数入口下断点,就能在它自己的地址空间里,完整看到它是怎么"对外操作"游戏进程的——这是分析同类外部注入工具时一个通用性很强的技巧。

环境

  • 调试器:X:\Tools\x64dbg\x64dbg.exe(TitanEngine 内核)
  • 调试目标:loader.exe 本体(不是游戏进程,全程未接触受驱动保护的游戏客户端)

第一步:挂起启动,停在系统断点

文件 → 打开 选择 loader.exe,进程以调试方式创建后立即停在系统断点ntdll.LdrpInitializeProcess 内部)。这时候目标自身代码一条指令都还没执行,TLS 回调、声明的入口点都没触发,处于最干净的初始状态。

可以看到 RIP 停在系统模块 ntdll.dll 内部,反汇编面板与寄存器面板显示的都是系统加载器代码,样本自身还未开始执行。

在命令栏依次下断点:bp CreateProcessWbp CreateProcessA,断点面板确认两者状态均为"已启用"。此外 x64dbg 会自动挂上一个一次性的"入口断点"(对应 OptionalHeader.AddressOfEntryPoint)。

第二步:命中入口断点——实锤 OEP 真的落在载荷段里

命中入口断点,RIP 停在样本自己的地址空间内,模块内偏移标注为 .SmE:$288A8B5 <OptionalHeader.AddressOfEntryPoint>

反汇编面板顶部一行清清楚楚可见 .SmE 段名,这正是 0x02 节里静态分析出的那个熵值 7.981 的巨大载荷段。

面包屑/反汇编行进一步给出了地址闭环证据:

状态栏同样确认了这是入口断点命中:

这实锤了 0x02 节纯静态分析的预测:声明的入口点确实落在 .SmE 载荷段内,而不是常规的 .text。反汇编窗口里正常指令与红色 ???(无效指令)交替出现,是线性反汇编面对非顺序执行的跳转代码产生的正常错位现象(不代表代码没解密),符合壳 stub 依赖 call/jmp 跳转而非顺序执行的特点。

第三步:命中 CreateProcessW——拿到游戏路径 + 验证 dwCreationFlags

经过 TLS 回调解包,命中 kernel32.dll!CreateProcessW(真正的导出函数入口,不是 thunk)。寄存器面板确认:

  • RCXlpApplicationName)= L"X:\XXX Launcher\games\XXX Game\SomeGame.exe" —— 与 0x00 节 config 文件记录的路径完全一致,实锤这次调用正是在创建游戏进程
  • RSI = &"X:\Sample\loader.exe"(样本自身完整路径,推测将作为 lpCurrentDirectory 参数传入)

紧接着验证第 6 个参数 dwCreationFlags(位于 [RSP+0x30]),用监视窗口展开:

监视窗口读出 [rsp+30] = 00007FF800000000,结合反汇编确认 kernel32 包装函数用的是 mov eax, dword ptr ss:[...]32 位读取)取用这个参数,说明真正生效的只有低 32 位 = 0x00000000(高 32 位是栈上遗留的垃圾数据,不是参数本身)。

0x0A-勘误:挂起注入猜想被证伪

dwCreationFlags = 0,完全没有设置任何标志位,不含 CREATE_SUSPENDED(0x4)。 这直接推翻了 0x09 第 4 条基于静态证据(导入表裁剪、无法解释注入手法)做出的"挂起注入"猜想——游戏进程是被完全正常、立即运行地创建出来的,根本没有走挂起流程

结合这一实测结果重新推断:真正的注入手法更可能是竞速注入(race injection)——即正常创建游戏进程后,尝试在反作弊驱动完成加载/接管保护之前的时间窗口内尽快完成注入动作,而不是「挂起创建 → 改上下文 → 恢复线程」的经典手法。需要特别说明:这里只是从代码行为上分析出它"试图"抢时间窗口这一设计思路,本次分析完全没有涉及游戏客户端和反作弊模块,也没有做任何"是否被检测到"的验证,所以不能得出"这个工具成功绕过了反作弊"的结论——能确认的只是"用了什么手法、什么时机去注入",至于这个时机是否真的有效、有没有被抓,是另一个完全没有验证过的问题。这个反转也是本帖标题里强调"实锤"的原因——静态推断和动态实测得出了完全不同的结论,只有动态验证才是可靠的(这里说的"动态验证",验证的对象是"注入手法本身",不是"能不能过反作弊")。

第四步:一次性追加全部关键 API 断点

保持进程正常运行(不需要恢复挂起,因为本来就没挂起),继续追踪 OpenProcess/VirtualAllocEx/WriteProcessMemory/CreateRemoteThread/NtCreateThreadEx/QueueUserAPC 等调用。一次性追加挂上 10 个断点:

游戏窗口已经正常打开(说明进程创建后确实在正常跑,不是挂起状态),随即命中下一个断点。

第五步:命中 VirtualAllocEx——在远程进程申请一页 RWX 内存

寄存器/监视确认:

  • RCXhProcess)= 0x1F8 —— 不是 GetCurrentProcess() 伪句柄(伪句柄固定值是 0xFFFFFFFFFFFFFFFF),是一个正常范围的真实句柄数值,说明这是对另一个进程(大概率是刚创建的游戏进程)的远程分配调用
  • RDXlpAddress)= 0(NULL,交给系统自动选址)
  • R8dwSize)= 0x1000(4096 字节,一个内存页)
  • R9flAllocationType)= 0x3000 = MEM_COMMIT | MEM_RESERVE
  • [RSP+28](第 5 个参数 flProtect)低 32 位 = 0x40 = PAGE_EXECUTE_READWRITE
  • 附带发现:R15 寄存器里此刻缓存着 kernel32.LoadLibraryW 的函数地址(反汇编右侧直接标注 <kernel32.LoadLibraryW>

解读VirtualAllocEx(远程句柄, NULL, 0x1000, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE) 是在目标进程里申请一页可读可写可执行的内存,是远程代码注入准备阶段最典型的特征(缓冲区既要能写入代码/字符串,又要能被执行)。R15 提前缓存好 LoadLibraryW 地址,进一步指向经典的远程线程注入手法(而不是手动映射/manual-map)。

第六步:命中 WriteProcessMemory(第一次)——写入 43 字节的自定义 shellcode stub

移除 VirtualAllocEx 断点后,命中 kernel32.dll!WriteProcessMemory

参数面板确认:

  • RCXhProcess)= 0x1F8 —— 与上一步 VirtualAllocEx 的句柄完全一致,证实前后两次调用针对的是同一个远程进程
  • RDXlpBaseAddress)= 0x0000025300140000 —— 与上一步 VirtualAllocEx 的返回地址同一数值,说明这次写入的目标正是刚刚在远程进程里申请出来的那页 RWX 内存
  • R9nSize)= 0x2B43 字节)——数值很小,最初猜测是短字符串(比如 DLL 文件名),跳转到本地源缓冲区实际查看后证伪:右侧 ASCII 栏几乎全是不可打印乱码,不是任何可读路径/文件名;十六进制内容能识别出清晰的 x64 机器码特征
48 83 EC 28              sub rsp, 0x28            ; 栈帧
48 8B E0                 mov rsp, rax             ; (存疑)
E8 3D F8 7F 00 00        call ...                 ; 第一次调用
FF D0                    call rax
48 85 C0                 test rax, rax            ; 检查返回值
74 07                    jz  +7                   ; 失败则跳过
48 31 C0                 xor rax, rax
EB 48                    jmp ...
8B D8                    ...
48 B8/... imm64          ; 内嵌一个 64 位硬编码绝对地址
FF D0                    call rax                 ; 第二次调用
48 83 C4 28              add rsp, 0x28            ; 恢复栈帧
C3                       ret

结构上呈现的是**「序言 → 调用A → 检查返回值(test/jz,典型的判断 LoadLibrary 是否成功) → 调用B(地址是硬编码立即数) → 收尾ret」的完整闭环,且此前 R15 一直缓存着 LoadLibraryW 的地址——综合判断这是一个自定义的两段式远程线程 stub**:不是简单的 CreateRemoteThread(lpStartAddress=LoadLibraryW, lpParameter=DLL路径指针)(那种手法一次只能调一个函数),而是把这一小段代码本身当作远程线程的入口点,stub 内部自己先调 LoadLibraryW 加载 DLL、检查是否成功,再调用第二个固定地址(很可能是 DLL 自己的初始化函数/入口点),一次远程线程调用完成"加载+初始化"两步,比单纯 CreateRemoteThread+LoadLibraryW 更高级、更难被简单行为规则识别。

第七步:一个值得单独拎出来的细节——OpenProcess 命中次数为 0

在继续追踪下一次命中之前,先检查一下断点面板各断点的"命中"计数:

断点 命中次数
CreateProcessW 1
VirtualAllocEx 1
WriteProcessMemory 1(就是刚刚那次 43 字节 stub 写入)
OpenProcess 0
QueueUserAPC / ResumeThread / CreateRemoteThread / CreateProcessA / SetThreadContext / CreateRemoteThreadEx / NtCreateThreadEx 均为 0

OpenProcess 一次也没被调用过,但 VirtualAllocEx/WriteProcessMemory 都已经拿到了有效的远程句柄 0x1F8 并成功操作了远程进程。唯一合理的解释:这个句柄就是之前 CreateProcessW 自己创建进程时,系统通过 PROCESS_INFORMATION 输出参数直接返回给它的 hProcess——因为自己就是创建者,这个句柄天然就具备对该进程的完整访问权限,根本不需要再单独调用 OpenProcess 去"重新拿一个句柄"

这是一个很精致的小细节:少调一个 API,就少一个可能被行为规则/EDR 捕捉的观察点。分析同类样本时,"检查有没有调用某个理论上'应该'出现的 API"和"检查它是不是真的调用了"同样重要——很多时候答案是没有调,而是走了更省事的路。

第八步:命中 WriteProcessMemory(第二次)——DLL 路径字符串实锤

移除上一个 WriteProcessMemory 断点后,再次命中 kernel32.dll!WriteProcessMemory(命中次数变成 2)。这次参数面板的本地源缓冲区直接显示出了完整可读的宽字符串:

参数 含义
RCXhProcess 0x1F8 与前两次完全一致,同一个远程进程
RDXlpBaseAddress 0x0000025300140028 = 页基址 0x25300140000 + 0x28,恰好接在上一次写入的 stub 代码尾部之后
R9nSize 0x5282 字节 宽字符串长度,与下面的路径字符串长度匹配
R8lpBuffer L"X:\\Sample\\payload.dll" 本地源缓冲区内容就是待注入 DLL 的完整绝对路径,与样本自身所在目录完全对应

这是本次分析最强的一条实锤证据:写入的字符串直接就是待注入 DLL 的路径。加上地址刚好接在之前 stub 代码后面,远程进程那页内存的完整布局已经拼出来了

0x25300140000                 0x25300140028                              0x2530014007A(大概)
     |------ stub 代码(0x28字节) ------|------ L"X:\Sample\payload.dll\0"(0x52字节) ------|

stub 前面部分调用 LoadLibraryW(参数指向自己后面靶的这段字符串),成功后再调用第二个固定地址。

这里有个细节值得指出:第六步 WriteProcessMemory 写入 stub 时 nSize 实际读数是 0x2B(43 字节),但这次字符串写入的起始地址只比 stub 起始地址偏移了 0x28(40 字节)——也就是说 stub 那 43 字节里的最后 3 字节,会被这次写入的 DLL 路径字符串直接覆盖掉,真正留下来、会被执行到的 stub 代码其实只有前 40 字节。反过来看,这恰好说明那 3 字节本身就是无关紧要的填充/垫料,不影响 stub 的实际执行逻辑,两次写入的 nSize/地址数值本身都没有问题,只是覆盖顺序上有这么一个小细节。

第九步:命中 CreateRemoteThread——参数完全闭环,链条实锤完成

命中 kernel32.dll!CreateRemoteThread。参数面板确认:


参数 含义
hProcessRCX 0x1F8 与前面所有步骤完全一致
lpThreadAttributesRDX 0 NULL
dwStackSizeR8 0 默认栈大小
lpStartAddressR9 0x0000025300140000 恰好等于之前 VirtualAllocEx 申请到的页基址,也就是那段 stub 代码的起始地址
lpParameter[RSP+28] 0x0000025300140028 恰好等于 stub 代码尾部的那个偏移,也就是 DLL 路径字符串的地址

两个关键参数 lpStartAddress/lpParameter 分别与前两次 WriteProcessMemory 写入的两块数据地址一个不差地对上至此整条注入链条已完整实测闭环,无需再猜测:

CreateProcessW(游戏.exe, dwCreationFlags=0)              → 直接拿到 hProcess=0x1F8(不需 OpenProcess)
VirtualAllocEx(0x1F8, NULL, 0x1000, MEM_COMMIT|RESERVE, PAGE_EXECUTE_READWRITE)
                                                          → 返回 0x25300140000
WriteProcessMemory(0x1F8, 0x25300140000, stub代码, 0x28)  → 写入 LoadLibraryW+二次调用 stub
WriteProcessMemory(0x1F8, 0x25300140028, DLL路径字符串, 0x52)
                                                          → 写入 L"X:\Sample\payload.dll"
CreateRemoteThread(0x1F8, NULL, 0, lpStartAddress=0x25300140000, lpParameter=0x25300140028)
                                                          → 远程启动 stub,成功实现 DLL 注入

这是一个竞速注入(race injection)+ 自定义二段式远程线程 stub的完整 DLL 注入实现,比教科书式的 CreateRemoteThread(LoadLibraryW, pathPtr) 多一层自定义 stub 包装(可以在 LoadLibraryW 成功后继续链式调用到 DLL 自己的初始化函数,无需第二次远程线程),并且全程只用到 4 个 APICreateProcessW/VirtualAllocEx/WriteProcessMemory/CreateRemoteThread),没有 OpenProcess、没有 SetThreadContext、没有挂起/恢复流程,相对精简但设计精巧。


0x0B 总结

把整个分析过程串起来看,这个样本呈现出几个比较有意思的技术点,按重要性排一下:

  1. 私有壳 + 静态分析基本"看不见"任何有效代码.text/.rdata/.data/.pdata 全部 RawSize=0,真正的程序体和注入逻辑完全依赖运行时由 TLS 回调解包出来,静态字符串扫描也扫不出任何有效线索。这种设计对纯静态分析工具(尤其是自动化查杀/沙箱静态扫描)基本免疫,必须上动态调试才能看到真相。
  2. 导入表裁剪到极限:每个系统 DLL 只保留 1 个"占位"导入函数,真正用到的进程操作类 API 全部隐藏在运行时手动解析里,是专门针对"API 调用扫描"类检测设计的。
  3. 动态实测推翻了静态推断:最初基于"导入表裁剪 + 无法解释的注入手法"合理怀疑是挂起注入,但实测 dwCreationFlags=0 直接证伪,修正为竞速注入。这也是本帖想强调的方法论:任何基于静态证据的推断都只是假设,必须有动态实证才能定论
  4. 省略 OpenProcess 是一个精致的隐蔽技巧:因为句柄本来就是 CreateProcessW 自己创建时拿到的,没有必要再调用一次,少一个 API 调用就少一个可能被行为规则捕捉的点。
  5. 自定义二段式远程线程 stub:43 字节的手写 shellcode,先调 LoadLibraryW、检查成功后再链式调用 DLL 自己的初始化函数,一次远程线程完成"加载+初始化",比经典的 CreateRemoteThread(LoadLibraryW, path) 更进一步,也更难被简单的行为规则识别。
  6. 调试方法论上的技巧:全程只调试注入工具自身进程,从未接触受驱动保护的游戏客户端本体,只要盯住它对外调用的 Win32 API 导出函数入口,就能把整条"黑盒"的注入链条从头到尾实锤下来,不需要碰目标进程一根毛。这个思路对分析任何"外部注入型"工具都适用。

后续如果时间允许,会继续把 payload.dll 载荷段的静态解包逻辑(4 个 TLS 回调之间的调用关系、以及手写的 PEB 遍历式 API 定位实现)作为进阶内容补充上来,欢迎同好一起交流讨论。

(完)


[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!

收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回