样本
一个 DiscuzX 插件 keke_xzhseo.class.php
过程
代码格式化
参考之前的帖子PHP加密中的“VMProtect”——魔方加密反编译分析过程
大致浏览一下文件内容,可以看到 KIVIUQ VIRTUAL MACHINE ERROR : Access violation at address
(KIVIUQ虚拟机错误:在xxx地址处读取错误)这个东西,可以确定是魔方加密了。
魔方加密是一种基于虚拟机的加密,他将原本函数调用、运算符等操作,拆分成参数压栈、执行指令、结果出栈
这种步骤,所以“解密”是不可能,只能通过反编译的方式尝试还原代码。

分析虚拟机
更牛逼的代码格式化
- 为了方便阅读,我把乱码变量名替换成 $v0 这类的可读变量名了。
- 把通过 . 连接的字符串合成了一整个,然后把特别长的字符串输出到一个单独的文件 large_string_data.php,
- 方便以后使用。
- 由于后面破解过程中发现替换变量名对虚拟机有影响,所以我把 乱码变量名 => 可读变量名 输出到一个单独
- 的文件 variables_map.php,方便以后使用。
2018 年 03 月 01 日 nikic/php-parser 为了发展 PHP 7 更新了 4.0 版本,所以 format.php 的部分代码与
前面的帖子相比有所更改。有兴趣的同学可以研究我的代码是怎么写的,没兴趣的就看看就好了。
$GLOBALS['LARGE_STRING_DATA'] = (include 'large_string_data.php');
if (isset($v0)) {
array_push($v0, $v1, $v2, $v3, $v4, $v5);
} else {
$v0 = array();
}
static $v6 = null;
if (empty($v6)) {
$v6 = $GLOBALS['LARGE_STRING_DATA'][0];
}
$v1 = array(__FILE__);
$v2 = array(0);
$v3 = $v4 = $v5 = 0;
$v7 = $v8 = null;
try {
while (1) {
while ($v5 >= 0) {
$v8 = $v6[$v5++];
switch ($v8 ^ $v6[$v5++]) {
// 各种指令,此处省略
}
while ($v7-- > 0) {
$v8 .= $v8[0] ^ $v6[$v5++];
}
eval(substr($v8, 1));
}
if ($v5 == -1) {
break;
} elseif ($v5 == -2) {
eval($v2[$v4 - 1]);
$v5 = $v2[$v4];
$v4 -= 2;
} else {
exit('KIVIUQ VIRTUAL MACHINE ERROR : Access violation at address '
. ($v5 < 0 ? $v5 : sprintf('%08X', $v5)));
}
}
} catch (Exception $v8) {
if (!empty($v0)) {
$v5 = array_pop($v0);
$v4 = array_pop($v0);
$v3 = array_pop($v0);
$v2 = array_pop($v0);
$v1 = array_pop($v0);
}
throw $v8;
}
if (!empty($v0)) {
$v5 = array_pop($v0);
$v4 = array_pop($v0);
$v3 = array_pop($v0);
$v2 = array_pop($v0);
$v1 = array_pop($v0);
}
虚拟机的运行流程
大致浏览一下这段代码,通过分析可以知道,各个变量的含义,虚拟机的运行流程。
变量名 |
含义 |
$v0 |
虚拟机环境 |
$v1 |
栈 |
$v2 |
(未知,后文分析可知是报错等级栈) |
$v3 |
栈指针 |
$v4 |
(未知,后文分析可知是报错等级栈指针) |
$v5 |
内存指针 |
$v6 |
指令 + 指令集 + 数据(可以称之为内存,类似 .text 代码段) |
$v7 |
异或解码之后的数值,代表语句的字符串长度 |
$v8 |
临时变量(一个寄存器),用于异或解码,用于存储解密之后的指令,用于 try-catch 的异常变量 |
指令名称 |
含义 |
1 |
取 2 字节以内的字符串作为二级指令执行 |
2 |
取 4 字节以内的字符串作为二级指令执行 |
3 |
取 10 字节以内的字符串作为二级指令执行 |
a |
出栈 |
b |
栈解除引用 |
c |
压栈,压入 null |
d |
取数组元素或字符串中的字符 |
e |
取特殊变量,超全局变量和 this 特殊变量,或其他栈顶变量名的变量 |
fd |
取 100 字节以内的字符串压到栈顶 |
fq |
取 10^4 字节以内的字符串压到栈顶 |
fx |
取 10^10 字节以内的字符串压到栈顶 |
主循环 eip |
对应的操作 |
>= 0 |
继续虚拟机主循环,运行指令 |
-1 |
结束虚拟机主循环 |
-2 |
eval($v2[$v4 - 1]); $v5 = $v2[$v4]; $v4 -= 2; |
其他 |
虚拟机出错 |
运行结束后,从虚拟机环境 $v0 中依次弹出 $v5 $v4 $v3 $v2 $v1。
这里提到一个词——“二级指令”,这个词是我随便起的,就是上述的十几个指令是在虚拟机运行环境的代码中直接显式给出的,所以
称为“一级指令”,而二级指令就是指,解析出一个字符串然后再调用 eval 来执行的指令。
分析完虚拟机的逻辑之后,我们发现,不能像上一篇文章中的方法,直接分析每一条虚拟机指令,反编译出代码。我们必须跟随虚拟机的
运行,然后把每一条二级指令也还原出来,然后才能分析。
跟随虚拟机运行一下
我们可以改造一下这个虚拟机,在每一条指令执行时,输出他们做了什么事,以及他们的指令地址。
注意,我们需要用到 xdebug 来调试 php 程序,同时,最好选择一个 IDE 来辅助调试(我用的是 PHPStorm)。
代码在执行过程中,我们需要利用调试器,视情况调整一下环境:
- 如果虚拟机想要使用某些不存在的常量,我们可以提前定义常量,防止程序运行错误。
- 如果虚拟机想要使用某些不存在的变量,我们可以提前给他们赋值,防止程序运行错误。
- 如果虚拟机想要运行某个不存在的函数,我们可以直接跳过。
- 如果虚拟机想要进行条件跳转,我们可以改变跳转或不跳转。
改造虚拟机的过程
eval(substr($v8, 1));
改成
$v8 = str_replace(array_keys($GLOBALS['VARIABLES_MAP']), array_values($GLOBALS['VARIABLES_MAP']), $v8);
$code = substr($v8, 1);
echo $code, PHP_EOL;
$is_eval = true;
if ($is_eval) {
eval(substr($v8, 1));
}
然后在 if ($is_eval) { 这句下断点,每次执行到这里,如果想跳过本条语句的话,就 $is_eval = false;

可以大致感觉到执行一条语句的大致过程是:
- 压栈,压入 null
- 取函数名
- 取变量(特殊变量/字符串),作为第一个参数
- 继续取变量,作为第二个参数
- 取二级指令并执行(可能是调用函数、连接字符串等等)
- 出栈
- 使用引用+赋值+解除引用的方式,把结果传递到某个变量
反汇编
基本的反汇编
反汇编,就是脱离运行环境,分析机器指令。照着虚拟机的逻辑改就行了。
00000000 - 00000001 压入null
00000002 - 0000000D 压入字符串 defined
0000000E - 00000049 执行二级指令 $v1[++$v3]="\111\116\137\104\111\123\103\125\132";
0000004A - 0000007F 执行二级指令 $v1[$v3-2]=$v1[$v3-1]($v1[$v3]);
00000080 - 00000081 出栈
00000082 - 00000083 出栈
00000084 - 00000085 解除引用
00000086 - 000000A8 执行二级指令 $v1[$v3]=!$v1[$v3];
000000A9 - 000000D0 执行二级指令 if($v1[$v3])$v5=0x000000E9;
000000D1 - 000000D2 出栈
000000D3 - 000000E8 执行二级指令 $v5=0x0000012E;
000000E9 - 000000EA 出栈
000000EB - 000000FC 压入字符串 Access Denied
000000FD - 00000115 执行二级指令 exit($v1[$v3]);
00000116 - 00000117 出栈
00000118 - 0000012D 执行二级指令 $v5=0x0000012E;
0000012E - 0000012F 压入null
00000130 - 0000013D 执行二级指令 $v5=-1;
内存越界
内存越界是因为我是按顺序反汇编一级指令,然后编码解密二级指令,没有实际运行二级指令,所以不知道程序什么时候终止(就是还不知道 $v5=-1; 是什么)。其实就是代码没了,强行终止了。不用管这个。
上面这段指令,对应的代码其实就是
if (!defined('IN_DISCUZ')) {
exit('Access Denied');
}
增强的反汇编
只是像这样简单地反汇编还不行,我们必须把每一条二级指令的代码都想办法拆分成指令+数据的形式,然后才能供反编译使用。
这里列举一些简单的二级指令(指令集可能不止这些)。
// 取数据
$stack[$esp] = ???;
// 条件跳转
if ($stack[$esp]) $eip = 0x????;
// 无条件跳转
$eip = 0x????;
// 调用函数
$stack[$esp - 1] = $stack[$esp]();
$stack[$esp - 2] = $stack[$esp - 1]($stack[$esp]);
$stack[$esp - 3] = $stack[$esp - 2]($stack[$esp - 1], $stack[$esp]);
// 比较大小、算数运算、字符串链接等等
由于指令较多(共有数十种),具体指令集请参考成品代码。
结果像下面这样
00000000 - 0000000E global $_G
0000000F - 00000022 global $article
00000023 - 00000024 压入null
00000025 - 00000030 压入字符串 defined
00000031 - 0000004C 压入字符串 CLOUDADDONS_WEBSITE_URL
0000004D - 00000082 调用函数 1
00000083 - 00000084 出栈
00000085 - 00000086 出栈
00000087 - 00000088 解除引用
00000089 - 000000AB 取非
000000AC - 000000D3 条件跳转 000000EC
000000D4 - 000000D5 出栈
000000D6 - 000000EB 无条件跳转 000001E7
000001E7 - 000001E8 压入null
000001E9 - 00000207 压入常量 DISCUZ_ROOT
00000208 - 00000236 压入字符串 source/plugin/keke_xzhseo/identity.inc.php
00000237 - 0000026B 字符串连接
0000026C - 00000281 无条件跳转 00000288
00000288 - 00000289 出栈
0000028A - 000002B9 include 2
000002BA - 000002BB 出栈
000002BC - 000002D8 压入空数组
000002D9 - 000002E2 压入字符串 check
000002E3 - 000002E4 引用变量
000002E5 - 00000308 栈内赋值 1
00000309 - 0000030A 解除引用
0000030B - 0000030C 出栈
0000030D - 0000030E 出栈
0000030F - 00000310 压入null
00000311 - 0000031B 压入字符串 substr
0000031C - 0000031D 压入null
0000031E - 00000340 压入字符串 md5
00000341 - 00000350 压入字符串 keke_xzhseo
00000351 - 00000357 压入字符串 _G
00000358 - 00000359 引用变量
0000035A - 00000365 压入字符串 siteurl
00000366 - 00000367 取数组元素
00000368 - 00000369 出栈
0000036A - 0000036B 解除引用
0000036C - 000003A0 字符串连接
000003A1 - 000003A2 出栈
000003A3 - 000003B8 无条件跳转 000003BF
000003BF - 000003F4 调用函数 1
000003F5 - 000003F6 出栈
000003F7 - 0000040C 无条件跳转 00000413
00000413 - 00000414 出栈
00000415 - 00000416 解除引用
00000417 - 0000042D 压入数字 0
0000042E - 00000444 压入数字 7
00000445 - 0000049C 调用函数 3
0000049D - 0000049E 出栈
0000049F - 000004B4 无条件跳转 000004BB
000004BB - 000004BC 出栈
000004BD - 000004BE 出栈
000004BF - 000004C0 出栈
000004C1 - 000004D6 无条件跳转 000004DD
000004DD - 000004DE 解除引用
000004DF - 000004E8 压入字符串 uskey
000004E9 - 000004EA 引用变量
000004EB - 0000050E 栈内赋值 1
0000050F - 00000510 解除引用
00000511 - 00000512 出栈
00000513 - 00000514 出栈
00000515 - 00000516 压入null
00000517 - 00000524 压入字符串 loadcache
00000525 - 00000550 压入字符串 uskey
00000551 - 00000552 引用变量
00000553 - 00000588 调用函数 1
00000589 - 0000059E 无条件跳转 000005A5
000005A5 - 000005A6 出栈
000005A7 - 000005A8 出栈
000005A9 - 000005AA 解除引用
000005AB - 000005AC 出栈
000005AD - 000005B3 压入字符串 _G
000005B4 - 000005B5 引用变量
000005B6 - 000005E1 压入字符串 cache
000005E2 - 000005E3 取数组元素
000005E4 - 000005E5 出栈
000005E6 - 000005FB 无条件跳转 00000602
00000602 - 0000060B 压入字符串 uskey
你可以看到这里多出了许多指令,比如 global, 调用函数, 取非, 条件跳转, 无条件跳转,这些指令就是解析之后的二级指令。
现在我们反汇编之后的结果是“线性的”了,可以被反编译了。
DFS反汇编
你或许以为上面得到的反汇编指令是很容易的,其实不是这样的,这些指令中有一些“花指令”,就像下面这样。
0000026C - 00000281 无条件跳转 00000288
00000288 - 00000289 出栈
这里的 00000282 - 00000288 之间的指令没法执行,由于指令长短不一样,这段花指令打乱了原本解析过程,所以必须要用较高级的方法。
- 如果遇到无条件跳转,直接跳转。
- 如果遇到条件跳转指令,分成两个分支来解析。遇到分支则继续分下去(递归),直到解析的指令之前已经解析过了、或跳转到 -1(跳转到 -1 就类似 return 语句,代表结束虚拟机),直到已经解析完所有指令。
- 最后按指令在虚拟机中出现的顺序排序即可。
简而言之,这就是一个深度优先搜索(DFS)。
通过这一步骤,我们真正把所有有用的指令提取出来了,没用的指令直接抛弃了,已经真正脱离了虚拟机了,我们得到的可以称之为更为通用的字节码了。
指令分块(链表到图)
顺序的指令都很好解析,也很好反编译,分支结构是比较麻烦的,最麻烦的就是循环结构。为了方便之后分析程序流程,这里可以先把“线性”的反汇编程序转换为无序的“向量图”。
我采用的方法也是比较好理解的:
- 在所有与跳转有关的位置(跳出和跳入)将代码分块,保证每块中最多 1 个跳转,且跳转指令必须是最后一条。
- 遍历每一个分块,分析每一块结束时跳转的去向,构造成一个图。
- 跳转到 -1 的块将最后跳转到 -1 的指令改成 return 指令。
- 对图进行一些拓扑变换,简化图,例如把连续几个直线串起来的块合成一个等等。(这一步不是必须的,因为后面的进行流程分析,自然会把无分支的指令连成一整块的)
如果用流程图可视化地表示一下,大概就是这样的。

分块之后由于没有了块内跳转,所以我们不再需要每一条指令的地址了,我们只需要给每个分块一个独立的 id 即可。同时也没有了“跳转”这种说法了,无条件跳转变成了连续的指令了,条件跳转变成了分支(或者循环)了。
用过 IDA 或 x64dbg 的同学可能对这种图比较熟悉了。
反编译
分析流程
前面说了,反编译线性的指令很简单,条件分支和循环比较复杂,复杂就因为他们的流程有分支、有层次结构,不能使用循环来解决,需要使用递归才比较方便。
在我尝试反编译的时候,个人感觉各种指令的反编译,最简单的就是线性代码了,其次就是单分支结构 if,然后就是循环 while、for 等,最麻烦的就是 break 和 continue 了。
我采用的方案如下:
- 线性代码一直运行。
- 遇到条件分支采用 DFS 分析,先走 yes 再走 no。
- 遇到循环则记录当前环的所有顶点。然后退回到最后一个条件分支,如果刚才是 yes 分支,则继续尝试走 no 分支,如果已经是 no 分支了,则开始分析这个“条件分支构成的循环”。
- 分析“条件分支构成的循环”的方法:将“条件分支构成的循环”转换为“无条件循环” + if-break 语句。
- 遇到终点则正常回退到最后的条件分支,执行另一个分支或执行分析。
- 如果没有构成循环,分析普通条件分支的方法:将条件分支转换为 if 语句,yes、no 分别构成 stmts 和 else 块。
-
假设不存在循环交叉(即假设变异前没有极其变态的 goto 语句)。
- 如果遇到无条件跳转,直接跳转。
- 如果遇到条件跳转指令,保存当前反汇编器的指针位置,以及一些其他的状态信息,然后分成两个分支来解析。两个分支顺序解析,直到遇到另一个
- 分支或者虚拟机退出指令,交换分支的控制权,直到两个分支合成一个分支时结束,继续按一个分支解析。此条语句记为 if。
- 同时建立一个已经分析过的地址列表,如果跳往分析过的,则记录为 while。
说了半天就是使用 BFS(广度优先搜索)分析语法分支
最开始,反汇编、指令分块与分析流程这几步是同时进行的,直接采用 BFS 来反汇编、分块、构造 if 和 while 结构。后来感觉代码越写越复杂,分析
了一下每个步骤可以独立开来,就使用 DFS 反汇编(因为 DFS 代码比 BFS 简单),然后简单地根据跳转分块并优化,最后使用 BFS 分析流程。这样感觉的确清晰了不少。
举个例子
00000001 条件跳转 00000004
00000002 指令块1
00000003 无条件跳转 00000006
00000004 指令块2
00000005 无条件跳转 00000008
00000006 指令块3
00000007 无条件跳转 00000009
00000008 指令块4
00000009 指令块5
我们解析的结果应该是
if ($stack[$esp]) {
指令块2
指令块4
} else {
指令块1
指令块3
}
指令块5
再举个例子
00000001 条件跳转 00000004
00000002 指令块1
00000003 无条件跳转 00000001
00000004 指令块2
解析得到
while ($stack[$esp]) {
指令块1
}
指令块2
经过我们不懈的努力,上文的第一段反汇编程序(就是这段 if (!defined('IN_DISCUZ')) { exit('Access Denied'); }),分块结果如下
压入null
压入字符串 defined
压入字符串 IN_DISCUZ
调用函数 1
出栈
出栈
解除引用
取非
如果
出栈
压入字符串 Access Denied
exit
出栈
否则
出栈
压入null
反编译
普通的反编译
普通的反编译,原理很简单,指令对栈做了什么操作,我们也就同样根据他的操作构造抽象语法树(AST),构建 AST 正好是编译的逆过程。
由于魔方1代加密是一种仅基于栈的指令集,没有寄存器的存在,反编译算法会变得简单。
比如刚才那段指令,构建 AST 用的栈的内容变化就是这样的
- null
- null, 'defined'
- null, 'defined', 'IN_DISCUZ'
- defined('IN_DISCUZ'), 'defined', 'IN_DISCUZ'
- defined('IN_DISCUZ'), 'defined'
- defined('IN_DISCUZ')
- defined('IN_DISCUZ')
- !defined('IN_DISCUZ')
- if (!defined('IN_DISCUZ')) {} else {}
- stmts 块:
- 'Access Denied'
- exit('Access Denied');
- else 块:
- if (!defined('IN_DISCUZ')) { exit('Access Denied'); } else {}
这样就还原出来了这段指令对应的源码。
表达式和语句
实践中,你可能会发现,这种方法看上去很简单,但是也是存在一些问题的。比如,如何区分表达式 Expression 和语句 Statement,有些表
达式会影响运行环境,而他们运行完不会返回运行结果给栈(或者运行结果被抛弃),如果这时下一条语句是“出栈”的话,将在 AST 中出现一
个单独的表达式。在 PHP 中表达式是不能充当语句的,他后面必须有一个分号才可以构成一个语句,我们必须得想想方法。
最后我想到一个好办法,把所有已经被使用过的表达式添加一个 used 属性,每当一个表达式被丢弃的时候(出栈或者解除引用都会使表达
式从栈中被移除),如果这个表达式没有被使用过,则使用这个表达式构建一条语句,放到 AST 中。如果出栈的本来就是语句,那就直接放到 AST 中就行了,不需要其他处理。
if 语句、逻辑短路、三元运算符
If statement, Logical Short-Circuit, Ternary 这三个东西都可以通过条件跳转来表示,只不过三个东西对栈的操作不同
if 语句会在判断之后就直接抛弃判断条件,stmts 块和 else 块都会紧跟一个出栈,最终的栈会比执行之前少一层(把判断条件出栈了)。
if ($cond)
{stmts}
else
{else}
压入 $cond
如果
出栈
{stmts}
否则
出栈
{else}
逻辑短路,通常是“逻辑或”短路,stmts 块为空,else 块都会紧跟一个出栈,但随后还会再压入一个值,最终的栈和执行之前平衡。
如果和上面的情况相反,else 块为空,则是“逻辑与”短路。
$a or $b
压入 $a
如果
否则
出栈
压入 $b
三元运算符算是前面两个的结合体,stmts 块和 else 块都会紧跟一个出栈,两个块随后都还会再压入一个值,最终的栈和执行之前平衡。
$cond ? $a : $b
压入 $cond
如果
出栈
压入 $a
否则
出栈
压入 $b
我们可以通过判断 stmts 块和 else 块来区分三者,也可以通过最终的栈和之前的栈进行对比来区分。(我选择了第二种,容错性高,而且出现意外错误可以抛出异常)
循环
0000022E - 0000023B 压入字符串 checkdirs
0000023C - 0000023D 引用
0000023E - 0000023F 解除引用
00000240 - 00000259 reset
0000025A - 0000025F 压入字符串 k
00000260 - 00000261 引用
00000262 - 00000269 压入字符串 dir
0000026A - 0000026B 引用
0000026C - 00000306 调用函数 0
00000307 - 0000032E 条件跳转 00000347
0000032F - 00000330 出栈
00000331 - 00000346 无条件跳转 00000DA3
00000347 - 00000348 出栈
中间省去一部分指令
00000B3E - 00000B4B 压入字符串 writeable
00000B4C - 00000B4D 引用
00000B4E - 00000B4F 解除引用
00000B50 - 00000B72 boolean_not
00000B73 - 00000B9A 转换为bool
00000B9B - 00000BC2 条件跳转 00000BF5
00000BC3 - 00000BD8 无条件跳转 00000C2B
00000BF5 - 00000BF6 出栈
00000BF7 - 00000BFE 压入字符串 dir
00000BFF - 00000C00 引用
00000C01 - 00000C02 解除引用
00000C03 - 00000C2A 转换为bool
00000C2B - 00000C52 条件跳转 00000C87
00000C53 - 00000C54 出栈
00000C55 - 00000C6A 无条件跳转 00000C71
00000C71 - 00000C86 无条件跳转 00000D72
00000C87 - 00000C88 出栈
00000C89 - 00000C90 压入字符串 dir
00000C91 - 00000C92 引用
00000C93 - 00000CA8 无条件跳转 00000CAF
00000CAF - 00000CB0 解除引用
00000CB1 - 00000CBB 压入字符串 return
00000CBC - 00000CBD 引用
00000CBE - 00000D15 数组元素获取 0
00000D16 - 00000D2B 无条件跳转 00000D32
00000D32 - 00000D55 赋值 0 1
00000D56 - 00000D57 解除引用
00000D58 - 00000D59 出栈
00000D5A - 00000D5B 出栈
00000D5C - 00000D71 无条件跳转 00000D72
00000D72 - 00000D8C next
00000D8D - 00000DA2 无条件跳转 0000026C
0000026C
reset($checkdirs);
if ($k = $dir()) {
} else {
goto loop_end;
}
loop_start:
// 中间省去一部分指令
if (!$writeable || $dir) {
$return[] = $dir;
}
next($checkdirs);
goto loop_start;
loop_end:
等价转换一下
reset($checkdirs);
while ($k = $dir()) {
// 中间省去一部分指令
if (!$writeable || $dir) {
$return[] = $dir;
} else {
break;
}
next($checkdirs);
}
继续分析所有指令
想要全自动解析整个文件,偷懒是不行的,必须得把每一种指令都匹配出来,然后再手动写好每一种指令的构造 AST 的代码。
自动反编译与手动修改之后的对照
汇编语言
00000000 压入常量 false
0000001B 压入字符串 prefix
00000026 引用
00000028 赋值 0 1
0000004C 解除引用
0000004E 出栈 1
00000050 出栈 1
00000052 压入字符串 prefix
0000005D 引用
0000005F 解除引用
00000061 压入常量 false
0000007C 完全相同
000000B3 出栈 1
000000B5 条件跳转 000000F5
000000DD 出栈 1
000000DF 无条件跳转 00000226
000000F5 出栈 1
000000F7 压入常量 null
000000F9 压入字符串 strlen
00000104 压入字符串 dir
0000010C 引用
0000010E 调用函数 1
00000144 出栈 1
00000146 出栈 1
00000148 解除引用
0000014A 压入数字 1
00000161 相加
00000196 无条件跳转 000001B2
000001B2 出栈 1
000001B4 压入字符串 prefix
000001E4 引用
000001E6 赋值 0 1
0000020A 解除引用
0000020C 出栈 1
0000020E 出栈 1
00000210 无条件跳转 00000226
00000226 压入常量 null
00000228 压入字符串 opendir
00000234 压入字符串 dir
0000023C 引用
0000023E 调用函数 1
00000274 出栈 1
00000276 出栈 1
00000278 解除引用
0000027A 压入字符串 dh
00000281 引用
00000283 赋值 0 1
000002A7 解除引用
000002A9 出栈 1
000002AB 出栈 1
000002AD 压入常量 null
000002AF 压入字符串 readdir
000002BB 压入字符串 dh
000002DB 引用
000002DD 调用函数 1
00000313 出栈 1
00000315 出栈 1
00000317 解除引用
00000319 压入字符串 file
00000322 引用
00000324 无条件跳转 00000340
00000340 赋值 0 1
00000364 解除引用
00000366 出栈 1
00000368 压入常量 false
00000383 完全相同
000003BA 出栈 1
000003BC 取非
000003DF 条件跳转 0000041F
00000407 出栈 1
00000409 无条件跳转 00000CB3
0000041F 出栈 1
00000421 压入字符串 file
0000042A 引用
0000042C 解除引用
0000042E 压入字符串 .
00000434 相等
0000046A 出栈 1
0000046C 取非
0000048F 转换为bool
000004B7 条件跳转 000004F5
000004DF 无条件跳转 000005C9
000004F5 出栈 1
000004F7 压入字符串 file
0000051F 引用
00000521 解除引用
00000523 压入字符串 ..
0000052A 相等
00000560 无条件跳转 0000057C
0000057C 出栈 1
0000057E 取非
000005A1 转换为bool
000005C9 条件跳转 00000609
000005F1 出栈 1
000005F3 无条件跳转 00000C9D
00000609 出栈 1
0000060B 压入字符串 dir
00000613 引用
00000615 解除引用
00000617 压入字符串 /
0000061D 无条件跳转 00000639
00000639 字符串链接
0000066E 出栈 1
00000670 压入字符串 file
00000679 引用
0000067B 解除引用
0000067D 字符串链接
000006B2 出栈 1
000006B4 无条件跳转 000006D0
000006D0 压入字符串 readfile
000006DD 引用
000006DF 赋值 0 1
00000703 解除引用
00000705 出栈 1
00000707 出栈 1
00000709 压入常量 null
0000070B 压入字符串 is_dir
00000716 压入字符串 readfile
00000723 引用
00000725 调用函数 1
0000075B 出栈 1
0000075D 出栈 1
00000779 解除引用
0000077B 条件跳转 000007BB
000007A3 出栈 1
000007A5 无条件跳转 00000C87
000007BB 出栈 1
000007BD 压入字符串 root
000007C6 引用
000007C8 解除引用
000007CA 压入字符串 /
000007E5 字符串链接
0000081A 出栈 1
0000081C 压入常量 null
0000081E 压入字符串 substr
00000829 压入字符串 readfile
00000836 引用
00000838 压入字符串 prefix
00000843 引用
00000845 调用函数 2
0000088C 无条件跳转 000008A8
000008A8 出栈 1
000008AA 出栈 1
000008AC 出栈 1
000008AE 解除引用
000008B0 字符串链接
000008E5 出栈 1
000008E7 压入字符串 return
000008F2 引用
00000AF3 数组元素获取 0
00000B4B 赋值 0 1
00000B6F 解除引用
00000B71 出栈 1
00000B73 出栈 1
00000B75 压入常量 null
00000B77 无条件跳转 00000B93
00000B93 压入字符串 cloudaddons_getsubdirs
00000BAE 无条件跳转 00000BCA
00000BCA 压入字符串 readfile
00000BD7 引用
00000BD9 压入字符串 root
00000BE2 引用
00000BE4 压入字符串 return
00000BEF 引用
00000BF1 调用函数 3
00000C49 出栈 1
00000C4B 出栈 1
00000C4D 出栈 1
00000C4F 出栈 1
00000C51 解除引用
00000C53 出栈 1
00000C55 无条件跳转 00000C87
00000C87 无条件跳转 00000C9D
00000C9D 无条件跳转 000002AD
00000CB3 压入常量 null
00000CB5 无条件跳转 -1
自动反编译结果
${'prefix'} = false;
if (${'prefix'} === false) {
${'prefix'} = ('strlen')(${'dir'}) + 1;
} else {
}
${'dh'} = ('opendir')(${'dir'});
while (true) {
${'file'} = ('readdir')(${'dh'});
if (!(('readdir')(${'dh'}) === false)) {
} else {
return null;
}
if ((bool) (!(${'file'} == '.')) and (bool) (!(${'file'} == '..'))) {
${'readfile'} = ${'dir'} . '/' . ${'file'};
if (('is_dir')(${'readfile'})) {
${'return'}[] = ${'root'} . '/' . ('substr')(${'readfile'}, ${'prefix'});
('cloudaddons_getsubdirs')(${'readfile'}, ${'root'}, ${'return'});
} else {
}
} else {
}
}
手动反编译结果
$prefix = false;
if ($prefix === false) {
$prefix = strlen($dir) + 1;
}
$dh = opendir($dir);
while ($file = readdir($dh)) {
if ($file != '.' && $file != '..') {
$readfile = $dir . '/' . $file;
if (is_dir($readfile)) {
$return[] = $root . '/' . substr($readfile, $prefix);
cloudaddons_getsubdirs($readfile, $root, $return);
}
}
}
return null;
可以看出来,还是有一定差距的,某些问题还是出在循环语句上。
变量引用追踪
一个变量在被引用的时候是可以被赋值的,解除引用之后只能在赋值号右边,是只读的,不能更改原来的变量,也不能作为引用参数传给函数。
变量引用计数
000002AD 压入常量 null
000002AF 压入字符串 readdir
000002BB 压入字符串 dh
000002DB 引用
000002DD 调用函数 1
00000313 出栈 1
00000315 出栈 1
00000317 解除引用
00000319 压入字符串 file
00000322 引用
00000324 无条件跳转 00000340
00000340 赋值 0 1
00000364 解除引用
00000366 出栈 1
00000368 压入常量 false
00000383 完全相同
000003BA 出栈 1
000003BC 取非
000003DF 条件跳转 0000041F
这段代码,正常来说,反编译结果会是
$file = readdir($dh);
if (!(readdir($dh) === false)) {
但实际上,应该是
if (!(($file = readdir($dh)) === false)) {
这个虚拟机在栈中出现逆序赋值是很奇怪的,虚拟机代码是 $stack[$esp] = $stack[$esp - 1]; 用下层栈的内容改写上层栈,这个不符合先入先出原则。
尽管这个写法很别扭,但是既然别人已经做出来了,我们就要想办法弥补。我采用的方法是“引用计数”,这是一种垃圾回收的方式,
我们在最后一次这个变量从栈中消失的时候,把表达式从栈中移动到 AST 中并转换为语句。
代码简化
逻辑运算简化
(bool) ((bool) $_GET['aid'] or (bool) $_G['tid']) or (bool) (CURSCRIPT == 'admin')
化简为
$_GET['aid'] || $_G['tid'] || CURSCRIPT == 'admin'
非运算简化
!($file == '.')
化简为
$file != '.'
While、Foreach语句简化
while (true) {
if (!(($file = readdir($dh)) === false)) {
if ((bool) (!($file == '.')) and (bool) (!($file == '..'))) {
$readfile = $dir . '/' . $file;
if (is_dir($readfile)) {
$return[] = $root . '/' . substr($readfile, $prefix);
cloudaddons_getsubdirs($readfile, $root, $return);
}
}
} else {
break;
}
}
化简为
while ($file = readdir($dh)) {
if ($file != '.' && $file != '..') {
$readfile = $dir . '/' . $file;
if (is_dir($readfile)) {
$return[] = $root . '/' . substr($readfile, $prefix);
cloudaddons_getsubdirs($readfile, $root, $return);
}
}
}
ElseIf 简化
if ($lx == 1) {
$where = '&queryType=0&sortType=5';
} else {
if ($lx == 2) {
$where = '&sortType=9&shopTag=';
} else {
if ($lx == 3) {
$where = '&sortType=4&shopTag=';
} else {
if ($lx == 4) {
$where = '&dpyhq=1&shopTag=dpyhq';
}
}
}
}
化简为
if ($lx == 1) {
$where = '&queryType=0&sortType=5';
} elseif ($lx == 2) {
$where = '&sortType=9&shopTag=';
} elseif ($lx == 3) {
$where = '&sortType=4&shopTag=';
} elseif ($lx == 4) {
$where = '&dpyhq=1&shopTag=dpyhq';
}
全自动解析
- 先格式化代码,把指令数据提取出来。
- 便利格式化之后的代码,匹配虚拟机的代码,找出虚拟机的栈、栈指针、指令指针等变量的名称。
- 根据刚才找出的虚拟机变量,以及找到的指令数据反汇编并分块
- 反编译这部分指令。
- 代码简化。
- 把虚拟机部分挖掉,换上反编译之后的指令。
未完待续
这里的原理暂时还没有讲完
之后可能会做一个在线解析
程序代码有兴趣的可以在 GitHub 上自行搜索 mfenc-decompiler
反编译代码简介
目前不保证反编译结果的正确性,仅供参考。
反汇编和结构化之后的汇编指令应该没什么问题。
用法
use Ganlv\MfencDecompiler\AutoDecompiler;
use Ganlv\MfencDecompiler\Helper;
require __DIR__ . '/../vendor/autoload.php';
file_put_contents(
$output_file,
Helper::prettyPrintFile(
AutoDecompiler::autoDecompileAst(
Helper::parseCode(
file_get_contents($input_file)
)
)
)
);
源代码文件

DfsDisassembler.php 主反汇编器(DFS算法)
Disassembler1.php 一级指令反汇编器
Disassembler2.php 二级指令反汇编器
instructions.php 二级指令匹配列表
GraphViewer.php 反汇编指令列表->有向图转换器
DirectedGraph.php 有向图类
DirectedGraphSimplifier.php 用于简化有向图的抽象类
DirectedGraphSimpleSimplifier.php 简单地合并1进1出和没有指令的节点
DirectedGraphStructureSimplifier.php 分析流程结构生成if、loop、break等语句
BaseDecompiler.php 基础反编译器
Decompiler.php 反编译指令
Beautifier.php 反编译后代码美化
VmDecompiler.php 自动将从ast中找到VM,并对其进行反编译的类
AutoDecompiler.php 全自动反汇编器
Helper.php 助手函数
Formatter.php 测试过程中用于把乱码变量名替换成英文
instructions_display_format.php 指令翻译
部分结果展示
keke_xzhseo.class.php

123.txt

comiis_admin.inc.php

附件
examples.zip
附件中不包含反编译器!不包含反编译器!需要代码自行到 GitHub 搜索
包含:
- 我自己找的样本 keke_xzhseo.class.php 及反编译结果(Discuz!插件)
- 来自 某PHP加密文件调试解密过程 中 @索马里的海贼 的回帖 中的样本 123.txt 及反编译之后的结果(微擎应用)
- @jane35622 的帖子 【原创】PHP 魔方一代加密 逆向调试过程笔记外加讨论 中的样本 comiis_admin.inc.php 及反编译之后的结果(Discuz!插件)