ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

dnSpy实战:Unity游戏反编译与IL修改完整指南

dnSpy实战:Unity游戏反编译与IL修改完整指南 简介Unity 开发者或游戏引擎研究人员常需要查看程序集内部实现dnSpy 正是一款可对 .NET 程序集进行反编译、调试与代码修改的实用工具适用于 Unity 源码分析、游戏逻辑逆向和第三方库内部逻辑排查等场景。资源将完整工具打包为 rar 压缩包共 1736 个文件其中以 1583 个 dll 动态链接库为主配合 76 个 pdb 调试符号便于在反编译结果中定位类、方法与属性的原始调用关系同时包含 json、xml、txt 等配置说明文件、8 个 dntheme 主题样式文件以及 6 个 exe 可执行入口整体体积约 134MB目录结构清晰适合按需取用。已有 3943 人浏览学习适合具备一定 C#/.NET 基础、想深入理解 Unity 程序集结构或排查热更代码逻辑的读者。压缩包提供的完整可执行程序和依赖文件可直接展开使用无需再四处搜寻配套库借助调试符号和主题/配置文件还能进一步调整反编译界面与解析参数提升阅读与分析效率。1. Unity 反编译绕不开 dnSpy一个改数值的真实场景某单机卡牌游戏里A 同学想把每日签到奖励从 50 金币改成 5000金币字段却是加密存储的内存修改器完全派不上用场。我接手后装好 dnSpy打开游戏目录下的 Assembly-CSharp.dll搜索 DailyReward定位到限额逻辑改了三个 IL 指令保存重开游戏直接生效。整个过程不到十分钟。这个场景在 Unity 的 Mono 打包方式下非常典型C# 逻辑集中编译在一个托管 dll 里dnSpy 就是当前 Windows 上最顺手的 Unity 反编译代码工具能看、能搜、能改 IL 还能重新保存。如果你做单机 Mod、自研代码审查、逆向学习或游戏安全测试dnSpy 几乎是一把万能钥匙。下文会从 dnSpy 能处理的 Unity 程序集开始把拆包、定位、修改、保存的完整流程走一遍再把混淆、校验、IL2CPP 这些实际会遇到的坑也说透。2. 先搞清程序集在哪dnSpy 能反编译什么、不能反编译什么2.1 Unity 的 C# 去哪了Mono 与 IL2CPP 两条打包路径Unity 项目在打包时所有 C# 脚本不会直接变成机器码而是先编译成中间语言IL。真正落地的方式有两种这决定了 dnSpy 到底有没有用。第一种是 Mono 模式。IL 会被打包进一个托管程序集最常见的名字就是 Assembly-CSharp.dll放在安装目录的*_Data/Managed/下Android 平台在 APK 的assets/bin/Data/Managed/。这个 dll 是标准的 .NET 程序集dnSpy 可以直接打开、反编译、修改、保存整个过程跟处理一个普通 .NET 桌面程序没有区别。第二种是 IL2CPP 模式。IL 会先被转换成 C 代码再交叉编译成原生机器码最终跑到一个叫libil2cpp.so或GameAssembly.dll的原生库里。这种情况下托管 dll 基本不存在了至少不存在一个拿着就能改的 Assembly-CSharp.dll。dnSpy 对这个原生库无能为力得换 Il2CppDumper 配 IDA 那套流程。特性Mono 打包IL2CPP 打包产物形态Assembly-CSharp.dll托管 ILlibil2cpp.so / GameAssembly.dll原生代码dnSpy 直接反编译可以不可以修改后保存支持不支持主流使用范围中小型游戏、单机、老项目中大型游戏、性能敏感、防修改诉求强定位逻辑难度低搜字符串即可中高需要符号导出和原生逆向我在实际项目里见过太多人拿到一个 IL2CPP 包在Managed/目录里找不到 dll就以为加密了或文件被隐藏了。其实只是打包方式不同。判断方法很直接先看 APK 或游戏目录里有没有Managed/Assembly-CSharp.dll没有就找libil2cpp.so或GameAssembly.dll有原生库大概率就是 IL2CPP。2.2 dnSpy 的程序集树、搜索窗口与反编译代码视图dnSpy 的界面乍一看像个改版 Visual Studio但核心功能集中在三个地方熟悉之后就发现它比 IDE 轻得多。程序集树在左侧加载 dll 后会把所有命名空间、类、方法按层级展开和 VS 的类视图很接近。双击任意方法中间代码窗口会显示反编译后的 C# 代码代码窗口顶部有几个 Tab可以在 C#、IL、元数据视图之间切换这个切换能力是 dnSpy 最值钱的地方——改 IL 指令时随时回到 C# 视图确认原始逻辑。搜索窗口是定位逻辑的主战场快捷键CtrlShiftK。它支持按字符串、方法名、字段名、类名搜索默认是全程序集范围。Unity 游戏里业务逻辑的命名基本上表意清晰比如 DailyReward、AchievementManager、StoreController搜一个核心类名整个功能模块的入口就出来了。还有一个容易被忽略但很好用的功能右上角的“分析”Analyze。选中某个方法或字段右键选“分析”dnSpy 会列出谁调用了它、它调用了谁、从哪些方法引用。这个功能在改数值时特别关键因为一个数值往往不只在一个地方被读取你得确认所有读取点都改到位不然改了奖励方法没改领取方法游戏里表现依然是旧逻辑。能力边界也得说清楚。dnSpy 是反编译器不是还原器。它能还原结构和逻辑但丢掉的注释、局部变量命名、代码块原始顺序都回不来。混淆过的程序集还原度会进一步下降。加壳保护例如把 dll 加密后运行时再解密的程序集dnSpy 直接打不开。这些不是工具的问题是 .NET 元数据本身被破坏或加密后的必然结果。3. 从安装包到反编译代码完整提取与定位流程3.1 提取 Assembly-CSharp.dllAndroid 与 PC 两种最常用路径先说要拆的对象是什么。Android 平台的 APK 本质是个 zipMono 打包的 Unity 游戏dll 文件通常位于assets/bin/Data/Managed/。PC 版则直接在游戏安装目录下GameName_Data/Managed/Assembly-CSharp.dll。iOS 平台基本见不到 Mono 包这里不展开说。Android 下最省事的方式是直接用命令行解包不必先装 apktool。用 unzip 只抽 dll 反而更高效# 解包 APK只取出 Managed 目录下的全部文件 unzip -o game.apk -d apk_out assets/bin/Data/Managed/* # 确认关键 dll 是否到位 ls -lh apk_out/assets/bin/Data/Managed/这里有两个容易踩的坑。第一个是 Unity 版本不同路径会有差异老版本可能放在assets/bin/Data/Managed/有些定制 SDK 会把 dll 挪到其他 assets 子目录。找不到就先全量解包再搜文件名。第二个是 APK 里有时候存在不止一个 Assembly-CSharp.dll比如多国语言拆分包或分包加载这种时候要根据后续行为判断到底哪个在运行通常主包的优先。PC 版更简单直接进目录复制# PC 版 Unity 游戏的典型目录结构 ls 游戏安装目录/GameName_Data/Managed/Assembly-CSharp.dll # 别直接改原文件先复制一份工作副本 cp 游戏安装目录/GameName_Data/Managed/Assembly-CSharp.dll ./work.dll为什么强调复制一份工作副本因为后面用 dnSpy 修改并保存后文件大小和内容都会变化尤其是多改几处之后万一改崩了原文件可以随时恢复。另外Unity 启动时会加载 dll如果原文件被占用dnSpy 的保存操作会直接报错这个放到避坑章节再细说。WebGL 平台的做法略有差别。Unity WebGL 会把 dll 压缩成.gz文件放在输出目录里需要先解压拿到原始 dll 再用 dnSpy 打开。解压后通常能正常反编译但 WebGL 端很多游戏会做数据校验修改后不一定能直接跑起来。3.2 用 dnSpy 打开程序集并反编译搜索字符串定位目标逻辑拿到 dll 之后打开 dnSpy把 work.dll 直接拖进窗口或者在文件菜单里选择打开。程序集加载完左侧树会列出所有命名空间。这时候不建议逐个点开类名找逻辑正确姿势是直接搜索。CtrlShiftK打开搜索框输入目标逻辑的关键字。这里有个经验优先搜英文标识符而不是中文界面文案。Unity 游戏界面上的“每日奖励”是字符串资源往往存在单独的本地化表里搜中文很容易一无所获。而方法名和类名通常是英文且语义稳定搜 DailyReward 比搜“每日”靠谱得多。定位到目标类之后先看它的字段和属性再看方法。我习惯先搜一个跟业务相关的字符串比如奖励描述文本然后用分析功能反向看它是被哪个方法引用的。文本最终一定被某个方法读取抓到那个方法往上翻就是完整的业务入口。3.3 可抄作业的最小复现定位一个每日签到限额逻辑用一个虚拟案例串一遍完整流程。假设游戏里有类DailyRewardManager有一个方法GetRewardCount()控制每日可领取次数上限 10 次超过直接返回 0。我们要把这个上限放开。第一步在 dnSpy 中按CtrlShiftK搜DailyReward找到类并展开。第二步双击GetRewardCount方法看反编译代码// dnSpy 反编译出的代码命名和逻辑基本恢复 public int GetRewardCount() { if (this.currentCount 10) { return 0; } return this.currentCount; }第三步切到 IL 视图找到关键的比较指令。切 Tab 的操作在代码窗口底部点IL就能看到完整 IL 指令序列。// IL 视图里的核心片段 L_0000: ldarg.0 L_0001: ldfld int32 DailyRewardManager::currentCount L_0006: ldc.i4.s 10 L_0008: blt L_001d L_000a: ldc.i4.0 L_000b: retldc.i4.s 10是把常量 10 压入栈blt是“小于则跳转”。小于 10 跳转到第 L_001d 处返回真实数量不小于则落到ldc.i4.0返回 0。这句话翻译成人话就是if (currentCount 10) return currentCount; return 0;。这个位置就是修改点。把这个方法改成无条件返回 currentCount思路是把比较逻辑跳过去。具体操作见下一章这里先记住一条方法论在 IL 层面改逻辑核心就是改两类指令一类是“压入栈的数字常量”另一类是“条件跳转的方向”。理解了这两类Unity 反编译修改的基本功就算到手了。4. 用 dnSpy 修改 Unity 代码从改 C# 到改 IL 再到保存4.1 直接改 C# 代码Edit Method 背后做了什么dnSpy 提供了一条懒人路径。在代码视图中选中某个方法右键选择编辑方法(C#)会弹出一个 C# 源码编辑器直接改代码点编译dnSpy 会把你写的 C# 重新编译成 IL替换掉原方法体。这个功能在 Unity 游戏修改里非常受欢迎因为它大大降低了 IL 学习门槛。但实际用下来我一般只在改动很简单时才走这条路。原因有两个。第一dnSpy 的 C# 编译器对反编译出来的代码兼容性不完全有些类尤其是带泛型、迭代器、async 状态机的方法重新编译时容易报错或者编译出来体积变大。第二C# 视图里的代码经过了反编译器的“美化重构”你看到的 return 语句、取值方式未必是原始 IL 的等价形状改起来容易产生偏差。我的个人建议是能用 IL 编辑解决的就别碰 C# 编辑。IL 虽然看着不亲民但它是最接近程序集真实运行状态的表达改一步是一步位置精确反噬概率小。4.2 改 IL 指令更稳把一个条件分支 NOP 掉的完整操作还是上面那个 GetRewardCount 的例子。目标是让方法无论当前次数是多少都返回 currentCount。最干净的改法是把 IL 视图里那个blt条件跳转指令改成nop空操作同时把后面的ldc.i4.0和ret也处理掉。具体操作右键方法选择编辑 IL 指令。dnSpy 会打开一个 IL 编辑窗口里面每一行是一条指令支持双击修改、右键删除或插入新指令。窗口上方有“标签”栏显示每条指令的跳转目标。// 修改前的 IL L_0000: ldarg.0 L_0001: ldfld int32 DailyRewardManager::currentCount L_0006: ldc.i4.s 10 L_0008: blt L_001d L_000a: ldc.i4.0 L_000b: ret // 修改后的 IL跳转改为 nop后续的返回 0 直接改成返回真实值 L_0000: ldarg.0 L_0001: ldfld int32 DailyRewardManager::currentCount L_0006: nop L_0007: nop L_0008: nop L_0009: ret删掉ldc.i4.s 10和blt之后执行流会直接碰到栈上还压着的 currentCount然后ret把它返回。这里要注意ret返回的是栈顶值所以只要保证压入栈的是 currentCount就能正确返回。用nop顶替原指令是为了不打乱 dnSpy 自动管理的跳转标签避免出现跳转到空白位置的问题。如果只是想把这个方法的阈值从 10 改成 100就不需要 NOP 跳转直接把ldc.i4.s 10改成ldc.i4.s 100就行。IL 里小整数常量用ldc.i4.s指令承载带一个字节的参数。修改时双击指令在弹出窗口里把操作数改成 100回车确认。这种方式最安全因为它不改动任何跳转逻辑。参数说明和边界都在这里ldc.i4.s只能装 -128 到 127 的整数改成 1000 就会超界。遇到大数值时要用ldc.i4四字节整型甚至ldc.i8八字节整型指令长度从两字节变成五字节后续偏移量会整体变化。好在 dnSpy 的 IL 编辑器用的是标签跳转偏移量自动重算出错概率不大但记住这个前提改完之后务必看一眼整个方法体的 IL确认没有跳转落到被替换指令的中间位置。4.3 保存模块与备份改坏了怎么后悔修改完成后在 dnSpy 的文件菜单里选保存模块会弹出保存窗口确认保存路径。如果改的是工作副本work.dll直接覆盖保存即可如果改的是原文件dnSpy 会提示“程序集可能被其他进程加载保存可能失败”这个提示要当真。保存时有几个细节会直接影响结果。第一个是“保存时是否生成调试符号”的选项Unity 发布版基本没有 pdb这个选项不勾选即可。第二个是保存后文件体积通常会变大因为 dnSpy 重新编码了元数据和 IL这属于正常现象不代表改坏了。第三个是如果原 dll 有强名称签名Unity 游戏里少见保存时 dnSpy 会弹签名警告直接忽略运行时 Unity 不会做强名称校验。改坏了几次之后我养成了一个习惯这个习惯救过我很多次动手前先复制一份原始 dll 到备份目录文件名带日期每次修改保存后也复制一份改动后的版本记录改动位置。这样出了问题随时能 diff 回去不必重新反编译一遍。我见过有人改完发现游戏白屏想还原但原始 dll 已经被覆盖只好重新拆包重来白白折腾半小时。记住dnSpy 修改没有自动备份后悔药得自己提前备好。5. dnSpy 实战避坑混淆、保存失败与闪退的 5 个典型案例5.1 反编译出来全是 a.b.c遇到混淆怎么下手现象打开 Assembly-CSharp.dll 后左侧程序集树里类名全是a、b、c这类单字符方法名变成不可读的乱码CtrlShiftK搜业务关键字一个都搜不到。原因游戏发布前做了混淆常见工具是 ConfuserEx、Obfuscar或 Unity 自家的 IL2CPP 加防篡改方案。混淆器把类型名、方法名、字段名都改成无意义字符并把控制流打乱让反编译结果失去可读性。解决先判断混淆类型。如果类名是单字符但不带异常加密跳转还能硬看如果反编译代码里到处是int[]解密数组和switch跳转基本是 ConfuserEx 的一套玩法。dnSpy 的编辑菜单下有一个反混淆入口能识别并部分还原 ConfuserEx 混淆过的程序集。先试这个不行再上专门的去混淆工具针对处理。如果只是名字被改写但逻辑尚可阅读另一种办法是搜索字符串常量——混淆器通常只重命名标识符不加密明文文本搜 UI 文案反而能定位到业务代码。5.2 修改后保存模块报错文件占用和加载冲突现象改完 IL 点保存dnSpy 弹出“文件正在被其他进程使用”或“保存失败”的错误框有时候保存一半失败dll 被写坏。原因最主要的是源 dll 仍被 Unity 编辑器或游戏进程占用尤其是 PC 版游戏启动时会把 dll 整个读入内存并锁定文件。其次是 dnSpy 加载程序集时带了文件锁同路径写回时 Windows 拒绝写入。解决先在任务管理器里确认没有游戏进程。另一个有效操作是不要直接改原文件把 dll 复制到临时目录比如C:\dnspy_work\在 dnSpy 里打开临时副本改完保存再把副本复制回游戏目录覆盖原文件。这个流程能规避绝大多数文件占用问题还顺带实现了备份。如果保存时提示“程序集已被修改是否重新加载”选否继续保存。5.3 改了数值游戏闪退完整性校验和越界现象改完保存进游戏能启动但一触发修改过的逻辑就闪退或者下载更新后修改失效。原因常见情况有三种。一是游戏做了完整性校验启动时或运行时校验 dll 的哈希不对就崩溃或自动回滚。二是数值越界比如改了ldc.i4.s的常量导致运算结果超过后续逻辑的预期范围下游代码抛异常。三是修改点没找全比如只改了奖励数量却漏了领取条件分支导致逻辑走到一个未定义的中间状态。解决先确认是不是校验。把 dll 改回去用备份能恢复正常那基本是校验问题这属于主动检测去找校验方法本身难度不小很多游戏把校验放在启动流程里用 dnSpy 打开后搜hash、Verify、integrity之类的关键字试运气。跳过校验之前先排除数值越界这种低级错误改完用 dnSpy 再打开重新看一遍 IL确认跳转目标和修改前的结构一致没有悬空跳转。我自己遇到过最典型的一次是把一个阈值从 100 改成 10000但下游代码用这个值做数组下标直接数组越界崩溃。所以改大数值前先确认这个值最终流向哪里。5.4 找不到 Assembly-CSharp.dll这是 IL2CPP 包现象拆包后assets/bin/Data/Managed/目录根本不存在或者只有几个无关小 dll游戏主体逻辑的 dll 怎么都找不到。原因游戏使用了 IL2CPP 打包C# 代码已经被转成 C 并编译进libil2cpp.so、GameAssembly.dll或libil2cpp.app.so这类原生文件里。没有托管 dlldnSpy 自然无米下锅。解决这属于工具选型问题。IL2CPP 包的正确路线是先找libil2cpp.so和同目录下的global-metadata.dat用 Il2CppDumper 导出符号表再用 IDA 或 Ghidra 打开原生库配合符号表定位逻辑。这套流程复杂很多而且不能像 dnSpy 那样改完保存直接跑。所以结论很实在如果你离不开 dnSpy 的修改-保存效率在选择分析对象时优先挑 Mono 包如果你拿到的包是 IL2CPP尽早切换到原生逆向工具链别在 dll 目录里干耗。5.5 打开 dll 报“无法加载程序集”版本和依赖解析问题现象dnSpy 加载某个 dll 时弹错误提示“无法加载文件或程序集”或“未能解析依赖项”点确定后程序集树里是空的。原因Unity 不同版本依赖的 .NET 运行时不一样老项目用的 .NET 3.5 等价物新项目是 .NET 4.x 甚至 .NET Standard 2.1dnSpy 对部分运行时的程序集引用解析不完整。另外如果 dll 依赖UnityEngine.CoreModule.dll等运行时程序集而 dnSpy 没加载它们也会解析失败。解决先把*_Data/Managed/目录下的UnityEngine*.dll一并拖进 dnSpy让依赖链完整。这一步能解决大多数加载失败。还没解决的话换一个 dnSpy 版本试试老版本对新 Unity 程序集的支持和最新版本的行为差异很大手里多备两个版本不丢人。实在不行先查一下 dll 的目标框架是什么用对应版本的 .NET 开发工具包里的解析器辅助确认dnSpy 只是前端工具底层解析能力跟它的运行时版本直接相关。6. 从改完到确认生效三个让效率翻倍的验证技巧6.1 用“分析”功能检查所有调用点改完一个方法别急着保存。右键该方法选择“分析”dnSpy 会列出所有调用它的地方。数值逻辑往往有多个入口比如领取、补发、重置每个入口都可能独立读取次数上限。我曾经只改了一个入口就收工结果游戏里另一个界面走的还是旧逻辑表现就是“改了但没完全改”。确认调用点全部覆盖再保存省一次返工。6.2 用 dnSpy 的 C# Interactive 窗口做逻辑预演dnSpy 自带 C# Interactive 窗口可以加载目标程序集并直接调用方法。改 IL 之前先在这里验证原始逻辑的行为改完之后再验证一遍对比输出就能确认改动是否符合预期。操作很简单// 在 dnSpy 的 C# Interactive 窗口里先加载目标 dll #r C:\dnspy_work\Assembly-CSharp.dll // 构造目标类实例并调用方法这里用虚构类名演示 var mgr new DailyRewardManager(); typeof(DailyRewardManager).GetField(currentCount, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(mgr, 12); // 输出结果对比修改前后的返回值 Console.WriteLine(mgr.GetRewardCount());这段代码里#r是加载程序集反射设置私有字段是为了模拟“次数已超限”的状态。修改前跑一次会输出 0修改后跑一次会输出 12中间差的就是你改的指令。这个方法能把修改验证从“启动整个游戏试”压缩成“几秒钟看结果”排查逻辑问题时特别管用。注意类名和字段名要按你实际反编译结果替换Unity 类的构造函数可能带参数具体构造方式以反编译代码为准。6.3 反编译结果反过来用检查自己的代码暴露了什么dnSpy 不只是进攻工具也是防守工具。把你自己的 Unity 项目用 Mono 模式打一个测试包然后用 dnSpy 打开你会清晰地看到所有客户端逻辑、加密密钥、奖励公式、服务端校验开关全部一览无余。我常用这个方式做自检把需要保密的逻辑和服务端校验同步一份到后端客户端只保留基础表现这样即便别人用 dnSpy 拆包也拿不到核心资产。如果你发现自己的项目里硬编码了密钥、在客户端做了数值可信判断、把重要逻辑全放在 Assembly-CSharp.dll 里趁早改成服务端校验或 IL2CPP 打包这比任何混淆都踏实。这里也是我栽过跟头的地方有一回给模拟项目X做修改改完没保留原始反编译的代码备份结果后期排查改动记录时对着两个版本的 dll 来回切换根本说不清到底哪一刀是哪天切的。后来每次动手前先把原方法的 C# 反编译结果复制到一个文本文件里标注日期和改动思路再开始改 IL。这个习惯让我少走了很多弯路。工具是死的流程是活的dnSpy 再顺手也替不了你手边那份改写记录。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进