开发简单的PHP混淆器与解混淆器

zsx in 代码分享 / 4 / 12477

最近(被迫)拿到了不少经过混淆的PHP代码样本,尤其是我使用的某个开源软件里面竟然也有被混淆的PHP代码(还有几十个JS后门),导致我不得不把它们都解混淆来检查一下。不过,这些只要20分钟就能写出通用解混淆代码的混淆有什么意义呢?

好想出去玩_(:з」∠)_但出不去,只好在家里应XCTF抗疫赛邀请出题了。但是实在没题出怎么办?恰巧看到Xray的一篇吐槽安全从业人员代码平均开发能力差的文章,考虑到解混淆需要一定的开发能力,不如来用混淆水一题吧……

顺带一提,本次比赛中,我观赏了一下各个队伍的去混淆脚本,基本上都是正则表达式+黑魔法的写法,根本看不懂……

How?

我相信很多人对写一个PHP混淆与去混淆是一头雾水,完全不知道怎么下手的状态,或者除了正则表达式以外就没有思路了。实际上,写混淆器等于写半个编译器。如果你的程序能够正确理解PHP代码中每一个“单词”的意思,那么你的混淆器就基本开发完成一半了。

一个编译器通常分为编译器前端和后端两个部分,编译器前端负责对代码的解析。我们要着眼的也基本就是前端部分。编译过程中的第一步是词法分析,词法分析器读入源程序的字符流,把他们组织成有意义的词素(lexeme);对于每个词素,词法分析器产生对应的词法单元(token)。如果我们使用PHP来开发的话,这个过程不需要我们来做。PHP有一个函数token_get_all,可以直接把PHP代码转换成token数组。

Token?

基于token数组,我们可以开发一个简单的变量重命名器:

$file = file_get_contents($path);
$variable = 0;
$map = [];
$tokens = token_get_all($file);
foreach ($tokens as $token) {
    if ($token[0] === T_VARIABLE) {
        if (!isset($map[$token[1]])) {
            if (!preg_match('/^\$[a-zA-Z0-9_]+$/', $token[1])) {
                $file = str_replace($token[1], '$v' . $variable++, $file);
                $map[$token[1]] = $variable;
            }
        }
    }
}

非常简单,可以将所有由不可见字符组成的变量名改成正常人可读的变量名。

enphp 即是直接基于该数组开发。由于词法分析器并不负责维护每个token之间的关系,enphp不得不维护相当多的状态,导致其后续的开发和维护较为复杂,我们也不会基于这一串token来开发。

编译的第二步是语法分析,由token序列确定语法结构,通常会输出一棵语法树(syntax tree)。PHP是一个成熟的语言,也有一个成熟的解析器。php-parser 可以帮助我们把PHP代码解析成一棵抽象语法树(AST),我们就将基于它来开发。

既然有了能表示代码结构的树,那我们就知道怎么一个正常的混淆器应当怎么开发了:

  1. 把原始代码解析成一棵树。
  2. 遍历树,修改树上的某些节点。
  3. 将树还原成代码。

练手

现在让我们开始吧,php-parser的安装请自行看文档。

我们从最简单的代码变换开始,第一步将

Hello World!

替换成

<?php echo 'Hello World!';

我们先写一个主体结构:

<?php
use PhpParser\Parser;
use PhpParser\ParserFactory;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\PrettyPrinter\Standard;

require './vendor/autoload.php';

// 初始化解析器
$parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
// 将代码解析成AST
$ast = $parser->parse(file_get_contents('test/test1.php'));

$traverser = new NodeTraverser();
// 注册一个“游客”跟着一起漫游
$traverser->addVisitor(new HTMLToEcho($parser));
// 开始遍历AST
$ast = $traverser->traverse($ast);

// 将AST转换成代码
$prettyPrinter = new Standard();
$ret = $prettyPrinter->prettyPrint($ast);
echo '<?php ' . $ret;

再写一个游客类:

<?php
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;

class HTMLToEcho extends NodeVisitorAbstract
{
    public function leaveNode(Node $node)
    {
        // 当当前节点的类型是 InlineHTML
        if ($node instanceof Node\Stmt\InlineHTML) {
            // 将其替换成 echo 'value';
            return new Node\Stmt\Echo_([
                new Node\Scalar\String_($node->value)
            ]);
        }
    }

}

运行试试,是不是很神奇呢?关于NodeVisitor的使用,请直接阅读文档 Walking the AST

开始

现在让我们开始写一个字符串混淆器和解混淆器。

我们现在想要:

var_dump('Hello World');

变成

var_dump(str_rot13('Uryyb Jbeyq'));

只需要在发现一个字符串调用的时候,把它换成函数就好了:

$traverser->addVisitor(new StringToROT13($parser));
// ......

class StringToROT13 extends NodeVisitorAbstract
{
    public function leaveNode(Node $node)
    {
        if ($node instanceof Node\Scalar\String_) {
            $name = $node->value;
            return new Expr\FuncCall(
                new Node\Name("str_rot13"),
                [new Node\Arg(new Node\Scalar\String_(str_rot13($name)))]
            );
        }

    }
}

解混淆器,就是一个反向的过程。发现一个函数调用str_rot13,且第一个参数为字符串,就把它替换回来:

class ROT13ToString extends NodeVisitorAbstract
{
    public function leaveNode(Node $node)
    {
        if ($node instanceof Node\Expr\FuncCall &&
            $node->name instanceof Node\Name &&
            $node->name->parts[0] == 'str_rot13' &&
            $node->args[0]->value instanceof Node\Scalar\String_
        ) {
            $value = $node->args[0]->value->value;
            return new Node\Scalar\String_(str_rot13($value));
        }
    }

}

毫无难度,对吗 :)

对比上面两边的代码,会发现,解混淆器本质上和混淆器区别极小,在这个例子中毫无区别。两者的模式都是寻找可以替换的特征,之后将其替换成另一种实现。

再进一步

虽说混淆器和解混淆器区别极小,但这不代表没有,它们在开发时的侧重点不太一样。实际上,上面的混淆器在很多情况下是无法工作的,例如:

function a ($a = 'abcd') { echo $a; }

把这行代码进行混淆,就有出错的可能。因为这里’abcd’作为函数的默认值,PHP要求它必须在编译时就已知。因此,我们必须给混淆器加上一个判断。下面的代码可以部分规避这个问题。

public function enterNode(Node $node)
{
    if ($node instanceof Node\Param || $node instanceof Node\Stmt\Static_) {
        $this->_inStatic = true;
    }
}

public function leaveNode(Node $node)
{
    if ($node instanceof Node\Param || $node instanceof Node\Stmt\Static_) {
        $this->_inStatic = false;
    }
    if ($this->_inStatic) {
        return;
    }
    // original code...
}

混淆器是将代码复杂化,因此它必须考虑相当多的边边角角。而解混淆器作为将代码简单化的工具,不需要考虑这种情况。解混淆器考虑的情况则是另外一种。

让我们写一个稍微高阶一些的混淆和解混淆:

$a = true;
$b = false;
$c = 12345;
$d = 'abcdefg';

写成

$array = [true, false, 12345, 'abcdefg'];
$a = $array[0];
$b = $array[1];
$c = $array[2];
$d = $array[3];

可以发现,这种混淆不再是原先的直接替换节点就能解决的混淆了,它引入了一个外部依赖。我们试着写一个混淆器:

<?php
use PhpParser\Lexer;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\NodeVisitorAbstract;

class ConstantToArray extends NodeVisitorAbstract
{
    /**
     * @var string
     */
    private $_variableName = '';
    /**
     * @var array
     */
    private $_constants = [];

    private $_parser;

    private $_inStatic = false;

    public function __construct($_parser)
    {
        // 生成一个用于存储数据的变量名,比如AAAAA
        $this->_variableName = generate_random_variable(5);
        $this->_parser = $_parser;
    }

    public function afterTraverse(array $nodes)
    {
        $keys = [];
        foreach ($this->_constants as $key => $value) {
            $keys[] = unserialize($key);
        }
        $items = base64_encode(serialize($keys));
        // 懒得写一大串了。。。
        $nodes = array_merge($this->_parser->parse(
            "<?php \${$this->_variableName}=unserialize(base64_decode('$items'));"
        ), $nodes);
        return $nodes;
    }

    public function enterNode(Node $node)
    {
        // 在每个函数头部插入global $AAAAA
        if ($node instanceof Node\Stmt\Function_) {
            $global = new Node\Stmt\Global_([new Expr\Variable($this->_variableName)]);
            array_unshift($node->stmts, $global);
        }
        if ($node instanceof Node\Param || $node instanceof Node\Stmt\Static_) {
            $this->_inStatic = true;
        }
    }

    public function leaveNode(Node $node)
    {
        if ($node instanceof Node\Param || $node instanceof Node\Stmt\Static_) {
            $this->_inStatic = false;
        }
        if ($this->_inStatic) {
            return;
        }

                // 处理字符串、数字等类型
        if ($node instanceof Node\Scalar
            && (!$node instanceof Node\Scalar\MagicConst)) {
            // 使用serialize是为了解决类型问题,PHP是个神奇的弱类型语言
            $name = serialize($node->value);
            // _constants是个Map,这样做性能会高一些
            if (!isset($this->_constants[$name])) {
                // 这里最好事先扫描一遍并编制索引以提升随机性
                // count仅供测试用,比较好看
                $this->_constants[$name] = count($this->_constants);
            }
            return new Expr\ArrayDimFetch(
                new Expr\Variable($this->_variableName),
                Node\Scalar\LNumber::fromString($this->_constants[$name])
            );
        }

          // 处理true, false等类型
        if ($node instanceof Node\Expr\ConstFetch && $node->name instanceof Node\Name && count($node->name->parts) === 1) {
            $name = $node->name->parts[0];
            switch (strtolower($name)) {
                case 'true':
                    $name = true;
                    break;
                case 'false':
                    $name = false;
                    break;
                case 'null':
                    $name = null;
                    break;
                default:
                    return;
            }
            $name = serialize($name);
            if (!isset($this->_constants[$name])) {
                $this->_constants[$name] = count($this->_constants);
            }
            return new Expr\ArrayDimFetch(
                new Expr\Variable($this->_variableName),
                Node\Scalar\LNumber::fromString($this->_constants[$name])
            );
    }
}

而解混淆又要怎么写呢?

<?php
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;

class ArrayToConstant extends NodeVisitorAbstract
{
    /**
     * @var string
     */
    private $_variableName = '';
    /**
     * @var array
     */
    private $_constants = [];

    public function enterNode(Node $node)
    {
        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"));
        }
    }

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

}

我们看enterNode这里的大if,这里负责寻找$a = unserialize(base64_decode("string"))这种模式的代码,之后获取其表以及变量名。从上面的寻找逻辑,我们可以推测:

  1. 如果代码中有别的符合这个模式的代码,解混淆器就可能会出现错误。
  2. 如果代码中的数组赋值是别的模式,就必须重写此部份代码以适配该模式。

总结

对于混淆器而言,你要做的事情包括这些:

  1. 拿到尽可能多的PHP样本,寻找各种可能的语法不兼容问题。
  2. 基于信息不对称性,努力将混淆器引入的语句与真实的业务代码混为一体。
  3. 尽量打乱原始代码结构,能去除的信息(如变量名)尽可能去除。

而对于一个解混淆器而言,就需要:

  1. 准确识别出混淆模式及其依赖的外部信息。
  2. 需要能准确地提取出各类运行时才可获取的密钥、数据。
  3. 一旦信息无法恢复,就需要通过一定的规则还原出近似的信息。

尾声

我本次的混淆比较初级,完全不实用,毕竟连混淆器+解混淆器+写文章也就花了十个小时不到吧,性能低下,且不保证兼容性,仅仅是一个示例,仅供参考。不过我认为这个示例级别的混淆器效果要比绝大多数市面上流通的混淆器效果好得多,那些都是什么垃圾.jpg 一个混淆器要走向实用,你至少也要把控制流给打乱掉,就像 yakpro-po 这样吧。

你可能注意到了,本文中的每一个混淆规则都是一个单独的新类,并没有将不同功能的代码混合在一起;之后通过NodeVisitor::addVisitor在遍历的时候让它们按顺序被调用。这是组合模式这种设计模式的应用,这样的模块化设计非常适合进行后续的维护。

对于解混淆而言,大部分混淆都有一部分混淆规则是相同的,这种设计可以非常容易地就能通过不同规则的重新组合来解出一种新的混淆。而对于混淆而言,还有什么比套娃更有意思的事情呢 :D

如果本文对你有帮助,你可以用支付宝支持一下:

Alipay QrCode
EVO at 2022/7/27[回复]
这种混淆不再是原先的直接替换节点就能解决的混淆了
zsx at 2020/3/20[回复]
这里有一篇文章讲述了讲述了开发PHP混淆需要满足的基本要求和评价指标。不过就我看来,除了名词扯的有点多以外,这篇文章内的实现仍然处于我说的“20分钟就可以解除”的范围。
[1]王江涛. 代码自动混淆技术在脚本源程序加密保护中的应用研究[D]. 广州: 华南理工大学, 2014.
xctf at 2020/3/13[回复]
师傅,看了3天了,还是没看懂...可以整理一份可运行的混淆解混淆放在github吗,谢谢了
ftcx at 2020/4/11[回复]
能参考的资料很多呀,比如这篇 PHP解密:EnPHP mzphp2加密 无法还原变量名的混淆加密 https://www.52pojie.cn/thread-883976-1-1.html