天天看點

traverse+php,初探PHP-Parser和PHP代碼混淆

初探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);

格式基本能看了

traverse+php,初探PHP-Parser和PHP代碼混淆

因為函數和變量的亂碼讓我們之後的調試比較難受,是以簡單替換一下混淆的函數和變量

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内置的函數,我們調試一下就基本能看到了

traverse+php,初探PHP-Parser和PHP代碼混淆

但是得注意一下,phpjiami有幾個反調試的地方,在35行的地方打個斷點

traverse+php,初探PHP-Parser和PHP代碼混淆

可以看到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那裡我們需要解密的檔案内容呈現了出來。

traverse+php,初探PHP-Parser和PHP代碼混淆

解密之後的内容

?><?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加密之後的整個腳本就是解密我們檔案的腳本,我們的檔案内容被加密之後放在了?>最後面

traverse+php,初探PHP-Parser和PHP代碼混淆

整個解密過程也比較簡單,其中$v51是我們加密之後内容,$v55是解密後的内容。

$v55 = str_rot13(@gzuncompress(func2(substr($v51,-974,$v55))));

其中func2是解密函數

traverse+php,初探PHP-Parser和PHP代碼混淆

最後是拿func2解密之後的代碼放在這個eval中執行.

traverse+php,初探PHP-Parser和PHP代碼混淆

還有一種比較簡單快捷的方法是通過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);

?>

加密之後大概長這樣子

traverse+php,初探PHP-Parser和PHP代碼混淆

可以看到,我們的大部分字元串、函數等等都被替換成了類似于$GLOBALS{亂碼}[num]這種形式,我們将其輸出看一下:

traverse+php,初探PHP-Parser和PHP代碼混淆

可以看到我們原本的腳本中的字元串都在此數組裡面,是以我們隻要将$GLOBALS{亂碼}[num]還原成原來對應的字元串即可。

那麼我們如何擷取$GLOBALS{亂碼}數組的内容,很簡單,在我們擷取AST節點處打斷點即可找到相關内容:

traverse+php,初探PHP-Parser和PHP代碼混淆

$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)

traverse+php,初探PHP-Parser和PHP代碼混淆

然後就是通過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;

}

解出來的内容如下,可以看到大部分已經成功解密出來了

traverse+php,初探PHP-Parser和PHP代碼混淆

還有就是解密的一部分出現這樣語句:('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);

}

現在看起來就舒服多了

traverse+php,初探PHP-Parser和PHP代碼混淆

我們分析剩下亂碼的部分

traverse+php,初探PHP-Parser和PHP代碼混淆

可以看到是函數裡面的局部變量還是亂碼,從第一句可以看出所有的局部變量都是以& $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[亂碼]有關的已經全部被替換了,隻有變量部分是亂碼了

traverse+php,初探PHP-Parser和PHP代碼混淆

替換變量為$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;

}

至此所有代碼全部被還原(除了變量名這種不可抗拒因素之外)

traverse+php,初探PHP-Parser和PHP代碼混淆

還有一部分是沒有用的全局變量和常量,手動或者根據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