初探PHP-Parser
PHP-Parser是nikic用PHP編寫的PHP5.2到PHP7.4解析器,其目的是簡化靜态代碼分析和操作。
Parsing
建立一個解析器執行個體:
use PhpParser\ParserFactory;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
ParserFactory接收以下幾個參數:
ParserFactory::PREFER_PHP7:優先解析PHP7,如果PHP7解析失敗則将腳本解析成PHP5
ParserFactory::PREFER_PHP5:優先解析PHP5,如果PHP5解析失敗則将腳本解析成PHP7
ParserFactory::ONLY_PHP7:隻解析成PHP7
ParserFactory::ONLY_PHP5:隻解析成PHP5
将PHP腳本解析成抽象文法樹(AST)
use PhpParser\Error;
use PhpParser\ParserFactory;
require 'vendor/autoload.php';
$code = file_get_contents("./test.php");
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($code);
} catch (Error $error) {
echo "Parse error: {$error->getMessage()}\n";
}
var_dump($ast);
?>
Node dumping
如果是用上面的var_dump的話顯示的AST可能會比較亂,那麼我們可以使用NodeDumper生成一個更加直覺的AST
use PhpParser\NodeDumper;
$nodeDumper = new NodeDumper;
echo $nodeDumper->dump($stmts), "\n";
或者我們使用vendor/bin/php-parse也是一樣的效果
λ vendor/bin/php-parse test.php
====> File test.php:
==> Node dump:
array(
0: Stmt_Expression(
expr: Expr_Assign(
var: Expr_Variable(
name: a
)
expr: Scalar_LNumber(
value: 1
)
)
)
)
Node tree structure
PHP是一個成熟的腳本語言,它大約有140個不同的節點。但是為了友善使用,将他們分為三類:
PhpParser\Node\Stmts是語句節點,即不傳回值且不能出現在表達式中的語言構造。例如,類定義是一個語句,它不傳回值,你不能編寫類似func(class {})的語句。
PhpParser\Node\expr是表達式節點,即傳回值的語言構造,是以可以出現在其他表達式中。如:$var (PhpParser\Node\Expr\Variable)和func() (PhpParser\Node\Expr\FuncCall)。
PhpParser\Node\Scalars是表示标量值的節點,如"string" (PhpParser\Node\scalar\string)、0 (PhpParser\Node\scalar\LNumber) 或魔術常量,如"FILE" (PhpParser\Node\scalar\MagicConst\FILE) 。所有PhpParser\Node\scalar都是延伸自PhpParser\Node\Expr,因為scalar也是表達式。
需要注意的是PhpParser\Node\Name和PhpParser\Node\Arg不在以上的節點之中
Pretty printer
使用PhpParser\PrettyPrinter格式化代碼
use PhpParser\Error;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter;
require 'vendor/autoload.php';
$code = file_get_contents('./index.php');
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($code);
} catch (Error $error) {
echo "Parse error: {$error->getMessage()}\n";
return;
}
$prettyPrinter = new PrettyPrinter\Standard;
$prettyCode = $prettyPrinter->prettyPrintFile($ast);
echo $prettyCode;
Node traversation
使用PhpParser\NodeTraverser我們可以周遊每一個節點,舉幾個簡單的例子:解析php中的所有字元串,并輸出
use PhpParser\Error;
use PhpParser\ParserFactory;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PhpParser\Node;
require 'vendor/autoload.php';
class MyVisitor extends NodeVisitorAbstract{
public function leaveNode(Node $node)
{
if ($node instanceof Node\Scalar\String_)
{
echo $node -> value,"\n";
}
}
}
$code = file_get_contents("./test.php");
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$traverser = New NodeTraverser;
$traverser->addVisitor(new MyVisitor);
try {
$ast = $parser->parse($code);
$stmts = $traverser->traverse($ast);
} catch (Error $error) {
echo "Parse error:{$error->getMessage()}\n";
return;
}
?>
周遊php中出現的函數以及類中的成員方法
class MyVisitor extends NodeVisitorAbstract{
public function leaveNode(Node $node)
{
if( $node instanceof Node\Expr\FuncCall
|| $node instanceof Node\Stmt\ClassMethod
|| $node instanceof Node\Stmt\Function_
|| $node instanceof Node\Expr\MethodCall
) {
echo $node->name,"\n";
}
}
}
替換php腳本中函數以及類的成員方法函數名為小寫
class MyVisitor extends NodeVisitorAbstract{
public function leaveNode(Node $node)
{
if( $node instanceof Node\Expr\FuncCall) {
$node->name->parts[0]=strtolower($node->name->parts[0]);
}elseif($node instanceof Node\Stmt\ClassMethod){
$node->name->name=strtolower($node->name->name);
}elseif ($node instanceof Node\Stmt\Function_){
$node->name->name=strtolower($node->name->name);
}elseif($node instanceof Node\Expr\MethodCall){
$node->name->name=strtolower($node->name->name);
}
}
}
需要注意的是所有的visitors都必須實作PhpParser\NodeVisitor接口,該接口定義了如下4個方法:
public function beforeTraverse(array $nodes);
public function enterNode(\PhpParser\Node $node);
public function leaveNode(\PhpParser\Node $node);
public function afterTraverse(array $nodes);
beforeTraverse方法在周遊開始之前調用一次,并将其傳遞給調用周遊器的節點。此方法可用于在周遊之前重置值或準備周遊樹。
afterTraverse方法與beforeTraverse方法類似,唯一的差別是它隻在周遊之後調用一次。
在每個節點上都調用enterNode和leaveNode方法,前者在它被輸入時,即在它的子節點被周遊之前,後者在它被離開時。
這四個方法要麼傳回更改的節點,要麼根本不傳回(即null),在這種情況下,目前節點不更改。
other
其餘的知識點可以參考官方的,這裡就不多贅述了。
Documentation for version 4.x (stable; for running on PHP >= 7.0; for parsing PHP 5.2 to PHP 7.4).
Documentation for version 3.x (unsupported; for running on PHP >= 5.5; for parsing PHP 5.2 to PHP 7.2).
PHP代碼混淆
下面舉兩個php混淆的例子,比較簡單(鄭老闆@zsx所說的20分鐘内能解密出來的那種),主要是加深一下我們對PhpParser使用
phpjiami
大部分混淆都會把代碼格式搞得很亂,用PhpParser\PrettyPrinter格式化一下代碼
use PhpParser\Error;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter;
require 'vendor/autoload.php';
$code = file_get_contents('./test.php');
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($code);
} catch (Error $error) {
echo "Parse error: {$error->getMessage()}\n";
return;
}
$prettyPrinter = new PrettyPrinter\Standard;
$prettyCode = $prettyPrinter->prettyPrintFile($ast);
file_put_contents('en_test.php', $prettyCode);
格式基本能看了

因為函數和變量的亂碼讓我們之後的調試比較難受,是以簡單替換一下混淆的函數和變量
use PhpParser\Error;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
require 'vendor/autoload.php';
$code = file_get_contents("./index_1.php");
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$nodeFinder = new NodeFinder;
try {
$ast = $parser->parse($code);
} catch (Error $error) {
echo "Parse error:{$error->getMessage()}\n";
return;
}
$Funcs = $nodeFinder->findInstanceOf($ast, PhpParser\Node\Stmt\Function_::class);
$map=[];
$v=0;
foreach ($Funcs as $func)
{
$funcname=$func->name->name;
if(!isset($map[$funcname]))
{
if (!preg_match('/^[a-z0-9A-Z_]+$/', $funcname))
{
$code=str_replace($funcname,"func".$v,$code);
$v++;
$map[$funcname]=$v;
}
}
}
$v = 0;
$map = [];
$tokens = token_get_all($code);
foreach ($tokens as $token) {
if ($token[0] === T_VARIABLE) {
if (!isset($map[$token[1]])) {
if (!preg_match('/^\$[a-zA-Z0-9_]+$/', $token[1])) {
$code = str_replace($token[1], '$v' . $v++, $code);
$map[$token[1]] = $v;
}
}
}
}
file_put_contents("index_2.php",$code);
變量和函數基本能看了,還是有一些資料是亂碼,這個是它自定義函數加密的字元串,大多數都是php内置的函數,我們調試一下就基本能看到了
但是得注意一下,phpjiami有幾個反調試的地方,在35行的地方打個斷點
可以看到4個反調試的點:
第一個點:
當你是以cli運作php的時候就會直接die()掉,直接注釋掉即可
php_sapi_name()=="cli" ? die() : ''
第二個點:
和第一個差不多,也是驗證運作環境的,直接注釋即可
if (!isset($_SERVER["HTTP_HOST"]) && !isset($_SERVER["SERVER_ADDR"]) && !isset($_SERVER["REMOTE_ADDR"])) {
die();
}
第三個點:
如果你在if語句處停留超過100ms的話就會直接die掉,注釋即可
$v46 = microtime(true) * 1000;
eval("");
if (microtime(true) * 1000 - $v46 > 100) {
die();
}
第四個點:
$51就是整個檔案内容,這行是用于加密前的檔案對比是否完整,如果不完整則執行$52(),因為$52不存在是以會直接報錯退出,而如果對比是完整的話那麼就是$53,雖然$53也不存在,但隻是抛出一個Warning,是以我們這裡也是直接把這行注釋掉。
!strpos(func2(substr($v51, func2("???"), func2("???"))), md5(substr($51, func2("??"), func2("???")))) ? $52() : $53;
注釋完之後我們在return那裡打一個斷點,可以發現在return那裡我們需要解密的檔案内容呈現了出來。
解密之後的内容
?><?php @eval("//Encode by phpjiami.com,Free user."); ?><?php
//Clear the uploads directory every hour
highlight_file(__FILE__);
$sandbox = "uploads/". md5("De1CTF2020".$_SERVER['REMOTE_ADDR']);
@mkdir($sandbox);
@chdir($sandbox);
if($_POST["submit"]){
if (($_FILES["file"]["size"] < 2048) && Check()){
if ($_FILES["file"]["error"] > 0){
die($_FILES["file"]["error"]);
}
else{
$filename=md5($_SERVER['REMOTE_ADDR'])."_".$_FILES["file"]["name"];
move_uploaded_file($_FILES["file"]["tmp_name"], $filename);
echo "save in:" . $sandbox."/" . $filename;
}
}
else{
echo "Not Allow!";
}
}
function Check(){
$BlackExts = array("php");
$ext = explode(".", $_FILES["file"]["name"]);
$exts = trim(end($ext));
$file_content = file_get_contents($_FILES["file"]["tmp_name"]);
if(!preg_match('/[a-z0-9;~^`&|]/is',$file_content) &&
!in_array($exts, $BlackExts) &&
!preg_match('/\.\./',$_FILES["file"]["name"])) {
return true;
}
return false;
}
?>
upload
其實phpjiami加密之後的整個腳本就是解密我們檔案的腳本,我們的檔案内容被加密之後放在了?>最後面
整個解密過程也比較簡單,其中$v51是我們加密之後内容,$v55是解密後的内容。
$v55 = str_rot13(@gzuncompress(func2(substr($v51,-974,$v55))));
其中func2是解密函數
最後是拿func2解密之後的代碼放在這個eval中執行.
還有一種比較簡單快捷的方法是通過hook eval去擷取eval的參數,因為不涉及PHP-Parser是以就不過多展開了。
enphp混淆
github: github
使用官方的加密例子:
include './func_v2.php';
$options = array(
//混淆方法名 1=字母混淆 2=亂碼混淆
'ob_function' => 2,
//混淆函數産生變量最大長度
'ob_function_length' => 3,
//混淆函數調用 1=混淆 0=不混淆 或者 array('eval', 'strpos') 為混淆指定方法
'ob_call' => 1,
//随機插入亂碼
'insert_mess' => 0,
//混淆函數調用變量産生模式 1=字母混淆 2=亂碼混淆
'encode_call' => 2,
//混淆class
'ob_class' => 0,
//混淆變量 方法參數 1=字母混淆 2=亂碼混淆
'encode_var' => 2,
//混淆變量最大長度
'encode_var_length' => 5,
//混淆字元串常量 1=字母混淆 2=亂碼混淆
'encode_str' => 2,
//混淆字元串常量變量最大長度
'encode_str_length' => 3,
// 混淆html 1=混淆 0=不混淆
'encode_html' => 2,
// 混淆數字 1=混淆為0x00a 0=不混淆
'encode_number' => 1,
// 混淆的字元串 以 gzencode 形式壓縮 1=壓縮 0=不壓縮
'encode_gz' => 0,
// 加換行(增加可閱讀性)
'new_line' => 1,
// 移除注釋 1=移除 0=保留
'remove_comment' => 1,
// debug
'debug' => 1,
// 重複加密次數,加密次數越多反編譯可能性越小,但性能會成倍降低
'deep' => 1,
// PHP 版本
'php' => 7,
);
$file = 'test.php';
$target_file = 'en_test.php';
enphp_file($file, $target_file, $options);
?>
加密之後大概長這樣子
可以看到,我們的大部分字元串、函數等等都被替換成了類似于$GLOBALS{亂碼}[num]這種形式,我們将其輸出看一下:
可以看到我們原本的腳本中的字元串都在此數組裡面,是以我們隻要将$GLOBALS{亂碼}[num]還原成原來對應的字元串即可。
那麼我們如何擷取$GLOBALS{亂碼}數組的内容,很簡單,在我們擷取AST節點處打斷點即可找到相關内容:
$split=$ast[2]->expr->expr->args[0]->value->value;
$all=$ast[2]->expr->expr->args[1]->value->value;
$str=explode($split,$all);
var_dump($str);
可以看到,和上面輸出的是一樣的(如果加密等級不一樣則還需要加一層gzinflate)
然後就是通過AST一個節點一個節點将其替換即可,如果不知道節點類型的同學可以用$GLOBALS[A][1],将其輸出出來看一下即可,然後根據節點的類型和資料進行判斷即可,如下:
class PhpParser\Node\Expr\ArrayDimFetch#1104 (3) {
public $var =>
class PhpParser\Node\Expr\ArrayDimFetch#1102 (3) {
public $var =>
class PhpParser\Node\Expr\Variable#1099 (2) {
public $name =>
string(7) "GLOBALS"
protected $attributes =>
array(2) {
...
}
}
public $dim =>
class PhpParser\Node\Expr\ConstFetch#1101 (2) {
public $name =>
class PhpParser\Node\Name#1100 (2) {
...
}
protected $attributes =>
array(2) {
...
}
}
protected $attributes =>
array(2) {
'startLine' =>
int(2)
'endLine' =>
int(2)
}
}
public $dim =>
class PhpParser\Node\Scalar\LNumber#1103 (2) {
public $value =>
int(1)
protected $attributes =>
array(3) {
'startLine' =>
int(2)
'endLine' =>
int(2)
'kind' =>
int(10)
}
}
protected $attributes =>
array(2) {
'startLine' =>
int(2)
'endLine' =>
int(2)
}
}
根據上面的節點編寫腳本
public function leaveNode(Node $node)
{
if ($node instanceof PhpParser\Node\Expr\ArrayDimFetch
&& $node->var instanceof PhpParser\Node\Expr\ArrayDimFetch
&& $node->var->var instanceof PhpParser\Node\Expr\Variable
&& $node->var->var->name==="GLOBALS"
&& $node->var->dim instanceof PhpParser\Node\Expr\ConstFetch
&& $node->var->dim->name instanceof PhpParser\Node\Name
&& $node->var->dim->name->parts[0]===$this->str
&& $node->dim instanceof PhpParser\Node\Scalar\LNumber
){
return new PhpParser\Node\Scalar\String_($this->str_arr[$node->dim->value]);
}
return null;
}
解出來的内容如下,可以看到大部分已經成功解密出來了
還有就是解密的一部分出現這樣語句:('highlight_file')(__FILE__);,很明顯不符合我們平時的寫法,将其節點重命名一下
if (($node instanceof Node\Expr\FuncCall
|| $node instanceof Node\Expr\StaticCall
|| $node instanceof Node\Expr\MethodCall)
&& $node->name instanceof Node\Scalar\String_) {
$node->name = new Node\Name($node->name->value);
}
現在看起來就舒服多了
我們分析剩下亂碼的部分
可以看到是函數裡面的局部變量還是亂碼,從第一句可以看出所有的局部變量都是以& $GLOBALS[亂碼]為基礎的,而& $GLOBALS[亂碼]是我們上面已經找出來的,是以也是将其替換即可。
if ($node instanceof \PhpParser\Node\Stmt\Expression
&& $node->expr instanceof \PhpParser\Node\Expr\AssignRef
&& $node->expr->var instanceof \PhpParser\Node\Expr\Variable
&& $node->expr->expr instanceof \PhpParser\Node\Expr\ArrayDimFetch
&& $node->expr->expr->var instanceof \PhpParser\Node\Expr\Variable
&& $node->expr->expr->var->name==="GLOBALS"
&& $node->expr->expr->dim instanceof \PhpParser\Node\Expr\ConstFetch
&& $node->expr->expr->dim->name instanceof \PhpParser\Node\Name
&& $node->expr->expr->dim->name->parts!=[]
){
$this->Localvar=$node->expr->var->name;
return NodeTraverser::REMOVE_NODE;
}else if ($node instanceof \PhpParser\Node\Expr\ArrayDimFetch
&& $node->var instanceof \PhpParser\Node\Expr\Variable
&& $node->var->name===$this->Localvar
&& $node->dim instanceof \PhpParser\Node\Scalar\LNumber
){
return new \PhpParser\Node\Scalar\String_($this->str_arr[$node->dim->value]);
}
替換之後,可以看到與& $GLOBALS[亂碼]有關的已經全部被替換了,隻有變量部分是亂碼了
替換變量為$v這種形式
function BeautifyVariables($code){
$v = 0;
$map = [];
$tokens = token_get_all($code);
foreach ($tokens as $token) {
if ($token[0] === T_VARIABLE) {
if (!isset($map[$token[1]])) {
if (!preg_match('/^\$[a-zA-Z0-9_]+$/', $token[1])) {
$code = str_replace($token[1], '$v' . $v++, $code);
$map[$token[1]] = $v;
}
}
}
}
return $code;
}
至此所有代碼全部被還原(除了變量名這種不可抗拒因素之外)
還有一部分是沒有用的全局變量和常量,手動或者根據AST去進行删除即可,下面貼一下完整解密腳本
require "./vendor/autoload.php";
use \PhpParser\Error;
use \PhpParser\ParserFactory;
use \PhpParser\NodeTraverser;
use \PhpParser\NodeVisitorAbstract;
use \PhpParser\Node;
use \PhpParser\PrettyPrinter\Standard;
class MyVisitor extends NodeVisitorAbstract{
public $str;
public $str_arr;
public $Localvar;
public function leaveNode(Node $node)
{
if ($node instanceof \PhpParser\Node\Expr\ArrayDimFetch
&& $node->var instanceof \PhpParser\Node\Expr\ArrayDimFetch
&& $node->var->var instanceof \PhpParser\Node\Expr\Variable
&& $node->var->var->name==="GLOBALS"
&& $node->var->dim instanceof \PhpParser\Node\Expr\ConstFetch
&& $node->var->dim->name instanceof \PhpParser\Node\Name
&& $node->var->dim->name->parts[0]===$this->str
&& $node->dim instanceof \PhpParser\Node\Scalar\LNumber
){
return new \PhpParser\Node\Scalar\String_($this->str_arr[$node->dim->value]);
}
if (($node instanceof Node\Expr\FuncCall
|| $node instanceof Node\Expr\StaticCall
|| $node instanceof Node\Expr\MethodCall)
&& $node->name instanceof Node\Scalar\String_) {
$node->name = new Node\Name($node->name->value);
}
if ($node instanceof \PhpParser\Node\Stmt\Expression
&& $node->expr instanceof \PhpParser\Node\Expr\AssignRef
&& $node->expr->var instanceof \PhpParser\Node\Expr\Variable
&& $node->expr->expr instanceof \PhpParser\Node\Expr\ArrayDimFetch
&& $node->expr->expr->var instanceof \PhpParser\Node\Expr\Variable
&& $node->expr->expr->var->name==="GLOBALS"
&& $node->expr->expr->dim instanceof \PhpParser\Node\Expr\ConstFetch
&& $node->expr->expr->dim->name instanceof \PhpParser\Node\Name
&& $node->expr->expr->dim->name->parts!=[]
){
$this->Localvar=$node->expr->var->name;
return NodeTraverser::REMOVE_NODE;
}else if ($node instanceof \PhpParser\Node\Expr\ArrayDimFetch
&& $node->var instanceof \PhpParser\Node\Expr\Variable
&& $node->var->name===$this->Localvar
&& $node->dim instanceof \PhpParser\Node\Scalar\LNumber
){
return new \PhpParser\Node\Scalar\String_($this->str_arr[$node->dim->value]);
}
return null;
}
}
function BeautifyVariables($code){
$v = 0;
$map = [];
$tokens = token_get_all($code);
foreach ($tokens as $token) {
if ($token[0] === T_VARIABLE) {
if (!isset($map[$token[1]])) {
if (!preg_match('/^\$[a-zA-Z0-9_]+$/', $token[1])) {
$code = str_replace($token[1], '$v' . $v++, $code);
$map[$token[1]] = $v;
}
}
}
}
return $code;
}
$code = file_get_contents("./en_test.php");
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($code);
} catch (Error $error) {
echo "Parse error:{$error->getMessage()}\n";
return;
}
var_dump($ast);
$split=$ast[2]->expr->expr->args[0]->value->value;
$all=$ast[2]->expr->expr->args[1]->value->value;
$str1=$ast[2]->expr->var->dim->name->parts[0];
$str_arr=explode($split,$all);
$visitor=new MyVisitor;
$visitor->str=$str1;
$visitor->str_arr=$str_arr;
$traverser = New NodeTraverser;
$traverser->addVisitor($visitor);
$stmts = $traverser->traverse($ast);
$prettyPrinter = new Standard;
$code=$prettyPrinter->prettyPrintFile($stmts);
$code=BeautifyVariables($code);
echo $code;
注:需要注意的是enphp使用的全局變量不一定是GLOBALS,也可能是_SERVER、_GET等等,根據具體情況進行判斷,還有就是加密等級不同對應的解密方式也是不同的,不過其中的思想都是大同小異
Reference