简介NoFuserEx 是一款面向 .NET 程序集的反混淆工具适合逆向工程、恶意代码分析与软件安全研究的学习者使用主要解决被混淆程序集难以阅读、符号信息缺失和结构分析成本高的问题。压缩包共 10 个文件约 1.76MB包含可执行主程序、dnlib 依赖库、pdb 调试符号、config 配置、xml 文档与 manifest 清单等覆盖工具运行所需的程序、依赖和说明性文件并带有输出目录便于查看处理后的程序集结果。已有 358 人学习下载。读者可借助该工具对混淆样本进行还原尝试结合依赖库与调试符号理解 .NET 反混淆的基本流程观察输出程序集的变化并用于逆向分析练习、样本排查和工具二次开发准备压缩包体积小巧适合快速搭建本地分析环境。1. NoFuserEx 是什么一份只处理 .NET 混淆链的反混淆工具反编译一个被处理过的 .NET 程序集满屏switch分发、byte[]常量表、方法名是一串不可见字符——这种时候硬读 IL 是效率最低的做法。NoFuserEx 就是用来先剥掉这一层的一个基于 dnlib 的命令行反混淆工具针对 ConfuserEx 那一系 .NET 混淆器的几类保护做定点还原覆盖常量与字符串加密、控制流平坦化、代理调用、加密资源、反篡改初始化。它不是通用的脱壳工具。碰到虚拟化保护或者原生壳基本帮不上忙但样本正好落在覆盖范围内时省掉的重复劳动很可观。适合三类人源码丢了还要维护老程序集的工程师、需要快速看清样本行为的分析人员、做兼容性排查时被混淆代码挡住的人。反混淆这个词听着玄落到操作上就是「把不可读的 IL 改回可读的 IL」前提是你有权处理这个程序集。2. 它到底改了什么从 IL 层面看四条还原链路反混淆工具最怕被当成黑匣子用——输入一个 exe输出一个 exe中间发生了什么完全不知道出了问题只能反复试。要把 NoFuserEx 用稳得先弄清楚它在元数据和方法体上动了哪几刀以及这几刀为什么必须按顺序切。2.1 模块加载与写回dnlib 承担的基础活NoFuserEx 全程静态处理不加载、不执行目标程序集。它用 dnlib 把 PE 文件读成对象模型PE 头 → CLI 头 → 元数据根 → 各个堆 → 表行然后在这一层做修改最后用ModuleWriter重新序列化成磁盘文件。整个流程里没有一个Assembly.Load这一点很关键。原因在于混淆器的自保护设计。ConfuserEx 系的模块初始化器Module::.cctor里通常挂着反调试、反篡改、环境检测一旦你把程序集真正加载进 CLR这些逻辑就会跑起来轻则改内存里的方法体重则直接Environment.FailFast。所以正确的做法是静态读 IL自己写一个小解释器去模拟执行混淆器塞进来的解密方法拿到明文再写回调用点。NoFuserEx 内置的正是这么个轻量 IL 模拟器只支持解密方法用到的那一小撮指令算术、数组、字符串拼接、call不追求通用。写回阶段才是真正容易出错的地方。混淆器往往故意往程序集里塞无效元数据——重叠的字段 RVA、越界的 blob 索引、重复的 MethodDef 行——dnlib 读的时候相当宽容能跳过就跳过写的时候却会按自己的理解重新分配 token 和行序。如果原程序里有任何地方依赖「token 顺序」做自校验重排后就会对不上。下面这张表是处理时最常打交道的几个堆知道每个堆管什么排查时能少走很多弯路。元数据堆存什么反混淆时为什么关心#Strings类型名、方法名、字段名乱码名识别、重命名还原的落脚点#US用户字符串字面量字符串解密的最终写入位置还原成功后这里能直接搜到明文#Blob签名、常量值、自定义属性常量表的原始载体加密数据块通常藏在这#~表流MethodDef、Field、MemberRef 等行数据token 重排、行序变化导致自校验失败的重灾区2.2 字符串与常量还原定位解密方法再内联回调用点ConfuserEx 的常量保护做法是编译期把所有ldstr和数字常量抽出来序列化、压缩、加密成一个大 blob塞进某个类型通常是Module或者一个自动生成的类运行时由一个静态解密方法按 id 取出对应值。方法体里原来的ldstr hello变成了ldc.i4 id加call decoder。还原分三步。第一步定位解密方法特征比较固定签名是「一个 int 参数、返回 object 或 string」、方法体里有大段数组索引和InitializeArray、通常在Module::.cctor里被初始化。第二步用模拟器把它跑一遍把 id 到值的映射整张表拉出来。第三步遍历所有方法体把ldc.i4 id; call decoder这对指令替换成ldstr或ldc.i4。动手之前我一般会先用下面这段脚本扫一遍确认目标程序集里到底有多少个这样的调用点、解密方法的签名长什么样。这段代码不是工具源码只是拿 dnlib 做的一次快速统计用来判断「该不该指望工具自己认出来」。// 引用 dnlib.dll统计疑似常量解密调用的密集程度 using dnlib.DotNet; using dnlib.DotNet.Emit; var mod ModuleDefMD.Load(args[0]); int hits 0; var suspects new Dictionarystring, int(); foreach (var type in mod.GetTypes()) foreach (var m in type.Methods) { if (!m.HasBody) continue; var ins m.Body.Instructions; for (int i 0; i 1 ins.Count; i) { // 典型形态ldc.i4 id 紧跟 call decoder if (ins[i].OpCode.Code ! Code.Ldc_I4) continue; if (ins[i 1].OpCode.Code ! Code.Call) continue; var callee ins[i 1].Operand as MethodDef; if (callee?.MethodSig null) continue; if (callee.MethodSig.Params.Count ! 1) continue; if (callee.MethodSig.Params[0].ElementType ! ElementType.I4) continue; hits; suspects[callee.FullName] suspects.GetValueOrDefault(callee.FullName) 1; } } Console.WriteLine($疑似解密调用点总数{hits}); foreach (var kv in suspects.OrderByDescending(x x.Value)) Console.WriteLine(${kv.Value,6} {kv.Key});几个参数值得说明。Params.Count 1和Params[0].ElementType ElementType.I4是判定「入参是常量 id」的核心条件遇到变种把签名改成(int, int)id 加盐或者加一个object[]参数时这个条件就要放宽成Params.Count 1先打印签名人工看一眼再决定。MethodDef的转换失败as MethodDef返回 null说明调用目标在别的模块里属于MemberRef这类一般是跨程序集调用不在常量解密范围内跳过即可。替换阶段还有个细节解密方法的返回类型是object所以调用点后面往往跟着castclass、unbox.any或unbox。内联时要连带把这条转换指令一起吃掉否则会留下一条对int做castclass string的非法 IL反编译器打开就报红。2.3 控制流还原把状态机还原成顺序执行控制流平坦化是视觉上最明显的一层一个原本三十行的简单方法被改成两百行中间横着一个switch。具体做法是把方法体切成基本块打乱顺序用一个局部 int 变量当状态机每个块结尾修改状态值然后br回分发头。反编译出来就是一个while(true) { switch (state) {...} }。// 平坦化之后的典型骨架state 是那个被反复赋值的局部变量 ldc.i4.s 0x2A stloc.0 br.s L_dispatch L_block_1: // ……真实逻辑…… ldc.i4.s 0x07 stloc.0 L_dispatch: ldloc.0 switch (L_block_1, L_block_2, L_block_3, ...)还原的思路是识别分发器找到一个switch它的操作数是局部变量且每个 case 的第一条指令是ldc.i4 state加stloc。识别出来之后按状态值重建块与块之间的真实跳转边把状态变量和分发头删掉。难点不在识别在两侧边界。其一是不透明谓词混淆器会把跳转条件改写成if (x * x 0)这种恒真表达式静态分析判不出来工具只能保守保留于是还原完的代码逻辑是对的但看着仍然别扭——这是正常现象不必怀疑工具没生效。其二是异常处理表try/catch/finally的起始和结束偏移必须跟着基本块一起移动一块搬错整个方法就变成非法 ILpeverify立刻报错。这也是为什么控制流还原必须排在常量还原之后分发器里switch的 case 边界、状态值本身如果还是以call decoder的形式存在模拟器拿不到具体数值块切分就会错位还原出来的方法能加载但行为不对——比直接报错更难查。2.4 代理调用与资源还原顺序错了等于白做代理调用是相对简单的一层混淆器把方法体里的每个call、callvirt换成一个生成的 proxy 方法proxy 内部再调真正的目标。还原就是追一层转发把调用点直接指向原始方法。这里唯一的风险在callvirt如果目标方法被虚调用接口分派、重写直接改成call会破坏多态语义正确做法是保留callvirt只换操作数。资源保护则是把内嵌的.resources加密在程序集解析事件或模块初始化器里解密。还原后要重建Resource表项麻烦的是资源名往往也被改过——原本叫App.Properties.Resources.resources还原后变成一串乱码。名字对不上程序里按名查找资源的代码就会返回 null界面一片空白但编译和加载都不报错。所以整条链路的执行顺序是固定的反篡改 → 常量 → 资源 → 控制流 → 代理调用。反篡改必须第一个清否则模块初始化器会在运行时把改过的方法体重新校验一遍资源和常量有依赖关系密钥通常来自常量表控制流依赖常量已经内联代理方法的函数体本身也可能被平坦化所以放最后。这个顺序不是随意定的跳过任何一步都会在后面以「能打开但跑不动」的形式提现出来。3. 跑通第一个样本命令行、参数与验收闭环原理清楚了落到操作上其实很短一条命令一个输出目录然后花时间做验收。真正耗时的从来不是跑工具是判断「跑完的这一版到底对不对」。3.1 环境准备运行时、位数与依赖三件套工具本身是个 .NET Framework 控制台程序跑在 Windows 上最省事。用 Linux 加 mono 理论上能启动但 dnlib 在写回 PE 时对某些段布局的处理跟原生 CLR 有差异输出文件在 Windows 上加载失败的概率明显变高不值得为省一台虚拟机去折腾。目标程序集的位数AnyCPU / x86 / x64不影响静态处理dnlib 只读元数据不看机器码。真正设限的是混合模式程序集也就是 C/CLI 编译出来的那种方法体一半是原生指令dnlib 读出来是空的这类直接放弃后面 4.3 会细说。工作目录的摆放也有讲究。工具会往输入文件同级或指定目录写输出如果这个目录挂在 OneDrive、坚果云这类同步盘上写回途中文件句柄被同步进程抢走就会抛IOException报错信息还看不出是同步盘的问题。我一般的做法是在本地盘建一个D:\work\之类的纯本地目录把样本、工具、输出全放进去并且给这个目录加一条杀毒软件排除项——不是为了性能是为了避免实时扫描在写回阶段锁住文件。开工前先留底这一步别省# 1) 原始样本先算哈希之后所有对比都以它为准 sha256sum app.exe | tee app.exe.sha256 # 2) 复制一份只读备份避免后续操作把原件覆盖 cp app.exe app.exe.bak chmod 444 app.exe.bak # 3) 记录程序集的运行时版本写回时会沿用原 CLR 头 # Windows 上可以直接看文件属性或者用 PowerShell # [Reflection.AssemblyName]::GetAssemblyName(app.exe).Version哈希这一步的意义在于反混淆跑完发现行为不对时你得能确定自己测的还是不是原来那个文件。血泪经验是改到第三轮的时候人已经分不清哪个是原始样本、哪个是第二次输出靠文件名区分迟早翻车。3.2 最小可复现命令与参数说明不同构建版本暴露的参数不完全一样先无参跑一次或者加--help把 usage 打印出来看清楚再动手比照着一篇两年前的教程瞎猜要快得多。# 进入工具目录假设工具解压在 D:\tools\NoFuserEx cd /d D:\tools\NoFuserEx # 先看 usage确认这一版支持哪些开关 NoFuserEx.exe --help # 最小调用只有一个输入路径输出落在默认位置 NoFuserEx.exe D:\work\demo\app.exe关于参数几个行为开关是你一定会关心的名字以手上那版为准但语义大同小异行为默认表现什么时候要改输出位置输入同级目录下的独立子目录想统一收口到D:\work\out\时改日志详细度只打印阶段摘要排查「为什么某个方法没被处理」时开到最详细资源处理解密并重建Resource表项只关心代码逻辑、不跑界面时关掉能少一类失败调试符号不处理 PDB有原始 PDB 时可以保留方便对照行号反篡改清理清理模块初始化器见 4.1某些样本要单独跑这一阶段日志详细度这个参数值得单独提一句。默认摘要只会告诉你「处理了 N 个方法」看不出哪些方法被跳过了。开到最详细之后日志里会逐个列出识别到的解密方法全名、替换点数、以及失败原因签名不匹配、模拟执行超时、遇到不支持的指令。第一次处理新变种时这份日志的信息量比输出文件本身还大。3.3 验收用反编译器和 IL 差异确认到底改了啥输出文件生成之后先别急着双击运行。在虚拟机里打开 ILSpy 或 dnSpy按三个指标快速过一遍第一字符串堆。反编译器里按CtrlShiftF全局搜一个你确定存在于原程序里的界面文案比如某个按钮的标题。还原成功的话搜到的应该是ldstr 确定这种直白形态如果搜不到或者搜到的还是一堆byte[]索引说明常量阶段没生效。第二方法体里的switch密度。挑一个原本逻辑简单的方法比如某个 setter 或者配置读取看它现在的指令条数。平坦化还原成功的方法指令数会掉到原来的三分之一到五分之一。第三Module::.cctor的长度。这个方法是混淆器藏自保护逻辑的首选位置还原之后它应该变得很短甚至只剩一两条ldtoken。想量化的话用下面这段脚本分别统计还原前后的switch指令数量比肉眼扫靠谱// 统计整个模块里 switch 指令的出现次数用于前后对比 using dnlib.DotNet; using dnlib.DotNet.Emit; foreach (var path in args) { using var mod ModuleDefMD.Load(path); int sw 0, methods 0, instrs 0; foreach (var t in mod.GetTypes()) foreach (var m in t.Methods) { if (!m.HasBody) continue; methods; var ins m.Body.Instructions; instrs ins.Count; foreach (var i in ins) if (i.OpCode.Code Code.Switch) sw; } Console.WriteLine(${Path.GetFileName(path),-24} 方法 {methods,5} 指令 {instrs,8} switch {sw,5}); }switch计数只是粗指标但足够判断控制流阶段有没有真的跑起来。指令总数同时下降说明块切分和分发头删除都生效了如果switch降到 0 而指令数没怎么变多半是分发头被删了但块没合并属于半成品。最后一步是 IL 合法性校验用 .NET Framework SDK 自带的peverify# /md 校验元数据/il 校验 IL/verbose 打印每一项的检查结果 peverify /md /il /verbose D:\work\demo\app_NoFuserEx\app.exe输出里如果有「无法解析的 token」「堆栈不平衡」这类错误说明某次替换留下了非法指令最常见的就是 2.2 提到的castclass没跟着一起删。这类错误在反编译器里不一定看得出来dnSpy 对非法 IL 相当宽容但 CLR 加载时会直接拒绝表现出来就是「双击没反应」。4. 排查清单五类让输出「能打开但跑不动」的情况反混淆失败很少以「工具直接崩了」的形式出现更多是输出文件看着正常、反编译也能看一运行就出各种状况。下面这五条是按我自己的踩坑频率排的每条都给出定位方法和处置方式。4.1 反篡改残留一运行就 TypeInitializationException现象输出文件能被 dnSpy 正常打开反编译出来的代码也漂亮但双击运行立刻闪退Windows 事件日志里是一条TypeInitializationException内部异常指向Module::.cctor。原因反篡改保护没被清干净。混淆器在模块初始化器里存了原始方法体的哈希运行时先解密、再比对、不一致就抛异常。NoFuserEx 改了方法体但初始化器里的校验分支还在所以一跑就炸。解决先确认工具的反篡改阶段有没有真的执行。有些版本需要单独指定开关或者在日志里会明确写「未检测到受支持的反篡改模式」。命令行看不出端倪时直接在 dnSpy 里打开输出的Module::.cctor找call到某个返回bool的方法之后紧跟brfalse或throw的结构把整个校验分支nop掉再保存。手工改的地方记得只改初始化器不要动业务方法。4.2 强名称失效改一个字节签名就废了现象程序集带强名称反混淆之后加载时报「强名称验证失败」或者用sn -vf校验直接报错。原因这不算工具的bug。强名称签名覆盖的是整个文件含元数据任何 IL 改动都必然导致签名不匹配属于设计使然。解决测试环境用sn -Vr *,*关掉强名称验证就行注意这条命令影响整机策略只在虚拟机里用。要在正式环境跑就得用sn -k生成自己的密钥对再用sn -R重签重签之后的程序集身份变了依赖原来公钥令牌的配置文件、InternalsVisibleTo声明、插件加载白名单都会跟着失效得一并改。这条链子牵得比较长动手前先想清楚是不是真的需要重签。注意跳过强名称验证只应该出现在隔离的测试环境里不要在任何共享机器上做全局策略变更。4.3 混合模式与多模块dnlib 的处理边界现象处理 C/CLI 编译的程序集时工具不报错但输出文件里大量方法体的指令数是 0或者处理多模块程序集主程序集加.netmodule之后附属模块里的类型全部丢失运行时报TypeLoadException。原因混合模式程序集的方法体包含原生机器码和 IL thunk 两部分dnlib 只认纯 IL 那一段读出来就是空的写回时自然也没内容。多模块程序集则是元数据分散在多个文件里ModuleWriter默认只写主模块附属的.netmodule需要单独处理或者显式保留。解决混合模式这类直接绕开从原生逆向的角度切入或者只处理其中的托管层部分、保留原生段不动。多模块的样本先确认.netmodule文件在不在输出目录不在的话把主程序和附属模块一起喂给工具或者手工把附属模块复制过去。判断方法很简单用dnlib加载后遍历mod.GetTypes()看类型总数跟原始文件比掉了多少。4.4 变种与二次混淆不要期望一个工具覆盖全部变种现象跑完之后一部分字符串变成明文了另一部分还是加密的或者日志显示「未找到常量解密方法」但反编译一看明明有大量byte[]索引。原因目标不是原版 ConfuserEx而是改过解密方法形态的变种——参数从(int)变成(int, int)、返回值从object变成string、甚至把解密逻辑拆成两个方法通过委托调用。二次混淆先过一遍 A 混淆器再过一遍 B也会造成同样的现象外层被还原了内层还裹着。解决先用 2.2 那段扫描脚本把解密方法的实际签名打出来跟工具期望的形态对一下。签名只是多了个参数的话可以自己写脚本按同样的思路处理工作量不大。二次混淆则一定要分阶段跑一次只剥一层每层跑完都用peverify和实际运行验一遍——一次全部处理掉的后果是中间某层处理错了你根本定位不到是哪一层。4.5 单文件发布包先拆包再谈反混淆现象目标是一个 .NET 单文件发布的可执行文件工具加载后报「无效的 PE 文件」或者元数据表全空。原因单文件发布把所有依赖程序集打包进宿主 exe 的资源区主文件的元数据里根本没有业务代码真正的程序集是运行时从资源里解出来的。解决先拆包把内嵌的程序集提取成独立 dll再对每个 dll 单独跑反混淆。拆包思路是找宿主里的 bundle 清单一段以固定魔数开头的二进制块记录了每项的名称、偏移、大小按偏移切出来就是原始文件。拆出来的 dll 命名可能被哈希过需要根据程序集元数据里的Assembly.Name重命名回原名否则运行时解析会失败。这一步没有通用工具能一键搞定得按具体格式写几十行解析代码。5. 从「能看懂」到「能编译」反混淆之后还剩三道活NoFuserEx 跑完多数人会有个错觉字符串明文了、switch没了是不是就完事了实际上到「能用 ILSpy 导出成 C# 工程并且编译通过」之间还有相当一段距离。这几步工具不管只能手工补。5.1 剩余混淆的识别哪些是工具明确不处理的第一类是重命名。混淆器把类型名、方法名换成\u0001\u0002这种不可见字符或者换成A、A.A、A.A.A这样的占位名。NoFuserEx 不做语义化重命名它只保证名字唯一。想把名字还原成人能读的形态得靠基于签名的启发式推断实现了IDisposable且方法体只有一个bool字段赋值的大概率是Dispose继承自EventArgs的类名多半跟事件相关get_/set_前缀的方法对应属性。这类推断有现成思路可以借鉴但落到具体程序集上还是要人工核对误判比不改更麻烦。第二类是无效元数据。混淆器会往程序集里塞进大量假方法、假引用、交叉引用的类型反编译器打开时满屏红叉。这些条目本身不影响运行CLR 加载时会跳过无法解析的引用但会让导出的工程编译不过。处理办法是写脚本清理遍历所有MemberRef把解析不到目标的条目删掉遍历所有方法体把call到不存在方法的指令替换成nop加默认值返回。第三类是残留的字符串。常见于变种只有一部分解密路径被覆盖。判断方法是拿还原前后的#US堆大小做对比如果堆大小基本没变说明字符串还原基本没生效变了但还不够说明只覆盖了一部分需要按 4.4 的思路再补一轮。5.2 导出 C# 工程五类必然遇到的编译错误ILSpy 的「另存为工程」能生成 csproj 和一堆 .cs 文件但直接dotnet build十有八九是过不去的。下面这五类错误几乎每次都会碰到按出现频率排错误类型典型表现处理方式非法 IL 残留反编译出的方法体是/* 无法反编译 */回到peverify找非法指令手工修那几条泛型约束丢失where T : struct之类约束不见了编译报类型不匹配从元数据的GenericParamConstraint表手工补回约束编译器语法糖未还原出现c__DisplayClass、Mainb__0这类名字ILSpy 设置里开启闭包和 lambda 的还原选项资源引用路径错Properties.Resources.xxx找不到检查.resx是否生成、资源名是否被改名强名称与 InternalsVisibleTo编译通过但运行时找不到 internal 成员去掉程序集签名或者把 internal 改成 public其中泛型约束丢失最费时间因为反编译出来的代码看起来完全正常报错信息却指向一个跟约束无关的地方。定位方法是拿dnSpy打开原始程序集直接看GenericParamConstraint表对照导出代码的where子句逐个补。5.3 资源与配置的重建最容易被忽略的一环反编译工程在 IDE 里点「运行」直接崩、查半天发现是配置文件里的某个连接字符串或者插件路径没跟过来这种情况太常见了。app.config、.ini、外部.dll依赖、以及那些被混淆器改过名的.resources都不会出现在导出的 csproj 里。我的做法是先列清单再动手用dnlib把原始程序集的Resource表整个打印出来拿到每个资源的名字、大小、是否内嵌再对照输出目录把缺失的补上名字对不上的按原始名字改回去。配置文件同理从原程序目录复制过来逐项确认路径是相对还是绝对。这一步做完导出的工程能跑起来的概率会高很多。6. 验收这一步别省我给反混淆产物定的三条硬指标反混淆这件事最容易犯的错是把「反编译看着干净」当成成功。看着干净和真的正确之间隔着运行时行为这一关。吃过几次亏之后我现在对每一份输出都强制走三条硬指标缺一条都不算完。第一条peverify零错误。这是底线元数据和 IL 都合法才能谈后面的。输出里只要有一条「堆栈不平衡」就说明某次指令替换留下了残渣这时候去测运行行为纯属浪费时间因为在 CLR 眼里这个程序集根本加载不了。第二条关键方法的指令序列等价。挑三到五个业务核心方法不是解密方法是真正的业务逻辑把还原前后的 IL 指令序列导出来做归一化对比去掉nop、把ldarg.0这类标准化、忽略 token 数值只比较操作码和操作数类型。序列一致说明还原没有改变语义只是去掉了外层包装。这一步用 dnlib 写个几十行的对比工具就够比人眼扫靠谱得多。第三条行为回归。在隔离虚拟机里跑一遍主流程把入口、退出、关键输出日志文件、导出的数据文件跟原始样本对比。原始样本如果因为反调试跑不起来就在调试器里跳过反调试再跑总之要有一个基准行为。这三条里前两条是静态的、可以自动化第三条必须手工但只有它能发现「IL 合法、语义等价、但资源加载顺序变了」这类问题。# 三条指标串成一条命令跑完直接看结论 peverify /md /il D:\work\out\app.exe \ dotnet run --project .\il-diff -- D:\work\demo\app.exe D:\work\out\app.exe \ echo 静态检查通过可以进虚拟机跑行为回归这套流程我第一次完整走完花了将近一天后面熟练了大概二十分钟。从那以后我每次处理新样本不管多急都强制把这三条走一遍——省下来的那点时间最后都会在「为什么这版跑起来不对」上十倍还回去。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?