关于PHP-AST这件事

PHP-Parser

👴想写一个自动化寻找 php 反序列化链的小玩意。

最开始写项目的驱动是黄总说要准备国赛的项目赛,然后表示“今年不能像去年一样鸽掉了”,再然后在一次开会上,双方也是相视一笑:“嘿嘿,嘿嘿”,也就这么搁置下来了。然后说要我们准备好带好下一届,让他们搞,然后看了看这群兔崽子,彳亍……还远着。然后现在是确实自己有了想搞的动力(原因不明),突然,欸就很想搞了,所以就再捡起来。

但是对于大概的项目流程还是蛮不理解,又是自己一个人搞,就先摸索研究一下。在网上找了很久找到了 PHP-Parser 这个工具,是专门做 PHP AST 的,简单介绍一下吧


PHP-Parser 的项目主页是 https://github.com/nikic/PHP-Parser。可以对多版本的 PHP 进行完美解析,生成一个抽象语法树

对于词法分析,PHP 有个内置函数 token_get_all() 可以用来获取 TOKENS,作为语法分析的输入,这个开源项目也是用的 token_get_all() 生成的 token 流

不过话说回来,AST,即 Abstract Syntax Tree,也就是抽象语法树,它是源代码语法结构的一种抽象表示,可以将你所写的的代码用树状结构化表现出来


那么话不多说,直接开整吧

安装

可以使用 PHP 的包管理工具 composer 添加,在项目目录执行命令 php composer.phar require nikic/php-parser 即可,如果没有下载 composer,就先执行 curl -s http://getcomposer.org/installer | php 来安装。

开搞

跟着官方文档 学了。

基本组成语法

引入

如果要使用这个库的话,首先要将 composer 生成的自动装带器给包含进来,可以使用 require 'path/to/vendor/autoload.php';

另外,你可以把 xdebug.max_nesting_level 设置为更高的值:ini_set('xdebug.max_nesting_level', 3000);

这样可以确保遍历高度嵌套的节点树时不会出现错误,但是最好完全禁用 Xdebug,因为它很容易使该库的运行速度慢五倍以上。

解析

我们可以创造一个 parser(解析器)实例:

1
2
use PhpParser\ParserFactory;
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);

这个工厂类接受一个参数以区别对待不同版本的 PHP:

Kind Behavior
ParserFactory::PREFER_PHP7 尝试将代码解析为PHP7。如果失败,请尝试将其解析为PHP 5
ParserFactory::PREFER_PHP5 尝试将代码解析为PHP5。如果失败,请尝试将其解析为PHP 7
ParserFactory::ONLY_PHP7 将代码解析为PHP7
ParserFactory::ONLY_PHP5 将代码解析为PHP5

除非有更好的选择,一般默认用 PREFER_PHP7create() 方法可以选择性地接受 Lexer 作为第二个参数,这个我们姑且不做研究。

然后,我们可以把我们需要 AST 化的 PHP 代码传递给 parse 方法,如果遇到了语法错误,将会抛出 PhpParser\Error 异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
use PhpParser\Error;
use PhpParser\ParserFactory;

$code = <<<'CODE'
<?php
function printLine($msg) {
echo $msg, "\n";
}
printLine('Hello World!!!');
CODE;

$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);

try {
$stmts = $parser->parse($code);
// $stmts is an array of statement nodes
} catch (Error $e) {
echo 'Parse Error: ', $e->getMessage();
}

一个 parser 实例可以重复使用以解析多个文件。

节点转储

如果要以人可读的方式去转储 AST 的话,可以使用 NodeDumper

1
2
3
4
5
<?php
use PhpParser\NodeDumper;

$nodeDumper = new NodeDumper;
echo $nodeDumper->dump($stmts), "\n";

对于之前的实例,将会输出如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
array(
0: Stmt_Function(
attrGroups: array(
)
byRef: false
name: Identifier(
name: printLine
)
params: array(
0: Param(
attrGroups: array(
)
flags: 0
type: null
byRef: false
variadic: false
var: Expr_Variable(
name: msg
)
default: null
)
)
returnType: null
stmts: array(
0: Stmt_Echo(
exprs: array(
0: Expr_Variable(
name: msg
)
1: Scalar_String(
value:

)
)
)
)
)
1: Stmt_Expression(
expr: Expr_FuncCall(
name: Name(
parts: array(
0: printLine
)
)
args: array(
0: Arg(
name: null
value: Scalar_String(
value: Hello World!!!
)
byRef: false
unpack: false
)
)
)
)
)

我们还可以通过使用 php-parse 脚本,使用文件名或者代码字符串调用它来获取 AST:

1
2
vendor/bin/php-parse test.php
vendor/bin/php-parse "<?php foo();"

如果想要快速获取 AST 中某些语法的表达方式的话,这样就很可。

节点🌳结构

观察上面的节点转储,我们可以发现上面代码中的 $stms 是两个节点的数组,一个 Stmt_Function 和一个 Stmt_Expression 节点,它们所对应的类名称为:

  • Stmt_Function -> PhpParser\Node\Stmt\Function_
  • Stmt_Expression -> PhpParser\Node\Stmt\Expression

可能有人注意到了 Function 类的最后面多了一个 _,但是 Function 是一个保留关键字,该库中的许多节点类名称都带有尾随 _,以避免与关键字冲突。

因为 PHP 是一种大型语言,所以大概有140多个不同的节点,为了更好地使用它们,将它们分为了三类:

  • PhpParser\Node\Stmt 是语句节点,即不返回值并且不能出现在表达式中的语言构造,例如类定义就是一个语句,因为它没有返回值所以我们不能构造像 func(class A {}); 这样的语句
  • PhpParser\Node\Expr 是表达式节点,即返回值的语言构造,因此可以在其他表达式中出现,表达式的实例如:
    • $var (PhpParser\Node\Expr\Variable)
    • func() (PhpParser\Node\Expr\FuncCall)
  • PhpParser\Node\Scalar 是表示标量值的节点,比如 'string' (PhpParser\Node\Scalar\String_),0 (PhpParser\Node\Scalar\LNumber)或者魔术常数比如 __FILE__ (PhpParser\Node\Scalar\MagicConst\File),所有的PhpParser\Node\Scalars都扩展了PhpParser\Node\Expr,因为标量也是表达式。
  • 这两个组都不存在某些节点,比如名称(PhpParser\Node\Name)和调用参数(PhpParser\Node\Arg)的节点。

Node\Stmt\Expression节点有点令人迷惑,因为它既有术语 statement 又有 expression,这个节点有别于被称为 Node\Expr 的 expr,是一个由表达式 Node\Stmt\Expression 代表的包含 expr 作为子节点的 expression statement。