天天看点

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