ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

游戏逆向分析实战:静态分析、动态调试与Frida插桩的综合应用

2026/9/5 17:17:44 拓冰建站 浏览量
游戏逆向分析实战:静态分析、动态调试与Frida插桩的综合应用 做游戏逆向这几年最常被问到的一句话是是不是把某款“神兵”练熟什么都能逆我的回答通常会让对方失望——真正卡住你的往往不是工具不够强而是你手上的信息维度太单一。静态反编译能告诉你代码里有哪些校验函数却看不到程序运行时的实际参数动态调试能让你逐条指令走却经常被反调试检测打断分析数据文件能看到字节变化却又说不清它到底经过了哪一段算法。最近我在虚拟机上复盘一个自己搭建的CTF教学样本时把静态分析、动态调试、Frida插桩、文件格式比对和脚本化批量抓取几条路线全部完整走了一遍。整个过程很有“我全都要”的味道——不是贪多而是这些路线互为补充少了哪一环答案都会缺一块。这篇文章就把这套流程拆开讲。我不会只给结论重点放在每个环节里“为什么这么做”以及“做完之后下一步该往哪走”最后附带几个让我折腾了很久的坑。如果你正准备接触游戏程序的结构分析、校验逻辑定位或存档数据格式还原这篇文章应该能帮你少走几段弯路。1. 逆向对象拆解先判断“该全都要哪些”1.1 先回答三个问题再开工不管标题多花哨动手之前我会先逼自己回答三个问题目标有没有授权边界它通过什么方式跟外界交互我这次是只做一个一次性验证还是要长期跟踪它的逻辑变化以这次复盘用的教学样本为例它是我自己搭的一个Win32小游戏程序启动后会读取一个叫level.dat的存档文件对文件内容做CRC校验然后解密出一段内部数据再把关卡名称、难度和隐藏标志显示在窗口标题里。程序里还故意埋了一个反调试线程每隔几百毫秒检查当前进程是否处于被调试状态。这种样本很适合用来演示“多路线组合”为什么比“单工具硬刚”更靠谱。这三个问题的答案会直接决定后续线路图。如果目标是一个读本地存档的单机程序那数据格式比对一定是性价比最高的切入点如果目标带网络通信那抓包和协议解析就绕不开如果你发现目标里面有明显的反调试行为那么一开始就得考虑用运行时插桩来绕过传统调试器容易被发现的场景而不是傻傻地跟反调试线程硬碰。1.2 四种常用技术路线放到一张表里看我习惯把常用的分析手段分成四类每一类的产出物和局限都不一样路线主要产出最擅长场景明显短板静态代码分析函数清单、反编译伪代码、字符串交叉引用快速理清整体逻辑定位可疑函数看不到真实运行时数据容易被混淆带偏动态调试寄存器快照、内存内容、调用栈回溯精确确认某条路径是否真的被执行速度慢容易被反调试或时序问题干扰运行时插桩批量函数参数、返回值、内存变化大规模验证调用关系捕捉运行细节必须先找准Hook点否则作用有限数据格式比对字段边界、文件结构、校验差异理解外部输入输出格式验证逻辑猜想无法覆盖未经外部输入触发的内部逻辑这张表不是让你每次把所有四列都跑一遍而是提醒你每当你发现“我看不懂这里”的时候通常不是因为你不够努力而是因为你当前使用的路线存在信息盲区。这时候应该考虑换一条路线补视角而不是继续在同一个工具里死磕。1.3 把“我全都要”翻译成一条信息闭环我真正想表达的“全都要”是指你脑子里的信息流必须是闭环的。静态分析先产生一批可疑函数和地址动态调试去验证其中哪些函数真的被调用了运行时插桩把调用的参数和返回值抓下来最终数据格式比对又用文件差异验证你从代码里得到的结论是否正确。所以我把这次流程拆成了五段静态圈地、动态验证、插桩抓参、格式还原、自动化回填。每一段的输出都是下一段的输入。这个方法同样适用于其它同类型目标分析不是特定样本的一次性玩法。2. 从“神兵利器”到完整工具链我保留了静态与动态两套主力2.1 主静态分析工具选Ghidra不是因为IDA不好网上聊逆向必提IDA这没问题。IDA Pro的反编译器确实成熟插件生态也丰富但价格摆在那里不是所有人都能随便在虚拟机里装一份。Ghidra免费开源反编译能力足够尤其是Java和Python两种脚本接口都开放这意味着你可以在分析过程中批量给函数重命名、导出行注释甚至写自定义脚本来做去混淆。对教学和中小型样本来说Ghidra完全够用。我这次分析的样本是一个不带头文件的C程序导入Ghidra后选择x86:LE:64:default语言自动分析跑完大概一两分钟。分析完成后我可以直接看到反编译伪代码足够还原函数调用关系。如果你手上只有32位样本Ghidra也能自动识别成x86:LE:32:default不用担心差异。2.2 动态调试和运行时插桩两把用途不同的螺丝刀动态调试我用x64dbg。它不是最智能的工具但足够直观能实时看寄存器、栈顶、内存dump也能在关键地址下断点后一张张翻调用栈。win32平台分析里x64dbg依然是很多人首选。但如果目标程序里插了反调试线程传统调试器从附加那一刻起就会被盯上。这时我会切换到Frida。Frida跑在目标进程内部以注入方式工作它不去改调试寄存器也不触发常见的调试端口检测更重要的是你可以用JavaScript写逻辑批量拦截函数、读取参数、修改返回值比手动下断点效率高一个量级。看这张分工表就很清晰工具用途适合做的不适合做的x64dbg传统动态调试单步跟踪、观察栈布局、少量断点批量抓取几十万次调用Frida代码插桩批量函数参数记录、内存搜索、动态修改逐指令精细调试Ghidra静态分析反编译、交叉引用、结构体定义运行时验证HxD/010 Editor格式查看文件结构观察、字节序列对比代码逻辑分析2.3 为什么给虚拟机做干净快照比工具版本更重要工具链本身不难凑齐真正让新手翻车的是环境不干净。我花了很长时间才养成一个习惯每次开始新的分析任务前先在虚拟机里做一个干净快照。本次样本跑在一个Windows 10虚拟机里宿主机是Ubuntu。Windows虚拟机负责运行样本和部分调试Linux宿主机执行Frida脚本因为我对Python端的Frida API更熟悉。有人会问直接在Windows里装Frida不行吗当然可以用frida.exe也能跑但虚拟机加宿主机双机布局有一个额外好处无论目标程序把系统搞得多么乌烟瘴气你回滚快照就恢复原状。多次动态分析和Hook实验会污染进程状态没有快照的话你会经常遇到“明明前一轮能复现这一轮却崩了”的玄学问题。3. 静态分析现场在反编译视图里圈定可疑逻辑3.1 节区权限和导入表暴露出来的线索把样本丢进Ghidra后我不会一上来就埋头看伪代码而是先看两个东西节区权限和导入表。PE文件节区属性通常能说明很多问题。如果看到一个节区同时可写可执行基本可以猜测这个程序会在运行期生成或修改代码。更合理的布局是代码节只读可执行数据节可读可写。这种判断不需要高深知识但能帮你快速定调这个样本是否值得关注动态自解密逻辑。再看导入表我注意到样本导入了GetProcAddress、LoadLibraryA和大量VirtualAlloc相关函数。这三个组合在一起通常代表程序会动态解析API地址而不是在导入表里把所有函数都列出来。VirtualAlloc出现则意味着可能临时申请一块可执行内存把解密后的代码放进去执行。静态分析到这里我已经预判后续必须用动态手段来跟踪实际内存内容。3.2 字符串窗口里先找“不该出现”的那一批在Ghidra里打开字符串窗口Search - For Strings常见的提示字符串会出现在眼前报错信息、文件名、日志文本。教学样本故意放了一些明显的线索比如字符串level.dat、checksum mismatch和Level loaded。如果你是分析正常商业游戏可能不会看到这么直白的文件名但只要程序要读取或打印什么总有字符串能暴露。操作思路很简单选中最可疑的一个字符串右键Show References To顺着交叉引用跳进函数。这次从level.dat出发我很快接触到文件读取函数。这个方法可靠的原因在于程序要处理文件就必须用某一个模块去打开路径那个路径字符串一定会被某个函数引用。3.3 顺着调用链定位校验逻辑跟踪后的调用链大致是窗口初始化函数 - 文件读取子过程 - 数据解析子过程 - 校验子过程。校验子过程的反编译结果在伪代码层看长这样int validateLevel(FILE *fp, uint8_t *buf, int len) { uint32_t crc crc32(buf, len); uint32_t expected 0x1C5F9E4A; if (crc ! expected) { return -1; } return 0; }真实程序当然没那么干净但逻辑骨架就是这样。读入文件字节流后程序对缓冲区做一次CRC然后把结果和一个硬编码常量做比较。静态分析这一步最大的收获不是得到那行伪代码而是知道了三个关键偏移量文件读取函数大约位于模块偏移0x24B0校验函数位于0x3140后面解析函数位于0x3F70。3.4 静态分析的边界你知道“可能如此”但不知道“确实如此”静态分析结束之前我心里有几个问题仍然没有答案校验函数是不是真的会被正常流程调用CRC计算用的缓冲区到底是文件中的哪一段那一段在进入校验前有没有被解密这些只有动态运行时才能验证。这也是很多新人容易陷入的误区以为反编译器把伪代码列出来就等于全逆完了。实际上伪代码只是编译器猜测结果它既不能保证变量名准确也不能告诉你调用发生的具体条件。静态分析的产出是一张“嫌疑人名单”而不是最终判决书。4. 动态调试与运行时插桩让程序自己报出关键参数4.1 加载样本之后先别急着点运行在x64dbg里加载样本后第一件事不是按F9跑起来而是检查当前模块基址、线程入口和栈布局。对Windows程序来说断点应该设置在你从静态分析里得到的模块偏移上而不是直接输入一个绝对地址。因为ASLR问题每次启动的基址都可能不同硬编码绝对地址会浪费大量时间。我会手算一下期望的绝对地址当前模块基址加上函数RVA。然后用x64dbg的内存布局窗口确认这个地址属于哪个模块再下断点。断点类型优先选择F2软件断点如果怀疑目标有断点检测可以换成硬件断点。4.2 程序一运行就退出的反直觉现象第一次尝试时我按下运行键程序窗口一闪就没了。如果你也走到这一步先别急着怀疑样本有问题。更常见的原因是反调试代码在启动早期检查了PEB.BeingDebugged标志位。想要判断是不是反调试我通常会把同样的样本在不附加调试器的情况下直接启动一次看它能否正常运行。如果正常运行附加调试器后就退出那基本确认目标存在环境感知行为。这种现象值得记录下来但它的价值不在“绕过”而在于告诉你传统调试器在这里会遇到额外的阻碍后面可以调整思路继续分析。4.3 从I/O断点切回目标函数与其在反调试检测逻辑里跟它纠缠我选择先绕开冲突区从文件读取下断点。在x64dbg里对ReadFile或CreateFileW这类系统API下断点等断点命中后查看栈回溯就能看到究竟是模块中的哪一层代码发起了这次读取。这种策略非常有效因为它把问题从“反调试在哪”转化成“我要看的那一段数据是打哪进来的”。从ReadFile返回后断点会停在系统库里面此时单步步出到模块代码区就能看到紧跟着的文件处理逻辑。这个位置就是静态分析时看到的读取函数区域。找到它之后我再把断点重新设在0x3140的校验函数入口等第二次命中断点。4.4 用Frida记录第二个参数里的缓冲区内容传统断点能停住程序但如果你想知道连续十次调用各自传入什么参数手动单步会非常累。这时Frida的Interceptor.attach就派上用场了。const mod Process.getModuleByName(sample_course.exe); Interceptor.attach(mod.base.add(0x3140), { onEnter(args) { // 假设校验函数原型是 int check(int uid, int len, uint8_t *buffer) const uid args[0].toInt32(); const len args[1].toInt32(); const buffer args[2]; console.log([check] uid${uid} len${len}); if (buffer) { console.log(hexdump(buffer, { length: Math.min(len, 64) })); } }, onLeave(retval) { console.log([check] return${retval.toInt32()}); } });为什么要用模块基址加偏移因为ASLR的存在每次启动Frida计算出来的基址不同但模块基址加上编译期偏移是稳定的。Process.getModuleByName会返回当前基址加偏移就能定位到同一个函数。Windows x64下前四个参数依次放在rcx、rdx、r8、r9里Frida的args[0]到args[3]对应这四个寄存器。像我这里的检查函数args[2]就对应缓冲区地址hexdump能看到校验前缓冲区的实际内容。透过它我第一次真正看到level.dat被读入后的原始字节——它并不是文件里的字面内容而是已经过了一段解密处理。4.5 插桩得到的参数反过来修正静态猜测用Frida观察到的调用计数值和缓冲区差异非常有用。原本静态分析时我猜测校验函数只会被调用一次实测数据打脸——它在程序启动期间被调用了三次。前两次传入的缓冲区长度只有8字节第三次才是完整文件内容。这提示程序可能先用短数据做某种初始化再针对正式数据做校验。这种“预测被实测推翻”的时刻正是多路线组合的价值所在。单纯靠看代码很能总结出这个执行模式单纯靠断点也很难快速统计全部调用次数但一个插桩脚本轻松解决了。5. 存档结构与校验数据的还原路径5.1 为什么数据文件是很好的分析入口程序与外界交互最直接的方式就是读写文件。对一个单机游戏来说存档文件就是可控输入你可以随意修改它、重复运行程序、观察程序反应的差异这种交互式分析比静态猜结构高效得多。本次样本的存档文件叫level.dat用HxD打开后是一串看不出规律的字节。理论上你直接研究这些字节也能猜出一些结构但最靠谱的方式永远是在代码里找到“它把哪些字节解释成什么字段”。代码是权威的文件只是载体。5.2 初始字节扫描找魔数、长度和可见字符串我会先做一次最笨的观察。扫一眼文件头如果有“LVL1”这类的可读ASCII那就是典型的魔数标识。教学样本里刚好有这么一个魔数并且我能从Ghidra的代码中看到程序用memcmp把它和硬编码字符串比较。这种明文的标识在结构分析中相当于地标能帮你锚定后面字段的相对位置。初始文件里我看到的结构大概是这样的偏移长度字段说明0x004魔数固定字符串LVL10x042版本号当前值为01 000x062头长度当前值为0C 000x084数据区长度小端存储0x0CN加密数据需要运行时解密尾部4CRC32对整个数据区校验不是说程序代码里一定有注释标注这些字段而是说通过Ghidra中的解引用位置和Frida打印出来的缓冲区长度你可以反推出这些字节代表什么。5.3 用动态Hook确认“字段在哪一段被解析”光看文件结构还不够需要确认字段从文件到内存后发生了什么事。方法是在读取函数返回后、解析函数执行前设置Hook点打印目标地址的内容。比如我在模块偏移0x12A0找到了一个看起来像“解析关卡名”的函数用Frida在它入口打印arg0指向的内存结果发现那个缓冲区解析出来以后是16字节的关卡名字符串“MyCustomLevel”。这说明这个函数确实把文件中的一段字节转换成了可读字符串。进一步修改存档文件里的关卡名位置再运行程序窗口标题里的名字随之变化。这种“改动一个字节观察输出变化”的验证方式能够把代码地址和数据字段精确对应起来。5.4 它“看起来像加密”的循环很多其实是简单混淆数据区的字节一开始看着像加密但代码里其实是一个逐字节的异或循环。异或这类简单变换在反编译结果里特别容易被识别出来如果你看到某个循环变量对缓冲区每个字节执行buf[i] ^ key这种操作那基本就是最简单的混淆方式。识别它之后可以在Frida里把解密后的明文结构打印出来后面进一步观看函数就会省力很多。我的心得是对大多数教学样本和轻度保护程序而言不要一开始就脑补“这肯定是AES”。先假设它是弱混淆用代码逻辑去验证猜错的成本远低于站在原地瞎猜的成本。6. 自动化脚本从手动点断点到成批出结果6.1 手里只有一个样本时自动化是不是过度设计一次只分析一个函数的话手动断点完全够用不需要写任何脚本。但现实是一个程序会调用几十个甚至上百个类似函数你要能从这些调用里统计出谁先谁后、谁调用了谁、传入的参数有什么规律手动操作会慢到让人崩溃。这一点在游戏程序里尤其明显。UI一刷新、一帧渲染、一次状态同步都会触发大量函数调用靠人眼去找规律基本上不现实。只要你有过一次“从几千行日志里筛出真正变化的那一行”的经历就会理解脚本化的价值。6.2 一个能跑通的最小Frida脚本骨架我不喜欢一上来就写几百行复杂框架。最有效的做法是先写一个最小脚本确认能hook到目标然后再逐步叠加功能。一个能用的骨架长这样# trace.py import frida import sys import time def on_message(message, data): if message[type] send: print(message[payload]) else: print(message) session frida.attach(sample_course.exe) with open(trace.js, r, encodingutf-8) as f: source f.read() script session.create_script(source) script.on(message, on_message) script.load() while True: time.sleep(1)// trace.js const mod Process.getModuleByName(sample_course.exe); const targetAddresses [0x24B0, 0x3140, 0x3F70]; targetAddresses.forEach((rva, index) { Interceptor.attach(mod.base.add(rva), { onEnter(args) { send({ type: enter, index: index, address: rva }); }, onLeave(retval) { send({ type: leave, index: index, ret: retval.toInt32() }); } }); });这个骨架做的事情很简单向目标进程注入脚本在三个预先定位的RVA地址处做hook每次进出函数都向Python端发送一条消息。消息里携带的信息包括当前是进入还是离开、是哪个函数、返回值是多少。跑几分钟就能得到一份很可观的调用时序表。6.3 热点函数加采样比逐个全量记录更实际全量记录所有调用会产生大量日志分析起来并不方便。更好的做法是给Hook加上采样条件。例如只记录每第1000次调用时的参数或者只记录特定参数条件下的调用。let count 0; Interceptor.attach(mod.base.add(0x3140), { onEnter(args) { count; if (count % 1000 0) { const len args[1].toInt32(); send({ type: sample, count: count, len: len, rva: 0x3140 }); } } });这种采样方案能显著降低日志噪音同时保留对异常频率的感知。当你看到crc检查函数在几秒钟内被调用几十万次这本身就是关键信息说明它可能处在一个高频渲染路径里而不是只在文件读取后触发一次。6.4 脚本里的地址数据也能回填到静态分析日志里记录到的是模块RVA不是运行时基址。保存静态RVA的好处是能把它直接拿回Ghidra里定位。我跑完Frida后会整理函数进出记录再手动给Ghidra反编译窗口里的函数重命名比如FUN_00003140被我改成了check_level_crc界面一下就清楚许多。这种回填工作虽然枯燥但它是整个分析中最能提升效率的动作。名字一旦带语义后面看调用关系就跟看伪代码一样省力。再强调一次这个过程中最有用的不是某个脚本本身而是让“动态日志里的RVA”和“静态反编译里的函数”能够互相索引。7. 避坑记录这几个问题浪费了我不少时间7.1 ASLR让每次基址都不同别再硬编码绝对地址犯过的错误是第一次下断点时看到模块基址是0x00007FF6A3C20000校验函数地址是0x00007FF6A3C23140于是直接把0x00007FF6A3C23140填进断点窗口。重启进程后模块基址变了断点废掉程序直接跑飞。解决办法很简单把地址换算成模块偏移再操作。无论静态分析还是动态调试记录偏移永远比记录绝对地址可靠。模块基址每次启动都可能变偏移不变。7.2 Frida附加不上进程先检查架构和权限用Frida跑脚本时经常遇到unable to attach to process。很多人第一反应是脚本写错了其实多半是架构不匹配。如果目标是64位程序Frida-server也得是64位版本如果目标是32位却用64位Frida-server去连同样会失败。另一个容易忽略的问题是权限。许多程序会以更高权限运行或设置进程访问控制普通用户身份的Frida根本拿不到句柄。遇到这种直接换成管理员/root权限启动Frida问题立刻消失。7.3 在Hook回调里做重I/O会导致卡死使用Frida时最容易出的玄学问题就是卡死。原因经常是在onEnter回调函数里做了太多事情比如把参数直接写入磁盘、启动一个子进程、或者执行了耗时极长的同步操作。这些动作在插桩回调里会严重干扰目标程序的正常执行流程。解决建议是回调里只做数据处理和变量记录把真正的I/O操作挪到另一个线程或交给Python端处理。这个坑我只踩过一次就记住了。7.4 断点下在静态偏移上运行后却被“覆盖”有一次我在样本启动早期就给0x3F70下了F2断点结果程序崩溃。回溯之后发现那个地址所在的内存区域在运行一段时间后会被重新映射。程序的逻辑不是“常驻静态代码”而是先把一段加过密的数据解密到新申请的内存里再把指令写进去执行。我提前下断点等于在一块即将废弃的旧内存上打孔。这个问题没有神奇解法只能靠流程兜住先Hook内存申请类函数记录被注册为可执行的新内存区域等代码在目标地址落定后再重新设置断点。换句话讲动态分析面对“执行后才生成的代码”需要配合内存申请点来寻找合适的Hook时机。这套“全都要”的组合拳打完我最直接的感受是逆向分析不是一场工具数量比赛。真正有用的是你面对同一个疑问时能不能从多条路线里找出互相印证的那条。遇到代码看不太懂先跳到动态观察它的行为数据看不出结构再回头重新看反编译代码找到那个解析入口要是哪个函数死活hook不上那就溯源看它到底是不是动态生成出来的。全程下来你会发现静态分析负责“猜”动态验证负责“证”插桩负责“问”格式比对负责“算