初探PHP-Parser和PHP代码混淆

初探PHP-Parser

PHP-Parsernikic 用PHP编写的PHP5.2到PHP7.4解析器,其目的是简化静态代码分析和操作。

解析

创建一个解析器实例:

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)

<?php

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()}";
}
var_dump($ast);

节点转储

如果是用上面的 var_dump 的话显示的 AST 可能会比较乱,那么我们可以使用 NodeDumper 生成一个更加直观的 AST

<?php

use PhpParser\NodeDumper;

$nodeDumper = new NodeDumper;
echo $nodeDumper->dump($stmts);

或者我们使用 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
            )
        )
    )
)

节点树结构

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\NamePhpParser\Node\Arg 不在以上的节点之中

格式化打印

使用 PhpParser\PrettyPrinter 格式化代码

<?php

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()}";
    return;
}
$prettyPrinter = new PrettyPrinter\Standard;
$prettyCode = $prettyPrinter->prettyPrintFile($ast);
echo $prettyCode;

节点遍历

使用 PhpParser\NodeTraverser 我们可以遍历每一个节点,举几个简单的例子:解析php中的所有字符串,并输出

<?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;
        }
    }
}

$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()}";
    return;
}

遍历php中出现的函数以及类中的成员方法

<?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;
        }
    }
}

替换php脚本中函数以及类的成员方法函数名为小写

<?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 方法类似,唯一的区别是它只在遍历之后调用一次。
  • 在每个节点上都调用 enterNodeleaveNode 方法,前者在它被输入时,即在它的子节点被遍历之前,后者在它被离开时。
  • 这四个方法要么返回更改的节点,要么根本不返回(即null),在这种情况下,当前节点不更改。

PHP代码混淆

下面举两个php混淆的例子,比较简单(郑老板@zsx所说的20分钟内能解密出来的那种),主要是加深一下我们对 PhpParser 使用

phpjiami

大部分混淆都会把代码格式搞得很乱,用 PhpParser\PrettyPrinter 格式化一下代码

<?php

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

格式基本能看了

图片[1] - 初探PHP-Parser和PHP代码混淆 - 易航天地

因为函数和变量的乱码让我们之后的调试比较难受,所以简单替换一下混淆的函数和变量

<?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内置的函数,我们调试一下就基本能看到了


但是得注意一下,phpjiami有几个反调试的地,在35行的地方打个断点


可以看到4个反调试的点:

第一个点:
当你是以cli运行php的时候就会直接 die() 掉,直接注释掉即可

php_sapi_name()=="cli" ? die() : '';

第二个点:
和第一个差不多,也是验证运行环境的,cli模式下没有这些变量索引,直接注释即可

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.");

// 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;
}
?>

<html>

<head>
    <meta charset="utf-8">
    <title>upload</title>
</head>

<body>
    <form action="index.php" method="post" enctype="multipart/form-data">
        <input type="file" name="file" id="file"><br>
        <input type="submit" name="submit" value="submit">
    </form>
</body>

</html>

其实phpjiami加密之后的整个脚本就是解密我们文件的脚本,我们的文件内容被加密之后放在了 ?> 最后面


整个解密过程也比较简单,其中$v51是我们加密之后内容,$v55是解密后的内容。

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

其中func2是解密函数


最后是拿func2解密之后的代码放在这个 eval 中执行


还有一种比较简单快捷的方法是通过 hook eval 去获取 eval 的参数,因为不涉及 PHP-Parser 所以就不过多展开了。

Enphp混淆

官网:http://enphp.djunny.com/
github: https://github.com/djunny/enphp
使用官方的加密例子:

<?php
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节点处打断点即可找到相关内容:

图片[2] - 初探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


然后就是通过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;
}

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

图片[3] - 初探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);
}

现在看起来就舒服多了

我们分析剩下乱码的部分

可以看到是函数里面的局部变量还是乱码,从第一句可以看出所有的局部变量都是以 & $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 这种形式

<?php
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去进行删除即可,下面贴一下完整解密脚本

<?php
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 等等,根据具体情况进行判断,还有就是加密等级不同对应的解密方式也是不同的,不过其中的思想都是大同小异

参考

https://www.52pojie.cn/thread-693641-1-1.html

https://www.52pojie.cn/thread-883976-1-1.html

https://xz.aliyun.com/t/7363

https://github.com/nikic/PHP-Parser/blob/master/doc/2_Usage_of_basic_components.markdown

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容