|
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代码解析成抽象语法树:
<?php
use PhpParser\Error;
use PhpParser\ParserFactory;
require &#39;vendor/autoload.php&#39;;
$code = file_get_contents(&#34;./test.php&#34;);
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($code);
} catch (Error $error) {
echo &#34;Parse error: {$error->getMessage()}\n&#34;;
}(3)输出抽象语法树
通过Node Dumping我们可以生成一个直观的AST,例如我们使用view.php来解析sample.php:
//view.php
<?php
require &#39;vendor/autoload.php&#39;;
use PhpParser\Error;
use PhpParser\NodeDumper;
use PhpParser\ParserFactory;
//获取sample.php的代码内容
$code = file_get_contents(&#39;sample.php&#39;);
//初始化解析器
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
//解析sample.php内容,转换为ast
$ast = $parser->parse($code);
} catch (Error $error) {
echo &#34;Parse error: {$error->getMessage()}\n&#34;;
return;
}
$dumper = new NodeDumper;
//优化ast并dump
echo $dumper->dump($ast) . &#34;\n&#34;;sample.php的解析效果如下:
<?php
$a = &#39;a&#39;.&#39;ssert&#39;;
$a($_POST[&#39;x&#39;]);
=======
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 | 语句节点,不存在返回值也不能进行表达式判断 | NamespaceClass | PhpParser\Node\expr | 表达式节点,即存在返回值的语言结构,可以进行表达式判断 | VariableFuncCallBinaryOP | 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 = &#39;a&#39;.&#39;s&#39;.&#39;s&#39;.&#39;e&#39;.&#39;r&#39;.&#39;t&#39;;
$a($_POST[&#39;x&#39;]);
?>首先输出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 = &#39;assert&#39;;
$a($_POST[&#39;x&#39;]);因为变量的还原涉及到作用域、存储结构等问题,这里不做探讨。
(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(&#34;base64_decode&#34;),
[new Node\Arg(new Node\Scalar\String_(base64_encode($name)))]
);
}
}
}效果如下:
<?php
$str = &#34;Threatbook&#34;;
?>
--After parser:--
$str = base64_decode(&#39;VGhyZWF0Ym9vaw==&#39;); (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(&#39;/^[a-zA-Z0-9_]+$/&#39;, $node->name)) {
//如果这个变量再次出现,使用已经有的替换值进行替换
if (in_array($node->name, array_keys($this->NewName))){
$new_var_name = str_replace($node->name, &#39;v_&#39; . $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, &#39;v_&#39; . $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(&#39;/^[a-zA-Z0-9_]+$/&#39;, $key)) {
if ($this->Count[$name] !== null){
// 判断该数组当前键值
if ($this->NewName[$name][$key] !== null){
$new_key_name = str_replace($key, &#39;arr_&#39; . $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, &#39;arr_&#39; . $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, &#39;arr_&#39; . $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”,这种判断是因为上图中的第二次还原的调用形式为”(&#39;unserialize&#39;)((&#39;base64_decode&#39;)(&#39;xxx&#39;)”,PHP支持字符串调用的方式,在AST中会解析为”String_”节点;
- 获取加密的值,直接返回”unserialize(base64_decode(密文))”的值;
- 同时还原数组是需要判断”GLOBALS”的值是否存在。
代码如下:
class ArrayToConstant extends NodeVisitorAbstract
{
public $variableName = &#39;&#39;;
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] == &#39;unserialize&#39; &&
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] == &#39;base64_decode&#39;
) {
$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(&#34;0&#34;));
}else if(
//(&#39;unserialize&#39;)((&#39;base64_decode&#39;)类型的调用
$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 == &#39;unserialize&#39; &&
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 == &#39;base64_decode&#39;)
{
$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(&#34;0&#34;));
}else{
return;
}
}
public function leaveNode(Node $node)
if ($this->_variableName === &#39;&#39;) 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`以及字符串的连接符`.`三种,可以通过例二延伸出解法。
最后执行结果如下:

这样一来该文件的可读性已经很好了,短短的几行代码经过混淆后的代码量还是挺多的。
4.结语
正如文章开头所言,PHP-Parser中的每个Visitor都是独立的,按照“addVisitor“的顺序进行调用,这是组合模式这种设计模式的应用,这样的模块化设计非常适合进行后续的维护。通过PHP-Paser开发者可以便捷地解析和修改PHP代码,具备了元编程能力。在此基础上,我们可以实现静态代码分析、污点追踪等操作,或者排查潜在BUG、优化项目代码,减少重复劳动。本文大致介绍了PHP-Parser的基础使用方式,了解通过PHP-Parser进行反混淆,除这以外还可以实现更多更好用的功能,以此抛砖引玉。
5.参考链接
开发简单的PHP混淆器与解混淆器:https://blog.zsxsoft.com/post/42PHP-Parser Doc:
https://github.com/nikic/PHP-Parser/blob/master/doc
j0k3r:https://github.com/nikic/PHP-Parser/blob/master/doc |
|