樣本
一個 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 語句。
- 遇到終點則正;赝说阶詈蟮臈l件分支,執行另一個分支或執行分析。
- 如果沒有構成循環,分析普通條件分支的方法:將條件分支轉換為 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!插件)