1. 为什么“不动代码”反而是逆向分析里最被低估的能力
很多人一提到游戏逆向,脑子里第一反应就是上调试器、下断点、改内存、写注入。这套打法确实直接,但代价也大:你一旦开始动态调试,就等于把自己暴露在对方的检测面里,而且你看到的往往只是某一次运行的瞬时状态,程序稍微换个分支、换个地图、换个版本,之前的结论可能就全废了。我做了这么多年逆向,越来越觉得真正拉开水平差距的,不是谁更会下断点,而是谁能在完全不运行、不修改目标程序的前提下,把它的结构、调用关系、数据流向摸清楚。这就是被动分析技术的价值所在。
所谓被动分析,核心就一句话:只读不写,只看不改。你拿到的是一个静态的二进制文件或者一堆资源文件,你要靠反汇编、反编译、符号信息、字符串、交叉引用这些“死数据”,还原出程序活着时候的行为逻辑。它解决的是“我还没准备好动手,但我得先知道这东西长什么样”的问题。适合谁看?适合已经会基本反汇编、但总觉得自己分析得零散、抓不住主线的人;也适合那些想从“脚本小子”往“能独立啃一个陌生程序”进阶的从业者。
这篇内容我打算把被动分析拆成几个真正能落地的能力来讲:怎么从入口和字符串快速建立第一印象,怎么用交叉引用和调用关系把散落的函数串成一张网,怎么通过数据流分析判断一个值从哪来到哪去,以及这些手段在游戏这种高度模块化、强混淆的场景下有哪些坑。全程不涉及任何动态注入和内存修改,纯静态视角,你可以放心跟着复现。
2. 被动分析的第一现场:从入口点和字符串建立程序画像
2.1 入口点不是“main”,而是你理解模块划分的起点
新手最容易犯的错,就是拿到一个可执行文件就直奔所谓的 main 函数。游戏这类程序,尤其是带引擎的,真正的业务逻辑往往不在入口点附近。入口点(Entry Point)更多是运行时初始化、CRT 启动、加载器这些东西。你在这里花太多时间,收益很低。
我的习惯是:先把入口点当成一个“地图坐标”,而不是“答案”。用反汇编器打开之后,先看入口点调用了哪些初始化函数,这些函数里有没有明显的模块注册、类工厂注册、脚本引擎初始化。游戏程序通常会在启动阶段把各个子系统挂上去,比如渲染、音频、网络、资源管理。你在入口附近看到的这些注册调用,其实就是整个程序的骨架索引。
举个常见的模式:入口点会调用一个InitializeEngine之类的函数,里面按顺序调用RegisterRenderer、RegisterAudio、RegisterScriptSystem。这些注册函数内部往往会把一堆函数指针塞进全局表。你顺着这张全局表往下看,就能知道这个引擎大概有哪些能力模块。这一步不需要你理解每个函数的具体实现,只需要建立“这个程序由哪些大块组成”的认知。
提示:不要急着给函数重命名。被动分析阶段,命名太早会把你带偏。先用
sub_XXXX记着,等交叉引用和调用关系清晰了再统一命名,效率更高。
2.2 字符串是静态分析里性价比最高的线索
如果说入口点给你的是骨架,那字符串给你的就是血肉。游戏程序里字符串特别多,而且往往非常“诚实”:错误提示、日志格式、配置键名、资源路径、脚本命令、UI 文本,这些东西很难被完全抹掉。你甚至可以通过字符串的语言和用词,判断这个程序用了哪套引擎、哪个版本的中间件。
我一般会按类别筛字符串,而不是一股脑全看。分类方式大致是这样:
| 字符串类型 | 典型内容 | 能推断出的信息 |
|---|---|---|
| 资源路径 | .pak、.bundle、assets/ | 资源加载方式和打包格式 |
| 脚本相关 | .lua、require、RegisterFunction | 是否内嵌脚本引擎 |
| 网络协议 | http、socket、packet | 通信方式和协议栈 |
| 错误日志 | failed to、invalid | 关键校验点和失败分支 |
| 配置键 | resolution、volume | 配置系统和存档结构 |
筛完之后,对每一类里你最感兴趣的字符串做交叉引用(Xref)。比如你看到一个assets/characters/hero.model,对它做 Xref,就能找到加载这个资源的函数,进而找到资源管理模块的入口。这比漫无目的地翻函数列表高效得多。
2.3 用“字符串密度”快速定位核心逻辑区
还有一个很实用的技巧:观察字符串在地址空间里的分布密度。游戏程序里,核心逻辑区往往字符串密集,因为那里有大量日志、断言、错误处理。而纯算法区、数学库区字符串很少。你把字符串按地址排序,看哪些区段扎堆,基本就能圈出“业务逻辑重镇”。
我实测下来,这个方法在带符号剥离的程序上尤其好用。符号被剥了没关系,字符串还在。你甚至可以通过字符串的相邻关系,推断出函数的边界:两个字符串如果紧挨着出现在同一个数据段,它们很可能被同一个函数引用。再结合 Xref,就能把函数边界大致框出来。
这一步的产出应该是一张“热点地图”:哪几个地址区间是资源加载、哪几个是脚本绑定、哪几个是网络处理。有了这张图,后面做调用关系分析就有了方向,不会像无头苍蝇一样乱撞。
3. 交叉引用与调用关系:把散落的函数串成一张可推理的网
3.1 交叉引用不只是“谁调用了它”
很多人对交叉引用的理解停留在“找到调用者”这个层面,这其实只用了它一半的价值。交叉引用真正强大的地方,在于它能帮你建立双向的因果链。一个函数被谁调用(callers)告诉你它的上下文,它调用了谁(callees)告诉你它的职责。两者结合,你才能判断这个函数在程序里的角色。
举个实际场景:你通过字符串找到了一个疑似“伤害计算”的函数。对它做 caller Xref,发现它被三个地方调用:一个是玩家攻击逻辑,一个是怪物攻击逻辑,还有一个是陷阱触发逻辑。再对它做 callee Xref,发现它内部调用了“读取属性”“应用抗性”“计算暴击”。这时候你就能确定,这是一个通用的伤害结算函数,而不是某个特定攻击的专属逻辑。这个判断在被动分析阶段非常关键,因为它决定了你后续要不要深入这个函数。
3.2 调用图不是画出来好看的,是用来做剪枝的
游戏程序动辄几万个函数,你不可能每个都看。调用图(Call Graph)的真正用途是剪枝:帮你排除掉那些明显不相关的分支,把精力集中在核心路径上。
我的做法是先从几个已知的“锚点”出发,比如前面通过字符串定位到的资源加载函数、脚本注册函数。然后以这些锚点为根,向上向下各展开两到三层调用关系。展开的时候有个原则:优先跟踪那些被多个地方调用的函数,因为复用度高通常意味着它是基础设施或者核心逻辑;暂时跳过那些只被调用一次、且内部全是数学运算的函数,那多半是工具函数。
这里有个经验值:一个函数如果被调用超过 5 次,且调用者分布在不同的模块里,它大概率是某个通用服务。一个函数如果只被调用一次,且调用者就在它旁边,那它可能只是个内联展开或者编译器生成的辅助函数,优先级可以放低。
3.3 虚函数表和回调:游戏逆向里绕不开的“间接层”
游戏程序大量使用面向对象和回调机制,这就导致很多调用关系不是直接的call指令,而是通过虚函数表(vtable)或者函数指针间接调用。被动分析里,这类间接调用是最容易断链的地方。
处理 vtable 的思路是:先找到类的构造函数,因为构造函数里会把各个虚函数的地址写进 vtable。你通过构造函数就能还原出这个类的虚函数表布局。然后当你在别处看到call [eax+0x1C]这种间接调用时,只要知道eax是哪个类的实例,就能反推出它调的是哪个虚函数。
回调函数更麻烦一点,因为回调地址往往是在运行时动态注册的。但在静态视角下,你可以通过“注册函数”来反推:找到那个把函数指针存进全局表或者结构体的地方,那里就是回调注册点。注册点附近的代码通常会告诉你这个回调的触发条件。我一般会把所有“把函数地址写进某个结构体”的位置都标记出来,这些就是回调注册的候选点。
注意:间接调用断链是常态,不要指望一次就能全部还原。被动分析的产出是“高置信度的部分”加上“待验证的假设”,而不是一张完美的图。接受这一点,你的心态会好很多。
3.4 用调用深度和扇出度识别“上帝函数”
在调用图里,有一类函数特别值得关注:它们被很多地方调用(高扇入),同时自己又调用很多函数(高扇出)。这类函数往往是“上帝函数”,也就是核心调度或者核心状态机。游戏里的主循环、帧更新、场景切换,通常都是这种形态。
识别方法很简单:统计每个函数的调用者数量和被调用数量,画一个二维分布。落在右上角(高扇入+高扇出)的,就是你要重点啃的对象。啃这类函数的时候,不要一上来就看细节,先看它的整体结构:它按什么顺序调用子函数,有没有明显的状态判断分支。把它的控制流骨架理出来,比逐行读汇编有价值得多。
4. 数据流分析:判断一个值“从哪来、到哪去、被谁改过”
4.1 数据流分析解决的是“这个值可信吗”
调用关系告诉你函数之间怎么联系,数据流告诉你数据在函数之间怎么流动。在游戏逆向里,数据流分析最典型的应用就是:判断某个关键值(比如血量、金币、坐标)在内存里的完整生命周期。它从哪个函数产生,经过哪些函数传递,在哪些地方被修改,最终被谁读取。搞清楚这条链,你才能知道改哪里是有效的、改哪里会被校验覆盖。
被动分析做数据流,靠的是追踪寄存器和栈槽的传递。比如你在某个函数里看到mov [ebp-0x10], eax,然后这个栈槽在后面被传给另一个函数,你就可以顺着这个栈槽往下追。现代反编译器(如 IDA 的 Hex-Rays、Ghidra 的反编译器)会把这种栈槽传递还原成局部变量,读起来会舒服很多。
4.2 从“写入点”反推数据来源
一个很实用的切入方式是:先找到关键数据的写入点,然后反推它的来源。比如你想知道玩家血量是怎么算出来的,就先找到“把血量写进玩家结构体”的那条指令,然后看写入的值是从哪个寄存器来的,那个寄存器又是从哪个函数返回的。一层层往上追,就能追到伤害计算或者初始化逻辑。
这个过程中,你会遇到很多“中间变量”。我的建议是:只追你关心的那条链,不要被旁边的分支带跑。数据流分析最容易失控的地方,就是追着追着发现到处都是分支,最后迷失在细节里。给自己定一个规则:每追一层,问自己“这个值是不是我最终关心的那个”,如果不是,果断放弃这条分支。
4.3 数据流和调用图结合,才能定位“校验点”
单独看数据流,你能知道值怎么变;单独看调用图,你能知道函数怎么连。两者结合,你才能找到校验点:那些在数据被使用之前,对它做检查的地方。游戏里常见的校验包括范围检查、一致性检查、签名验证。
定位校验点的技巧是:在数据流链上,找那些“读取了值但似乎没有产生新值”的函数。这类函数往往就是校验函数,它们读数据、做比较、然后根据结果走不同分支。你顺着这些分支看,就能找到校验失败的处理逻辑。被动分析阶段,你不需要绕过校验,只需要知道它在哪、检查什么。这已经足够指导你后续的决策了。
4.4 处理“值被多处修改”的混乱局面
游戏里一个值经常被多处修改,比如血量会被伤害、治疗、buff、脚本同时改。这时候数据流会呈现多对一的汇聚形态。处理这种局面,我的经验是:先按修改来源分类,再按修改时机排序。
分类就是看每个写入点属于哪个模块:是战斗模块、还是脚本模块、还是网络同步模块。排序就是看这些写入点在调用图上的位置:是在主循环里每帧执行,还是只在特定事件触发。分类加排序之后,你就能画出一张“谁在什么时候改这个值”的时序表。这张表比单纯的数据流链有用得多,因为它反映了运行时的真实竞争关系。
5. 游戏场景下的特殊挑战:混淆、脚本层与资源耦合
5.1 控制流平坦化和虚假分支怎么破
游戏程序为了保护自己,经常上控制流平坦化(Control Flow Flattening)。原本清晰的分支结构被改成一个大的状态机,所有基本块都通过一个分发器跳转。被动分析遇到这种,直接读汇编会非常痛苦。
我的应对策略是:先找分发器,再还原状态变量。分发器通常是一个大的 switch 或者一串比较跳转,状态变量是一个在循环里被反复读取和更新的值。找到状态变量之后,看它每个取值对应哪个基本块,就能把平坦化后的结构重新展开成接近原始的控制流。这个过程手工做很累,但思路是清晰的:状态变量的取值序列,就是原始控制流的执行顺序。
虚假分支(Opaque Predicate)是另一类常见保护,就是那种“看起来有分支,但实际上永远走同一条”的代码。识别方法是看分支条件是否依赖常量或者不变量。如果条件里全是常量运算,那结果在编译期就确定了,这种分支就是虚假的。被动分析里,你可以直接把它当成直线代码处理,不用管那条永远不会走的分支。
5.2 脚本层是“半静态”的宝藏
很多游戏把大量业务逻辑放在脚本层(Lua、Python、自定义脚本)。脚本文件通常是明文或者简单编码的,这给被动分析开了后门。你可以直接读脚本,理解游戏规则、数值公式、UI 流程,而不需要碰底层的二进制。
脚本层和二进制层的桥梁是“绑定函数”。脚本里调用的每个原生函数,在二进制里都有一个对应的注册点。你通过脚本里的函数名,去二进制里搜字符串,就能找到绑定关系。反过来,你在二进制里看到一堆注册函数,也能推断出脚本层有哪些能力。这个双向映射建立起来之后,你的分析效率会提升一个量级。
提示:脚本层往往是版本更新最频繁的部分,而二进制层相对稳定。所以优先把二进制层的框架摸清楚,脚本层的变化可以后续增量分析。
5.3 资源耦合带来的“隐式调用关系”
游戏程序里,很多逻辑不是通过函数调用耦合的,而是通过资源引用耦合的。比如一个场景文件里引用了某个模型、某个材质、某个脚本,这些引用关系在二进制里可能只是一串字符串或者一个 ID。被动分析要把这些隐式关系也考虑进来。
做法是:把资源文件里的引用关系提取出来,和二进制里的资源加载函数做关联。比如场景文件里引用了hero.model,而二进制里有个函数专门加载.model文件,那这个函数就是场景加载链上的一环。这种关联不需要运行程序,纯静态就能建立。它帮你补全了调用图里缺失的那些“数据驱动”的边。
6. 把被动分析做成可复用的工作流
6.1 我的标准分析顺序
经过这么多项目,我总结出一个比较稳的顺序,分享出来供参考:
- 建立画像:入口点 + 字符串分类 + 字符串密度热点,产出模块地图。
- 锚点定位:从高价值字符串出发做 Xref,找到核心函数。
- 调用关系展开:以锚点为根,上下各展开两到三层,标记高扇入高扇出函数。
- 数据流追踪:选一到两个关键值,追完整生命周期,标记校验点。
- 特殊层处理:脚本层、资源层单独分析,建立和二进制层的映射。
- 假设整理:把所有推断写成假设列表,标注置信度,留给后续验证。
这个顺序的好处是,每一步都有明确产出,不会出现“分析了一天不知道干了啥”的情况。而且每一步的产出都能被下一步复用,形成积累。
6.2 工具只是辅助,关键是你问的问题
被动分析常用的工具就那么几个:反汇编器、反编译器、字符串提取、Xref 浏览器。工具本身不难学,难的是你知道该问什么问题。我见过很多人工具用得很溜,但分析出来的东西很浅,原因就是他们只是在“看”,没有在“问”。
好的问题包括:这个字符串为什么在这里?这个函数被谁调用?这个值从哪来?这个分支什么条件下会走?每问一个问题,你就往真相靠近一步。工具只是帮你回答问题的手段,不要本末倒置。
6.3 记录和命名:被动分析的复利效应
被动分析最大的痛苦是“上次分析到哪了”。我的做法是:边分析边记录,边记录边命名。每确定一个函数的职责,就给它起一个有意义的名字;每建立一条调用关系,就画一笔。这些记录在后续分析里会不断被复用,产生复利效应。
命名有个小技巧:用“动词+名词”的格式,比如LoadModel、CalcDamage、CheckChecksum。不要用Func1、Sub2这种无意义的名字。名字本身就是你对函数理解的外化,起名字的过程就是加深理解的过程。
6.4 什么时候该从被动转主动
被动分析不是万能的。当你把静态能挖的都挖了,还是有几个关键问题回答不了,比如“这个分支运行时到底走哪条”“这个值运行时到底是多少”,这时候就该考虑动态手段了。但注意,被动分析的产出会让你动态调试时更有针对性:你知道该在哪里下断点,该观察哪个值,该跟踪哪条调用链。带着假设去动态验证,比盲目下断点高效得多。
我个人的经验是:被动分析做到七成,动态验证做三成。这个比例下,你的分析既有深度,又不会陷入静态的过度推断。而且被动分析的结论更稳定,不容易被反调试手段干扰。
7. 一些踩过的坑和反直觉的经验
第一个坑:过度依赖反编译器。反编译器很强大,但它不是万能的。遇到混淆代码、异常控制流、内联汇编,反编译结果可能完全错误。我的习惯是:反编译结果只作为参考,关键逻辑一定要回到汇编确认。尤其是涉及数据流和调用约定的地方,汇编才是真相。
第二个坑:忽略编译器优化。现代编译器会把函数内联、把变量优化掉、把循环展开。你在静态视角看到的函数边界,可能和源码里的完全不一样。被动分析要接受这个现实:你分析的是编译后的产物,不是源码。理解常见的优化模式(比如尾调用优化、常量传播、死代码消除),能帮你少走很多弯路。
第三个反直觉的经验:字符串越少的地方,越可能是核心算法。因为核心算法通常不需要日志和错误提示,它们只做计算。所以当你发现某个区域几乎没有字符串,但被很多地方调用,那它很可能就是关键算法区,值得重点啃。
第四个经验:不要追求一次分析完。游戏程序太大,一次分析完是不现实的。我的做法是分阶段:第一阶段只求“知道有什么”,第二阶段求“知道大概怎么连”,第三阶段才求“知道具体怎么算”。每个阶段有每个阶段的目标,不要跳级。
最后一个心得:被动分析培养的是耐心和结构化思维。它不像动态调试那样有即时的反馈,你需要在脑子里构建模型,不断用新证据修正模型。这个过程很慢,但一旦建立起来,你对程序的理解会比动态调试深刻得多。因为动态调试看到的是“一次运行”,而被动分析看到的是“所有可能运行的结构”。这两种理解,价值是不一样的。