简介:NoFuserEx是一款面向.NET程序集的反混淆工具,专为逆向工程师、恶意样本分析人员及安全研究人员设计,用于处理经由ConfuserEx等常见混淆器保护后的二进制代码。资源以zip格式打包,共10个文件,其中包含两个exe主程序、两个dll动态链接库、两个pdb调试符号、两个config配置文件以及xml和manifest清单,整体体积仅1.76MB,轻量易用。exe用于启动工具并完成反混淆操作,dll提供核心解析与重写能力,pdb文件便于开发者调试排错,config则记录默认运行参数,目录中还预置了输出目录,方便保存处理结果。压缩包内自带了dnlib库及其调试符号,dnlib是.NET程序集操作的核心依赖,配合NoFuserEx主程序可实现对混淆方法的解析、还原与重写,提取原始方法体和关键字符串。已有356人学习下载,适合正在学习.NET逆向或需要快速剥离混淆层的开发者。通过该压缩包,读者能够获得开箱即用的工具链,直接用于分析恶意软件或商业软件中的受保护代码,显著提升逆向分析效率。
1. 反混淆工具解决的是代码从「不可读」到「可审」的最后一段距离
接手一个老业务系统,核心模块被压成几行短变量名加一串eval调用,排查问题时读不了、改不动、测不了。NoFuserEx 这类反混淆工具的价值,不在于玄学式“破解加密”,而是把被故意压扁的代码重新展开成可读、可审、可定位逻辑的文本。它的输入是一段你不敢直接跑的代码,输出是一份你能逐行解释的脚本。适用人群很明确:做安全审计的人、接手遗留系统的维护者、以及所有需要对第三方压缩代码做盘点的开发者。
反混淆工具解决的不是权限问题,而是时间问题。手搓解码可能要一整天,工具要做的是把机械重复的部分自动化,把“该用人脑判断的地方”留给你。下面从混淆后的代码长什么样开始,逐步还原一套可复现的处理流程。
2. 混淆代码的骨架:还原前先认清这几类痕迹
2.1 eval + base64 + gzinflate 三层壳:代码体藏在哪一层
最常见的混淆骨架,是把真正的 PHP 源码先压缩再编码,最后用eval执行。典型结构长这样:
$a = "..."; // 密文:base64 编码后的压缩数据 $b = base64_decode($a); // 第一层:还原出二进制压缩流 $c = gzinflate($b); // 第二层:zlib 解压后得到真正的 PHP 源码 eval($c); // 执行层:到这里才进入业务逻辑拿到这种代码,你要做的第一件事不是读变量名,而是找eval的输入链。base64_decode负责把文本变成二进制,gzinflate负责把压缩流解开,二者组合起来的产物,才是一段完整 PHP 脚本。NoFuserEx 在处理这一层时,会先做模式识别:找gzinflate、gzuncompress、base64_decode这些内建函数的嵌套关系,然后沿着数据依赖把“哪个字符串最终喂给了 eval”掰清楚。
这里有个参数值得注意:混淆器不一定每层都用base64_decode。有的会先做一次strrev倒序,有的用十六进制字符串,还有的把gzinflate换成gzuncompress。处理时不要只盯固定函数名,要盯“谁的返回值被 eval 消费”这个数据流。我一般会把所有可疑的赋值先打出来看一遍,确认每一层解码后的字节形态,再决定下一步。
2.2 变量名碾平:信息损失在哪里
混淆器为了压缩体积,会把所有变量名替换成$a、$b、$c这类短名,甚至直接复用同一个变量传递多个不同语义的数据。这意味着源码里“变量名即注释”的信息被抹掉了,工具层面能做的是保证同一变量在替换后仍然保持同一个名字,而不是恢复它原本的语义。
举个例子,混淆后的代码经常出现这种写法:
$a = array('abc', 'def'); $b = $a[0] . $a[1]; $c = 'file' . '_put_contents'; $c($b);变量名完全不可读,但数据流还在。工具能做的第一步是“一致性重命名”:把每个短变量按首次出现顺序映射成$v1、$v2,保证同一个变量不会在重构过程中被拆散。这样虽然命名仍然没有语义,但至少你能在阅读器里追踪某个值从创建到消费的完整路径。如果混淆器保留了符号映射表,那才能做到近似还原;没有映射表时,语义恢复只能靠人工根据上下文判断,工具替代不了这一步。
2.3 字符串表与逻辑压缩混在一起
除了变量名压平,Fuser 式混淆还经常把所有字符串抽到一张表里,用下标引用。代码里看不到一个直白的文件名、SQL 语句、URL,全部变成$arr[12]这种形式。这不是纯编码,而是把“人可读的语义层”从代码里剥离了。
这类混淆有个规律:字符串表本身通常不是加密的,只是被重新编码过。比如把所有字符的 ASCII 码做了移位,或者用自定义映射表做了替换。处理时先把字符串表单独摘出来,做一轮字符映射还原,再回填到引用位置。工具层面会把$arr[12]替换成实际字符串字面量,减少你来回翻表的次数。
2.4 数据边界:哪些是壳,哪些是真正的逻辑
反复解码时,最怕把壳和真逻辑混在一起读。区分边界有个比较实用的规则:连续出现两次以上“赋值-解码-再赋值”的结构,就是壳代码;直到某次解码结果里出现<?php或者明显的控制流语句(if、for、function),才算进入真逻辑。
实战中我还见过混淆器在真逻辑后面追加一段无意义的exit;或者大量空格,目的就是干扰边界判断。处理办法是先按解码层级切开,每层独立保存,最后再对照各层的文件尾部,确认是否存在“残留尾巴”。这一步不需要自动化,眼睛扫一遍比写正则快得多。
3. 第一阶段还原:从 eval 到明文的最小可复现步骤
3.1 把 eval 替换成 echo,让解码器自己输出明文
最直接的思路:既然eval()会执行解码后的字符串,那我们不执行,改为输出,就能拿到明文的 PHP 源码。常见做法是把eval替换成echo,然后把整个脚本放在隔离环境里跑一次,让解码结果打到标准输出。
php -r ' $c = file_get_contents($argv[1]); $hits = 0; $c = preg_replace_callback("/eval\s*\(/i", function($m) use (&$hits) { $hits++; return "echo "; }, $c, -1, $hits); fprintf(STDERR, "替换了 %d 处 eval 调用\n", $hits); file_put_contents($argv[2], $c); ' bad.php out.php这段命令的逻辑是:读入原文件,用正则定位eval后面跟空格和左括号的位置,整体替换成echo,同时记录替换次数输出到标准错误。第四个参数-1表示不限替换次数,避免只处理第一个eval。替换结果写入新文件,不会改动原文件。
执行时要注意:替换后的脚本仍然会运行解码函数,如果原本的字符串来自外部输入,解析过程有不确定性。安全基线是只在隔离环境里跑,不要在开发机或生产机上直接执行。参数i修饰符表示大小写不敏感,防止混淆器把EVAL做大写绕过文本搜索。
3.2 循环抽取:输出可能还是壳
第一轮替换后得到的out.php,可能是明文,也可能还是下一层壳。如果是后者,文件里仍然存在eval,就再跑一轮。这里建议用循环包裹,并设置最大轮数,防止无限递归。
cp bad.php out.php for i in $(seq 1 6); do echo "===== 第 $i 轮 =====" head -c 120 out.php echo php -l out.php php -r ' $c = file_get_contents($argv[1]); $hits = 0; $c = preg_replace_callback("/eval\s*\(/i", function($m) use (&$hits) { $hits++; return "echo "; }, $c, -1, $hits); file_put_contents($argv[2], $c); ' out.php out_next.php mv out_next.php out.php done循环里先打印文件头部,让你快速判断这一层产物是 PHP 代码还是乱码;再用php -l做语法检查,如果文件根本不是合法 PHP,说明解码链路可能走偏了,要及时停下来手动检查。head -c 120只输出前 120 字节,足够看清文件头。
这个循环的价值在于:多层嵌套时你不必手动重复复制文件、改文件名、跑命令。但要留意,如果某轮输出已经不是 PHP 源码(比如是序列化数组、JSON、或者图片字节流),php -l会直接报错,这时候就该退出循环,而不是继续替换。
3.3 用文件头和语法检查判断解码终点
解码终点怎么判断?最靠谱的依据是:替换eval后,文件里不再出现新的eval,且整个文件能通过php -l语法检查。另外可以用文件头确认,真正的 PHP 源码应该以<?php开头,而不是base64字符集里的乱码。
php -l out.php && grep -n "eval(" out.php || echo "已无 eval 调用"php -l只做语法检查不会执行代码,遇到错误会打印具体行号。组合grep -n用来确认eval是否清干净。如果语法检查通过且没有eval,说明代码体已经浮现;如果还有eval,检查它是否在字符串字面量里面——混淆器有时会把“eval”这个词本身作为字符串的一部分,造成误报。
3.4 执行点不只是 eval:assert 和可变函数也要盯
Fuser 式混淆不只用eval,常见的替代执行点包括assert、call_user_func、call_user_func_array,以及把函数名存进变量再调用的写法。如果只替换eval而漏掉这些,脚本可能在你眼皮底下执行了额外逻辑,这是最大的翻车点。
处理策略是:先用全局搜索把所有可能执行代码的调用点列出来,再逐一确认。我一般会执行一次grep -nE "eval|assert|call_user_func|create_function",把命中的行先过目一遍。assert在 PHP 7 之后的执行行为有变化,不同版本表现不一样,所以处理旧代码时别光看语法,还得确认运行环境的 PHP 版本。
4. 第二阶段还原:变量重映射与字符串折叠的实操
4.1 字符串折叠:把散的常量串拼回一个
解码出明文后,下一步是让代码可读。最常见的问题是相邻字符串碎片太多:'abc' . 'def' . 'ghi'这种写法在混淆代码里大量出现,人工合并太累,我一般用基于 token 的脚本批量处理。
<?php // 基于 token_get_all 的常量串折叠脚本 // 用法:php fold.php in.php out.php $code = file_get_contents($argv[1]); $tokens = token_get_all($code); $out = ''; $count = count($tokens); $i = 0; while ($i < $count) { $t = $tokens[$i]; if (is_array($t) && $t[0] === T_CONSTANT_ENCAPSED_STRING) { $merged = $t[1]; $j = $i + 1; while ($j < $count - 1 && $tokens[$j] === '.' && is_array($tokens[$j+1]) && $tokens[$j+1][0] === T_CONSTANT_ENCAPSED_STRING) { $merged = fold_two_strings($merged, $tokens[$j+1][1]); $j += 2; } $out .= $merged; $i = $j; continue; } $out .= is_array($t) ? $t[1] : $t; $i++; } file_put_contents($argv[2], $out); function fold_two_strings($a, $b) { // 只处理不含变量插值的纯字符串 $q = $a[0]; if (strpos($a, '$') !== false || strpos($b, '$') !== false) { return $a . '.' . $b; } $aval = stripcslashes(substr($a, 1, -1)); $bval = stripcslashes(substr($b, 1, -1)); return $q . addcslashes($aval . $bval, $q === '"' ? "\$\\\"" : "\\'") . $q; }脚本的逻辑是:遍历所有 token,发现一个常量字符串后,向后检查是否紧跟着.加另一个常量字符串;如果是,就合并两者并继续向后吞并。fold_two_strings负责去掉两端的引号,拼接内容后再套回引号,同时保留必要的转义。参数方面要注意,双引号字符串里如果包含$,说明可能存在变量插值,这时不要合并,避免改变原本语义。
这个脚本对纯字面量拼接有效,但如果字符串里混入了变量插值、或者单双引号交替出现,会自动跳过。实际使用前建议先备份,因为stripcslashes会把\n、\x41这类转义序列当作真实字符处理,和混淆器原本的编码方式可能不一致。
4.2 变量重映射:保证同一变量始终同名
解码后的代码变量名一般还是$a、$b这种短名。变量重映射的目标不是恢复原始变量名(没有映射表时做不到),而是建立一套稳定、一致的命名,方便追踪数据流。
<?php // 按作用域对变量做一致性重命名 // 用法:php rename_vars.php in.php out.php $code = file_get_contents($argv[1]); $tokens = token_get_all($code); $out = ''; $scopes = array(); $counter = array(); $depth = 0; foreach ($tokens as $t) { if (is_array($t)) { if ($t[0] === T_FUNCTION || $t[0] === T_CLASS) { $depth++; } if ($t[0] === T_VARIABLE) { $name = substr($t[1], 1); if ($name === 'this' || $name === 'GLOBALS') { $out .= $t[1]; continue; } if (!isset($scopes[$depth][$name])) { if (!isset($counter[$depth])) { $counter[$depth] = 0; } $counter[$depth]++; $scopes[$depth][$name] = '$v' . $counter[$depth]; } $out .= $scopes[$depth][$name]; continue; } } $out .= is_array($t) ? $t[1] : $t; } file_put_contents($argv[2], $out);这个脚本把每个函数或类看成一个独立作用域,在作用域内按首次出现顺序把变量重命名为$v1、$v2。关键参数是$depth,每次进入函数或类就加一,确保不同作用域的同名变量不会互相覆盖。$this和$GLOBALS是 PHP 的保留用法,直接跳过不处理。
运行完再用php -l验证语法。如果你的代码里用了extract、compact这类动态变量操作,重命名后可能造成不匹配,这类代码建议单独留给人眼处理。
4.3 格式化输出:让结构重新铺开
字符串折叠和变量重命名之后,代码还是可能挤在一行,可读性很差。一般做法是拿格式化工具统一缩进,把控制流拆成多行。如果没有现成工具,至少做两件基础工作:把分号后和右花括号后补换行,把连续的赋值语句按行拆开。
php -r ' $c = file_get_contents($argv[1]); $c = preg_replace("/;\s*/", ";\n", $c); $c = preg_replace("/}\s*/", "}\n", $c); file_put_contents($argv[2], $c); ' out.php pretty.php这个命令把分号和右花括号后的空白统一替换成换行,逻辑简单,偶尔会把字符串里的分号也切断,所以跑完必须重新过一遍php -l。更稳妥的做法是用成熟的格式化工具,按默认配置跑一遍,再人工确认改动点。格式化属于保险动作,不改语义,但能解决大量“一行几千个字符没法看”的问题。
5. 避坑:还原过程中最容易翻车的五个环节
5.1 现象:把 eval 改成 echo 后,脚本还是执行了额外逻辑
替换之后,脚本在输出解码结果前,控制台已经出现了你不预期的输出,比如写文件、发请求的副作用。
原因:执行点不止eval,还有assert、call_user_func、preg_replace的/e修饰符(PHP 7 以下)。全局替换只处理了eval,漏掉了其他入口。
解决:替换前先跑一次全局搜索,列出所有可能的执行入口;替换后不要急着运行,先检查产物里是否还有assert和变量函数调用。如果存在,把可疑的变量函数调用手动改成输出语句。
5.2 现象:解码产物php -l一直报语法错误
脚本在第 3 轮之后,php -l报出“unexpected end of file”之类的错误。
原因:解码出来的内容可能不是完整 PHP 文件,而是被截断的片段;或者开头有 HTML 输出、BOM、多余换行,干扰了解析器。
解决:先用head -c 200看文件头,确认是否以<?php开头;如果不是,把非 PHP 前缀裁掉再验证。文件尾部也要看,有时解码数据里带了一层?>闭合标签,导致后续内容被当成 HTML 输出。
5.3 现象:字符串折叠把转义序列改坏了
用 4.1 的脚本合并字符串后,输出里出现\x41变成A、\n变成真实换行的情况,原本的编码语义被改变。
原因:stripcslashes会把反斜杠转义序列直接解释成对应字符,而混淆器在编码时故意保留这些转义,是用字符串字面量的形式而不是实际字符。
解决:折叠脚本里加上“含反斜杠就跳过合并”的判断;或改用不解析转义的方式,只做字面拼接。遇到双引号字符串里有$时,也不要合并,防止变量插值错位。
5.4 现象:变量重命名导致参数错位
用 4.2 的脚本重命名后,某个函数内部参数引用乱了,原本该输出的值变成了空字符串。
原因:脚本按$depth区分作用域,但 PHP 的闭包、匿名函数、static变量在某些情况下会被误判成新作用域或同一作用域,导致不同变量被映射到同一名字。
解决:对包含use引用的闭包、static变量、global声明的代码,重命名时单独跳过;处理完立刻用php -l和简单的单元验证检查是否有未定义变量。
5.5 现象:call_user_func 指向的字符串没有跟着变
代码里有一个函数function v1() {...},重命名后函数名改成了v2,但后面call_user_func('v1')里的字符串还是'v1',运行时报函数不存在。
原因:重命名脚本只处理了函数定义位置的标识符,没有同步处理动态调用处作为字符串存在的函数名。
解决:重命名函数时,把原名字到新名字的映射单独存一份,在处理字符串字面量时,凡是内容等于旧函数名的,一并替换。这一步必须基于完整的符号表,不能靠正则乱换,否则会把普通字符串内容也改掉。
6. 验证还原质量:语法、指纹与行为三重检查
6.1 每轮提取后先过语法和指纹
还原不是“看着像就行”,每一轮产物都要验证。语法检查用php -l,指纹用文件的哈希值做对比。每层解码产物的哈希都应该不同,如果连续两轮哈希一致,说明替换没生效,需要检查文件路径或输出缓存问题。
php -l out.php && md5sum out.php这一步也是给后续维护留底:每层产物的哈希记录保存好,将来如果怀疑某个环节出错,可以回溯定位是在哪一层开始变质的。
6.2 行为对比:只看关键出口
还原后代码能不能跑,不是唯一标准;更关键的是它跑的出口是否和原混淆代码一致。我习惯只挑两三个关键行为做对比,比如文件写入路径、数据库连接参数、外部命令调用。把还原前后的代码分别在隔离环境跑一次,对比输出日志里的关键行为是否吻合。还原过程改动了字符串引号和变量名,理论上不该改行为,一旦行为不一致,优先怀疑字符串折叠或动态调用替换出问题。
6.3 把流程沉淀成一条习惯
经过几次完整的还原工作后,你会总结出属于自己的检查清单:先搜执行点,再逐层解壳,中间每一层单独保存,最后统一做语法验证和行为对比。这个流程适配到新样本时,只需要改一下解码函数列表和最大轮数。我现在拿到新混淆样本,第一件事不是读代码,而是把上面这套流程跑一遍,让工具先铺路,人再来做语义判断。
多年处理混淆代码的习惯只有一个:永远不在目标环境里执行原始样本,永远在隔离环境里处理,永远保留每一层中间产物。还原代码是技术活,但安全边界才是底线。这个习惯帮我躲过不少麻烦,希望帮到你。
本文还有配套的精品资源,点击获取