PHP反混淆实战 | 手把手带你入门PHP-Parser
2023-3-8 17:31:36 Author: 微步在线研究响应中心(查看原文) 阅读量:12 收藏

1

           什么是PHP-Parser ?

PHP-Parser是由Nikic开发的一款PHP抽象语法树(AST)解析工具。能够将PHP代码转换为抽象语法树,安全研究员可以通过生成的语法树对PHP样本进行控制流图生成、静态分析和污点检测等操作,同时其组合模式的设计使得每个节点操作的处理相互独立,后期维护十分方便。因此在PHP Webshell检测领域中被广泛使用。同时对于生成的抽象语法树也可以进行操作,从代码节点的角度对PHP文件进行修改,所以通过PHP-Parser进行代码的混淆和反混淆是十分方便的。在非安全领域,PHP-Parser也可以自动帮你补全单元测试框架、检查代码问题。
接下来从笔者从事相关工作的经验来探讨下通过PHP-Parser进行反混淆的方法,通过对某CTF混淆样本进行反混淆让大家初步掌握使用PHP-Parser反混淆的方法。
2

           PHP-Parser入门

此章主要介绍PHP-Parser的创建过程以及在实战中需要用的方法、节点类型等。
1.创建解析器实例
要使用PHP-Parser,首先需要创建实例,在创建时选择解析的语言版本,声明格式如下:
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
2.解析PHP代码

通过解析器的parse方法将PHP代码解析成抽象语法树:

<?phpuse 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";}
3.输出抽象语法树
通过Node Dumping我们可以生成一个直观的AST,例如我们使用view.php来解析sample.php:
//view.php<?php         
require 'vendor/autoload.php';use PhpParser\Error;use PhpParser\NodeDumper;use PhpParser\ParserFactory;//获取sample.php的代码内容$code = file_get_contents('sample.php');//初始化解析器$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);try {    //解析sample.php内容,转换为ast   $ast = $parser->parse($code);} catch (Error $error) {    echo "Parse error: {$error->getMessage()}\n";    return;}          
$dumper = new NodeDumper;//优化ast并dumpecho $dumper->dump($ast) . "\n";
sample.php的解析效果如下:
<?php $a = 'a'.'ssert';$a($_POST['x']);=======array(    0: Stmt_Expression(        expr: Expr_Assign(            var: Expr_Variable(                name: a            )            expr: Expr_BinaryOp_Concat(                left: Scalar_String(                    value: a                )                right: Scalar_String(                    value: ssert                )           )        )    )    1: Stmt_Expression(        expr: Expr_FuncCall(            name: Expr_Variable(               name: a           )            args: array(               0: Arg(                    name: null                    value: Expr_ArrayDimFetch(                        var: Expr_Variable(                           name: _POST                       )                        dim: Scalar_String(                            value: x                        )                    )                    byRef: false                    unpack: false                )            )        )   )
4.抽象语法树上的Node节点
PHP-Parser解析成抽象语法树之后会存在140多种节点,主要有以下几类:
节点表达式
节点类型
节点实例
PhpParser\Node\Stmts
语句节点,不存在返回值也不能进行表达式判断
Namespace
Class
PhpParser\Node\expr
表达式节点,即存在返回值的语言结构,可以进行表达式判断
Variable
FuncCall
BinaryOP
PhpParser\Node\Scalars
标量值节点,通常使用的字符串、数字或者魔术常量
String_
LNumber
5.AST转回PHP代码
PHP-Parser中的“PhpParser\PrettyPrinter“类用来打印AST转换之后的代码,在我们对抽象语法树进行修改之后可以使用其生成新的PHP代码:
$prettyPrinter = new PrettyPrinter\Standard;$prettyCode = $prettyPrinter->prettyPrintFile($ast);echo $prettyCode;     
6.节点遍历
节点遍历是PHP-Parser提供的最关键的接口,它为我们提供了遍历语法树节点的方式,通过编写特定的操作我们可以还原指定的代码。所有的Visitor都需要实现

“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)
3

          PHP-Parser实战

这部分将从最基础的还原拼接字符串入手,一步步分析语法树特点,编写还原操作;然后尝试对函数进行编码解码,最后通过CTF赛题实战完整的解混淆一个文件,加深对于PHP-Parser的掌握。      

1.字符二元操作符还原

针对字符串的异或、拼接、与或非等操作进行还原,基础样本如下:

<?php     $a = 'a'.'s'.'s'.'e'.'r'.'t';    $a($_POST['x']);?>

首先输出AST进行查看。

array(    0: Stmt_Expression(        expr: Expr_Assign(            var: Expr_Variable(                name: a            )            expr: Expr_BinaryOp_Concat(                left: Expr_BinaryOp_Concat(                    left: Expr_BinaryOp_Concat(                       left: Expr_BinaryOp_Concat(                            left: Expr_BinaryOp_Concat(                                left: Scalar_String(                                    value: a                                )                                right: Scalar_String(                                    value: s                                )                            )                            right: Scalar_String(                                value: s                            )                        )                        right: Scalar_String(                            value: e                       )                   )                   right: Scalar_String(                        value: r                    )                )                right: Scalar_String(                   value: t                )            )        )    )    1: Stmt_Expression(        expr: Expr_FuncCall(            name: Expr_Variable(               name: a            )            args: array(                0: Arg(                   name: null                    value: Expr_ArrayDimFetch(                        var: Expr_Variable(                            name: _POST                        )                       dim: Scalar_String(                            value: x                        )                    )                    byRef: false                    unpack: false                )            )        )    ))
这个例子比较单一,连接节点为“Node\Expr\BinaryOp\Concat“,且左右都为”Scalar“类型的节点,可以直接进行拼接操作,所以我们可以编写Visitor类如下,使用”leaveNode“或者”enterNode“将左右节点连接并返回:
class BinaryOpReducer extends NodeVisitorAbstract{   public function leaveNode(Node $node) {        if ($node instanceof Node\Expr\BinaryOp\Concat && $node->left instanceof Node\Scalar\String_ && $node->right instanceof Node\Scalar\String_) {            return new PhpParser\Node\Scalar\String_($node->left->value . $node->right->value);        }    }}
代码考虑的比较简单,没有对左右节点为其他变量类型的情况作限制,这样可以把连接操作符的字符串还原,效果如下:
<?php        $a = 'assert';$a($_POST['x']);
因为变量的还原涉及到作用域、存储结构等问题,这里不做探讨。       
2.字符串编码解码
这部分尝试将字符串替换为`base64`加密之后的结果,思路如下:
  • 判断当前节点是否为”Scalar\String_”;
  • 将节点的”value”值进行“base64_encode“编码;
  • 替换原节点为“FuncCall“类型;
代码如下:
class Base64Reducer extends NodeVisitorAbstract
public function leaveNode(Node $node) { if ($node instanceof Node\Scalar\String_) { $name = $node->value; return new Expr\FuncCall( new Node\Name("base64_decode"), [new Node\Arg(new Node\Scalar\String_(base64_encode($name)))] ); } }}
效果如下:
<?php              $str = "Threatbook";?>                  --After parser:--                                
$str = base64_decode('VGhyZWF0Ym9vaw=='); 
3.CTF混淆文件还原实战
经过上面两个例子,已经掌握了PHP-Parser的基础运用,接下来通过还原混淆文件深化一下对于节点的理解,样本是2020年高校战“疫”网络安全分享赛中Hardphp题目的混淆文件,我们将从WriteUP逆向推导出反混淆思路,混淆文件如下:

首先观察可以发现,混淆文件通过“unserialize(base64_decode(“的方式将字符串解码结果赋值给”GLOBALS“数组,然后通过数组值进行运算。
由于存在部分乱码的变量名,首先将所有的乱码变量批量重命名。思路如下:
  • 筛选所有“Variable“类型的节点;
  • 通过正则表达式匹配出乱码变量,这种变量名中不会出现字母数字等字符;
  • 通过一个数组存放重命名的变量名,如果某个乱码变量再次出现,通过数组查询新的变量名进行替换。

代码如下:

// 变量重命名class ReNameVariable extends NodeVisitorAbstract{
public $Count = 0;    public $NewName = [];
public function leaveNode(Node $node){ //判断Variable类型的节点 if ($node instanceof Node\Expr\Variable) { //匹配不含字母数字的乱码变量 if (!preg_match('/^[a-zA-Z0-9_]+$/', $node->name)) { //如果这个变量再次出现,使用已经有的替换值进行替换                if (in_array($node->name, array_keys($this->NewName))){ $new_var_name = str_replace($node->name, 'v_' . $this->NewName[$node->name], $node->name); return (new Node\Expr\Variable($new_var_name)); }else{ //记录新的变量名到数组 $this->NewName[$node->name] = $this->Count++; $new_var_name = str_replace($node->name, 'v_' . $this->NewName[$node->name], $node->name); return (new Node\Expr\Variable($new_var_name)); } } return ; } }
执行效果如下:
   

可以看到原本的不可见变量名已经被重命名成了“v_“格式的变量。同时可以观察到“GLOBALS“变量的键名也是乱码字符,借鉴变量名重命名的思路对所有”GLOBALS“数组的键名进行重命名:

  • 筛选所有的“ArrayDimFetch“类型节点,且代码样式为”$GLOBALS[XX][X]“,对”$node->var“和”$node->dim“也进行判断
  • 通过正则表达式匹配出乱码数组键名,这种键名中不会出现字母数字等字符
  • 通过一个数组存放重命名的键名名,如果某个乱码键名再次出现,通过数组查询新的变量名进行替换

和上面不同的是我们恢复的是二维数组,所以要多包含一层判断:

class ReNameArrayKeyValue extends NodeVisitorAbstract{
private $Count = []; private $NewName = [];
public function leaveNode(Node $node){ if ( $node instanceof Node\Expr\ArrayDimFetch && !($node->var instanceof Node\Expr\ArrayDimFetch) && !($node->dim instanceof Node\Expr\ArrayDimFetch) ) { $key = $node->dim->value; $name = $node->var->name; if (!preg_match('/^[a-zA-Z0-9_]+$/', $key)) { if ($this->Count[$name] !== null){ // 判断该数组当前键值 if ($this->NewName[$name][$key] !== null){ $new_key_name = str_replace($key, 'arr_' . $this->NewName[$name][$key], $key); return new Node\Expr\ArrayDimFetch( new Node\Expr\Variable($name), new Node\Scalar\String_($new_key_name) ); }else{ // 未替换该键值的操作 $this->NewName[$name][$key] = $this->Count[$name]++; $new_key_name = str_replace($key, 'arr_' . $this->NewName[$name][$key], $key); return new Node\Expr\ArrayDimFetch( new Node\Expr\Variable($name), new Node\Scalar\String_($new_key_name) ); } }else{ $this->NewName[$name] = []; $this->Count[$name] = 0; $this->NewName[$name][$key] = $this->Count[$name]++; $new_key_name = str_replace($key, 'arr_' . $this->NewName[$name][$key], $key); return new Node\Expr\ArrayDimFetch( new Node\Expr\Variable($name), new Node\Scalar\String_($new_key_name) ); } } return ; } }}
执行之后文件还原如下:

接下来就是解`unserialize(base64_decode(`混淆,可以先用在线代码运行工具输出一下解码的结果:

通过解码结果可以看到还存在`unserialize(base64_decode(`混淆,把密文复制再解一次:

   

结果看起来只剩基本函数了,通过在线的还原可以确定一下解混淆思路:
  • 筛选“FuncCall”节点,判断节点的”$node->expr->name->parts[0]”是否为”unserialize”,节点”$node->expr->args[0]->value->name->parts[0]”的值是否为”base64_decode”;
  • 筛选”FuncCall”节点,判断节点的”$node->expr->name->value”是否为”unserialize”,节点”$node->expr->args[0]->value->name->value”的值是否为”base64_decode”,这种判断是因为上图中的第二次还原的调用形式为”('unserialize')(('base64_decode')('xxx')”,PHP支持字符串调用的方式,在AST中会解析为”String_”节点;
  • 获取加密的值,直接返回”unserialize(base64_decode(密文))”的值;
  • 同时还原数组是需要判断”GLOBALS”的值是否存在。
代码如下:
class ArrayToConstant extends NodeVisitorAbstract{    public $variableName = '';    public $constants = [];
public function enterNode(Node $node) //unserialize(base64_decode(类型的调用 if ($node instanceof Node\Expr\Assign && $node->expr instanceof Node\Expr\FuncCall && $node->expr->name instanceof Node\Name && is_string($node->expr->name->parts[0]) && $node->expr->name->parts[0] == 'unserialize' && count($node->expr->args) === 1 && $node->expr->args[0] instanceof Node\Arg && $node->expr->args[0]->value instanceof Node\Expr\FuncCall && $node->expr->args[0]->value->name instanceof Node\Name && is_string($node->expr->args[0]->value->name->parts[0]) && $node->expr->args[0]->value->name->parts[0] == 'base64_decode' ) { $string = $node->expr->args[0]->value->args[0]->value->value; $array = unserialize(base64_decode($string)); $this->variableName = $node->var->name; $this->constants = $array; return new Node\Expr\Assign($node->var, Node\Scalar\LNumber::fromString("0")); }else if( //('unserialize')(('base64_decode')类型的调用 $node instanceof Node\Expr\Assign && $node->expr instanceof Node\Expr\FuncCall && $node->expr->name instanceof Node\Scalar\String_ && is_string($node->expr->name->value) && $node->expr->name->value == 'unserialize' && count($node->expr->args) === 1 && $node->expr->args[0] instanceof Node\Arg && $node->expr->args[0]->value instanceof Node\Expr\FuncCall && $node->expr->args[0]->value->name instanceof Node\Scalar\String_ && is_string($node->expr->args[0]->value->name->value) && $node->expr->args[0]->value->name->value == 'base64_decode') {                    $string = $node->expr->args[0]->value->args[0]->value->value; $array = unserialize(base64_decode($string)); $this->variableName = $node->var->name; $this->constants = $array; return new Node\Expr\Assign($node->var, Node\Scalar\LNumber::fromString("0")); }else{ return; } }
public function leaveNode(Node $node) if ($this->_variableName === '') return; if ($node instanceof Node\Expr\ArrayDimFetch && $node->var->name === $this->_variableName) { $val = $this->constants[$node->dim->value]; //判断该 GLOBALS 值是否存在 if ($val === null){ return; } if (is_string($val)) { return new Node\Scalar\String_($val); } elseif (is_double($val)) { return new Node\Scalar\DNumber($val); } elseif (is_int($val)) { return new Node\Scalar\LNumber($val); } else { return new Node\Expr\ConstFetch(new Node\Name\FullyQualified(json_encode($val))); } } }}
注意:因为字符是嵌套的”unserialize(base64_decode(“,所以这里需要进行还原两次,效果如下:

   

接下来我们处理一下字符运算,观察代码发现运算都是“x + (y - z)“格式,所以我们的返回值格式也固定为”$a + $b - $c“。
class ExpressionToNumber extends NodeVisitorAbstract
public function leaveNode(Node $node)    { if ($node instanceof Node\Expr\BinaryOp\Plus && ($node->left instanceof Node\Scalar\LNumber || $node->left instanceof Node\Scalar\String_ || $node->left instanceof Node\Expr\UnaryMinus) && $node->right instanceof Node\Expr\BinaryOp\Minus && ($node->right->left instanceof Node\Scalar\LNumber || $node->right->left instanceof Node\Scalar\String_) && ($node->right->right instanceof Node\Scalar\LNumber || $node->right->right instanceof Node\Scalar\String_)) { if ($node->left instanceof Node\Expr\UnaryMinus) { $a = -($node->left->expr->value); } else { $a = $node->left->value; } $b = $node->right->left->value; $c = $node->right->right->value; return new Node\Scalar\LNumber($a + $b - $c); } }}

通过观察发现还剩下`chr`、`str_rot13`以及字符串的连接符`.`三种,可以通过例二延伸出解法。
class ChrReducer extends NodeVisitorAbstract {    public function leaveNode(Node $node){        if ($node instanceof Node\Expr\FuncCall && is_string($node->name->value) && $node->name->value == 'chr' && count($node->args) === 1 && $node->args[0] instanceof Node\Arg && $node->args[0]->value instanceof Node\Scalar\LNumber        ){            $char = $node->args[0]->value->value;            return new Node\Scalar\String_(chr($char));        }        }}
class ConcatReducer extends NodeVisitorAbstract{          
public function leaveNode(Node $node) if ($node instanceof Node\Expr\BinaryOp\Concat){ if ($node->left instanceof Node\Scalar\String_ && is_string($node->left->value) && $node->right instanceof Node\Scalar\String_ && is_string($node->right->value)){ return new Node\Scalar\String_($node->left->value . $node->right->value); } } }}
class Rot13Reducer extends NodeVisitorAbstract{ public function leaveNode(Node $node){ if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Scalar\String_ && is_string( $node->name->value ) && $node->name->value == 'str_rot13' && count( $node->args ) === 1 && $node->args[0] instanceof Node\Arg && $node->args[0]->value instanceof Node\Scalar\String_ && is_string($node->args[0]->value->value) ){ return new Node\Scalar\String_(str_rot13($node->args[0]->value->value)); } }}  
最后执行结果如下:

这样一来该文件的可读性已经很好了,短短的几行代码经过混淆后的代码量还是挺多的。
4

           结语

正如文章开头所言,PHP-Parser中的每个Visitor都是独立的,按照“addVisitor“的顺序进行调用,这是组合模式这种设计模式的应用,这样的模块化设计非常适合进行后续的维护。通过PHP-Paser开发者可以便捷地解析和修改PHP代码,具备了元编程能力。在此基础上,我们可以实现静态代码分析、污点追踪等操作,或者排查潜在BUG、优化项目代码,减少重复劳动。
本文大致介绍了PHP-Parser的基础使用方式,了解通过PHP-Parser进行反混淆,除这以外还可以实现更多更好用的功能,以此抛砖引玉。
5

           参考链接

开发简单的PHP混淆器与解混淆器:https://blog.zsxsoft.com/post/42
PHP-Parser Doc:https://github.com/nikic/PHP-Parser/blob/master/doc
j0k3r:https://github.com/nikic/PHP-Parser/blob/master/doc

---End---
点击下方,关注我们
第一时间获取最新的威胁情报


文章来源: http://mp.weixin.qq.com/s?__biz=Mzg5MTc3ODY4Mw==&mid=2247500226&idx=1&sn=43b15aaf194d63a654887b442d1da6ea&chksm=cfcaa2d6f8bd2bc0f5276ce2527c2d854ef6c6ee4058d9902c6236a733a15153831c6ec24535#rd
如有侵权请联系:admin#unsafe.sh