最近在某短视频平台看到一个「游戏功能扩展工具」的爱发电项目,第一眼吸引我的其实不是功能,而是它的本体体积——一个 exe 加一个 dll 分别做到了 40MB 和 50MB 左右,对于一个「读配置+注入+悬浮菜单」体量的小工具来说这个大小相当反常,于是就顺手拖进去看看里面到底塞了什么,结果一路从静态 PE 结构扒到动态调试实锤出完整的注入链条,中间也踩了几个坑、被动态实测推翻过一次静态推断。整个过程记录下来,一是给自己留档,二是分享一下分析思路,尤其是如何在完全不接触受驱动保护的目标进程本体的前提下,只调试样本自身进程,就把整条注入链条实锤出来的方法——这个技巧应该对分析同类"外部注入型"工具都有参考价值。
声明:游戏名称、样本文件名、本地路径均已脱敏(游戏统一称"某神",主程序称 loader.exe,DLL 称 payload.dll,本地目录统一替换为 X:\Sample\,不影响分析的完整性和准确性);本帖只实锤了「注入手法」本身——DLL 是怎么被写进游戏进程、线程是怎么被拉起来的,不涉及、也未验证该工具是否真的能规避反作弊检测,注入手法和是否被检测到是两件完全不同的事,请勿混为一谈;不提供样本、不提供成品工具,仅作技术交流与逆向学习记录。
从目录结构就能看出这是一个典型的「loader.exe 读取 config 里的游戏路径 → 注入 payload.dll → DLL 在游戏内弹出菜单(Insert 键开关)」的外部注入式游戏功能扩展工具,架构上和市面上常见的同类外挂/工具高度相似(exe 只管找路径 + 注入,dll 才是真正的 hook + GUI 菜单主体),但这层私有壳用的是什么加密/加壳方案,所以拿来做逆向研究样本。
节表:
关键异常点:.text/.rdata/.data/.pdata 四个"看起来很正常"的节,RawSize 全部是 0——磁盘文件里根本没有对应的数据,加载时全是零页。也就是说这个 exe 文件本体里没有任何一行"看起来正常"的代码,真正的程序体只存在于运行时,由 .SmE 段里的小型 loader 解压/解密后动态写入这些区域。这是一种相当彻底的**单阶段内存解包(manual-mapping 风格)**设计。
节名 .mv)、.V v、.SmE 本身就是随机乱码字符串(含右括号、空格等非常规字符),不匹配任何已知商业壳(UPX/VMProtect/Themida/ASPack/PECompact/Enigma 等)的标准节名,判断是私有/自制壳,随机命名节表就是为了躲开基于节名的壳签名检测(PEiD/DIE 一类工具最常用的招)。
两个文件的 IAT 数据目录 RVA 精确对应节表里那个"莫名其妙"的小节(.V v / .JkP),证实这两个乱码节其实就是标准 IAT,只是被壳的节表生成器随机改了名字。
导入的 DLL 列表:
先从 DLL 名单本身能看出几点判断(这几点站得住,但导入表本身还有一个更大的坑,是靠逐字节静态核对发现的、不是动态调试测出来的——见下方勘误):
最初解析每个 DLL 都只打出 1 个函数名,一开始以为是脚本 bug,逐个核对 OriginalFirstThunk 的 RVA 间距后,确认这是真实数据:
每个 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,动态实测后发现比这里推断的更简洁)。
exe 的载荷段熵值接近理论最大值,几乎可以确定经过强压缩或加密;dll 载荷段熵值略低,可能混合了未压缩/弱压缩的数据结构,或采用了不同的处理强度。
两个文件的 TLS 回调 与 声明的入口点 都落在各自的载荷段内(.SmE / .JAt),而不是常规的 .text(下面 0x0A 动态调试会直接实锤这一点)。
TLS 回调函数开头字节(两文件几乎逐字节一致,仅跳转偏移不同):
这是标准的 TLS 回调分发器模板:只在进程/线程首次建立(Reason=1)时才触发真正逻辑。由于 Windows 加载器会在调用主入口点之前先执行 TLS 回调,壳作者利用这一点把"真正的解包代码"藏在 TLS 回调里,比放在声明的 OEP 更早执行、更隐蔽(很多自动化脚本/调试器默认只在 OEP 下断点,会错过这一步)。
两个文件用几乎相同的字节模板,证明 exe 和 dll 是同一个自制壳生成器批量处理产出的,不是两个独立保护方案。
跳转目标之后的代码(dll 侧)呈现出典型的**指令级混淆(多态变形)**特征:
"保存标志位 → 用大随机立即数做运算 → 操作栈上的值 → 恢复标志位",目的是让每次生成的壳 stub 字节码都长得不一样(对抗特征码扫描),而不是简单的固定 XOR 循环。说明这个私有壳除了"加密+随机节名"外,stub 本身也带一层指令级混淆,工程量不小。
512 字节原始数据中除标准资源目录头外,直接能读出应用程序清单 XML:
requireAdministrator → exe 启动即请求 UAC 管理员权限(符合需要打开游戏进程句柄/注入 DLL 的需求)。
数据目录里 .rsrc 声明的 VirtualSize 高达 0xD02816(约13.6MB),但 RawSize 只有 0x200(512字节)。512 字节范围内能看到看起来正常的资源目录树结构,以及两个自定义命名资源类型的 UTF-16 字符串:"DAT" 和 "TXT"(而非标准 RT_ICON/RT_MANIFEST 等数字类型),暗示原始程序可能内嵌了自定义二进制数据块。但由于文件里实际只给了 512 字节,任何试图按声明的 13MB 去解析该资源树的工具都会读出边界外的垃圾数据甚至崩溃——这既可能是构建工具的 bug,也可能是故意留下的反解析陷阱。
对整份文件做了逐字节字符串扫描,检索了一批常见特征词:已知壳名(UPX/VMProtect/Themida/ASPack/Enigma...)、常见压缩库(zlib/lz4/zstd/aplib)、游戏引擎特征、hook 框架(MinHook/Detours)、反调试/虚拟机特征等。
结果:除了 Authenticode 证书链里的合法内容之外,没有任何一条命中是可读的、有意义的字符串。零星命中的"UPX"/"zlib"等词,上下文全是不可打印乱码,属于 40MB 高熵数据里长度 3~4 的子串按概率必然出现的统计噪声,不代表真的存在这些开源库或壳。
这是一条重要的反向证据:进一步印证载荷段是真正意义上的高熵加密/压缩数据,且壳自身没有在明文里留下任何自我标识信息——是一个做得比较干净的私有壳。
payload.dll 建库分析仍在进行中(.id1 分析状态位图已达 159MB,.nam 命名索引仅 16KB,符合"40MB 里 90%+ 高熵数据、IDA 自动分析效率很低"的现象)。目前函数列表只识别出 5 个函数:4 个疑似解包相关的函数 + 1 个 TLS 回调(TlsCallback_0);反汇编视图里通过交叉引用又额外挖出 TlsCallback_1~3 三个 TLS 回调入口,说明这个 DLL 实际注册了4 个 TLS 回调,比手写脚本最初只检测到的单个入口更多,值得后续深入。IDA 头部自动识别的编译器/时间戳信息与静态手写解析完全吻合,交叉验证成功。
由于本帖的核心成果(完整注入链条)已经通过下面的动态调试实测拿到,载荷段的静态解包逻辑作为后续补充内容,不影响本次结论的完整性。
这是本次分析里最有意思的部分。核心思路是:全程只用调试器挂载 loader.exe 自身,从不 attach 或修改游戏客户端进程,因为游戏受驱动级反作弊保护,直接调试它风险高、也容易被检测到;但 loader.exe 是外部注入工具本体,调试它本身没有任何限制。只要在 loader.exe 里对着 CreateProcessW/VirtualAllocEx/WriteProcessMemory/CreateRemoteThread 这些 Win32 API 的导出函数入口下断点,就能在它自己的地址空间里,完整看到它是怎么"对外操作"游戏进程的——这是分析同类外部注入工具时一个通用性很强的技巧。
文件 → 打开 选择 loader.exe,进程以调试方式创建后立即停在系统断点(ntdll.LdrpInitializeProcess 内部)。这时候目标自身代码一条指令都还没执行,TLS 回调、声明的入口点都没触发,处于最干净的初始状态。

可以看到 RIP 停在系统模块 ntdll.dll 内部,反汇编面板与寄存器面板显示的都是系统加载器代码,样本自身还未开始执行。
在命令栏依次下断点:bp CreateProcessW、bp CreateProcessA,断点面板确认两者状态均为"已启用"。此外 x64dbg 会自动挂上一个一次性的"入口断点"(对应 OptionalHeader.AddressOfEntryPoint)。
命中入口断点,RIP 停在样本自己的地址空间内,模块内偏移标注为 .SmE:$288A8B5 <OptionalHeader.AddressOfEntryPoint>。

反汇编面板顶部一行清清楚楚可见 .SmE 段名,这正是 0x02 节里静态分析出的那个熵值 7.981 的巨大载荷段。
面包屑/反汇编行进一步给出了地址闭环证据:

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

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

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

监视窗口读出 [rsp+30] = 00007FF800000000,结合反汇编确认 kernel32 包装函数用的是 mov eax, dword ptr ss:[...](32 位读取)取用这个参数,说明真正生效的只有低 32 位 = 0x00000000(高 32 位是栈上遗留的垃圾数据,不是参数本身)。
dwCreationFlags = 0,完全没有设置任何标志位,不含 CREATE_SUSPENDED(0x4)。 这直接推翻了 0x09 第 4 条基于静态证据(导入表裁剪、无法解释注入手法)做出的"挂起注入"猜想——游戏进程是被完全正常、立即运行地创建出来的,根本没有走挂起流程。
结合这一实测结果重新推断:真正的注入手法更可能是竞速注入(race injection)——即正常创建游戏进程后,尝试在反作弊驱动完成加载/接管保护之前的时间窗口内尽快完成注入动作,而不是「挂起创建 → 改上下文 → 恢复线程」的经典手法。需要特别说明:这里只是从代码行为上分析出它"试图"抢时间窗口这一设计思路,本次分析完全没有涉及游戏客户端和反作弊模块,也没有做任何"是否被检测到"的验证,所以不能得出"这个工具成功绕过了反作弊"的结论——能确认的只是"用了什么手法、什么时机去注入",至于这个时机是否真的有效、有没有被抓,是另一个完全没有验证过的问题。这个反转也是本帖标题里强调"实锤"的原因——静态推断和动态实测得出了完全不同的结论,只有动态验证才是可靠的(这里说的"动态验证",验证的对象是"注入手法本身",不是"能不能过反作弊")。
保持进程正常运行(不需要恢复挂起,因为本来就没挂起),继续追踪 OpenProcess/VirtualAllocEx/WriteProcessMemory/CreateRemoteThread/NtCreateThreadEx/QueueUserAPC 等调用。一次性追加挂上 10 个断点:

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

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

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

OpenProcess 一次也没被调用过,但 VirtualAllocEx/WriteProcessMemory 都已经拿到了有效的远程句柄 0x1F8 并成功操作了远程进程。唯一合理的解释:这个句柄就是之前 CreateProcessW 自己创建进程时,系统通过 PROCESS_INFORMATION 输出参数直接返回给它的 hProcess——因为自己就是创建者,这个句柄天然就具备对该进程的完整访问权限,根本不需要再单独调用 OpenProcess 去"重新拿一个句柄"。
这是一个很精致的小细节:少调一个 API,就少一个可能被行为规则/EDR 捕捉的观察点。分析同类样本时,"检查有没有调用某个理论上'应该'出现的 API"和"检查它是不是真的调用了"同样重要——很多时候答案是没有调,而是走了更省事的路。
移除上一个 WriteProcessMemory 断点后,再次命中 kernel32.dll!WriteProcessMemory(命中次数变成 2)。这次参数面板的本地源缓冲区直接显示出了完整可读的宽字符串:

这是本次分析最强的一条实锤证据:写入的字符串直接就是待注入 DLL 的路径。加上地址刚好接在之前 stub 代码后面,远程进程那页内存的完整布局已经拼出来了:
stub 前面部分调用 LoadLibraryW(参数指向自己后面靶的这段字符串),成功后再调用第二个固定地址。
这里有个细节值得指出:第六步 WriteProcessMemory 写入 stub 时 nSize 实际读数是 0x2B(43 字节),但这次字符串写入的起始地址只比 stub 起始地址偏移了 0x28(40 字节)——也就是说 stub 那 43 字节里的最后 3 字节,会被这次写入的 DLL 路径字符串直接覆盖掉,真正留下来、会被执行到的 stub 代码其实只有前 40 字节。反过来看,这恰好说明那 3 字节本身就是无关紧要的填充/垫料,不影响 stub 的实际执行逻辑,两次写入的 nSize/地址数值本身都没有问题,只是覆盖顺序上有这么一个小细节。
命中 kernel32.dll!CreateRemoteThread。参数面板确认:


两个关键参数 lpStartAddress/lpParameter 分别与前两次 WriteProcessMemory 写入的两块数据地址一个不差地对上,至此整条注入链条已完整实测闭环,无需再猜测:
这是一个竞速注入(race injection)+ 自定义二段式远程线程 stub的完整 DLL 注入实现,比教科书式的 CreateRemoteThread(LoadLibraryW, pathPtr) 多一层自定义 stub 包装(可以在 LoadLibraryW 成功后继续链式调用到 DLL 自己的初始化函数,无需第二次远程线程),并且全程只用到 4 个 API(CreateProcessW/VirtualAllocEx/WriteProcessMemory/CreateRemoteThread),没有 OpenProcess、没有 SetThreadContext、没有挂起/恢复流程,相对精简但设计精巧。
把整个分析过程串起来看,这个样本呈现出几个比较有意思的技术点,按重要性排一下:
后续如果时间允许,会继续把 payload.dll 载荷段的静态解包逻辑(4 个 TLS 回调之间的调用关系、以及手写的 PEB 遍历式 API 定位实现)作为进阶内容补充上来,欢迎同好一起交流讨论。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。