TL;DR
这是一个比较有启发意义的项目,把现代fuzzer中的coverage guided的形式应用到了php上,现在对于php应用的安全的测试主要分布在基于黑盒的reqeust和response动态测试和静态的白盒审计,还有一个比较有前景的rasp,但是需要涉及php的内核。
php-fuzzer这个项目,我其实以前在想过一些方面的问题,但是又想到php应用太依赖于输入了,比如一个请求可能对应的一个单独的处理过程,但是这个调用过程并不是显式的,比如php里面的autoload,相当于一个间接调用,间接调用在静态分析过程中是非常致命的,往往不能找到准确的CFG(control flow graph),没有准确的CFG,就很难有准确的DFG(data flow graph). 种种原因很难在php下实现比较好的安全测试。
在这篇文章里面,我想讲一下php-fuzzer的原理和比较有意思点,它的作者是写php-parser的作者,php-parser这个项目就不用吹了。
0x00 整体架构
要分析一个fuzzer,应该从三个角度来看:
- 输入产生的机制
- 输入传递的机制
- 监控被测系统的机制
这个项目需要php > 7.4, 因为用一个php的新特性强类型--typed php ,看来php真在向java发展。
0x01 Generator
前面说了这个项目是基于覆盖率反馈的,那么它是如何计算覆盖率呢?其中一个比较重要的概念就是基本块(basic block), 为了提供基于覆盖率的反馈,需要给每个基本块一个独一无二的命名,并且在基本块结尾插入额外的代码,用来作为反馈,表示在一次执行的过程中,经过了这个基本块。
Hook include
php-fuzzer也是遵循了上面的过程。需要对被测php应用,进行语法分析,划分基本块,插入额外代码,重塑被测php应用。php-fuzzer使用的是动态插桩,并不会改变原php应用本地代码。这就涉及到如何在运行被测php应用的过程中去插桩?
运行一个php应用通常是以一个php文件作为入口点,然后动态的包含其他文件。如果我现在要去运行一个php应用,除了通过命令 php target.php 还可以 我重新使用一个new.php去动态包含target.php,然后php new.php。
如果有一个方案是在include某个php文件之前,对这个php文件内容动态插桩,通过这个方案,我就能对整个php应用进行插桩,但是有一个问题是如何hook include? 我们不能去改变被测php应用的本地代码,而且如果改php内核来实现又太过于复杂,没必要。
所以在php-fuzzer里面第一个有意思的地方在这里,如何在php代码层面动态的hook include和require, 作者这里用了一个有意思的库https://github.com/nikic/include-interceptor, 通过stream_wrapper_register来实现,为什么hook file:// 协议就能hook include呢?因为在php内核里面对于IO的处理都有不同协议的wrapper, 对于include本地文件,那就是使用的file:// 协议的wrapper, 如果include 远程链接,就是使用的http://的wrapper。
用了上面这个办法,php-fuzzer就能实现在动态include目标文件之前改变文件内容实现插桩。
插桩
插桩相对来说好理解,先通过php-parser生成目标php文件的语法树,构造自定义的visitor,针对指定的节点插入额外反馈代码。
$___key = (Context::$prevBlock << 28) | BLOCK_INDEX;
Context::$edges[$___key] = (Context::$edges[$___key] ?? 0) + 1;
Context::$prevBlock = BLOCK_INDEX;
其中基本块的命名通过一个累加变量来实现
public function getNewBlockIndex(int $pos): int {
$blockIndex = $this->blockIndex++;
$this->fileInfo->blockIndexToPos[$blockIndex] = $pos;
return $blockIndex;
}
edge的定义为 prevBlockIndex << 28 | curBlockIndex,前一个块index左移28位或上当前块index,就是一个边界的命名。怎么划分php里面的基本块呢?
-
逻辑结构
- if
- else if
- else
- while
- for
- case
- catch
- switch
- ...
-
逻辑运算
- &&
- ||
- !
- ==
- !=
上述目标节点都可能影响控制流,针对不同的节点,插桩方法也不同,例如:
if($condition){
//$stub
}
//stub
没有else的if 需要再上面两个地方插桩,但如果有esle的if,又有不同
if($condition){
//$stub
}else{
//$stub
}
现在这个if结构外面就不需要插桩了,还需要考虑一种情况:
if($condition1 && $condition2 //$stub){
}
上面插桩是额外的stmts而不是表达式,所以这里需要额外引入一种关于expr插桩的技巧:
if($condition1 && trace($conditions2)){
}
这里trace的返回值还是$conditions2不影响整体的逻辑。上述问题php-fuzzer均有考虑,这里就介绍完了一些插桩里面的小技巧,有点类似注释的fuzzer。
语料&&输入队列
输入可以用户提供,也可以初始化动态生成,一次输入执行的结果,就是关于edges[edge=>hit]的数组,需要进一步抽象相关的关系:
//count
//0: 0 hits
//1: 1 hit
//2: 2 hits
//3: 3 hits
//4: 4-7 hits
//5: 8-15 hits
//6: 16-127 hits
//7: >=128 hits
$feature = $count << 56 | $edge
//前面prevblock 和 curblock 的index使用了前56位,总体是64int
对比每次一次执行结构里面features,记录新的feature,如果有新的feature,就代表当前输入是能触发新的执行路径,就把当前的输入加入到输入队列,语料就是输入队列中总的输入字符串。
其中有一些小策略,两次输入有相同的features,取最短的输入,删除队列里面较长的输入。php-fuzzer也有精简输入语料的功能,保留最短和有不同features的输入。
输入变异和交叉
php-fuzzer有不同的变异策略,同样也有交叉策略,使用两个输入构造新的输入,其中相关函数都是基于libfuzzer,也有libfuzzer里面字典策略,字典策略有时候能提高效率,减少畸形的输入。
其中也有一个扩展输入长度的策略,如果在某个输入长度,迭代次数超过了相关最大次数,就可以尝试扩展输入长度的上限。
这些策略都是一些常规随机策略。
0x01 delivery
其中的输入传递策略,也就是我之前说通过include来执行目标php应用,所以再使用php-fuzzer之前你需要写一个target.php,php-fuzzer通过include这个target.php实现动态插桩和来设定输入目标,通常是是一个闭包函数。可以看看项目下example里面的例子:
<?php declare(strict_types=1);
/** @var PhpFuzzer\Fuzzer $fuzzer */
$autoload = __DIR__ . '/PHP-CSS-Parser/vendor/autoload.php';
if (!file_exists($autoload)) {
echo "Cannot find PHP-CSS-Parser installation in " . __DIR__ . "/PHP-CSS_Parser\n";
exit(1);
}
require $autoload;
$fuzzer->setTarget(function(string $input) {
$parser = new Sabberworm\CSS\Parser($input);
$parser->parse();
});
其中包含traget.php的过程在php-fuzzer中也是闭包执行,用来传递$fuzzer实例。
(static function(Fuzzer $fuzzer) use($path) {
require $path;
})($this);
0x02 monitor
我之前也好奇什么时候代表目标应用存在问题?二进制程序有crash或者hang可以代表问题的发现,php库类应该不会发生crash,php代码默认内存安全?其中相关php库类也应该有自己的错误处理机制。php-fuzzer对于错误的发现代码量也很少:
$crashInfo = null;
try {
($this->target)($input);
} catch (\Exception $e) {
// Assume that exceptions are not an abnormal conditions.
} catch (\ParseError $e) {
echo "PARSE ERROR $e\n";
echo "INSTRUMENTATION BROKEN? -- ABORTING";
exit(-1);
} catch (\Error $e) {
$crashInfo = (string) $e;
}
从代码上来看期望的Error的产生,对于为什么这样,作者给相关解释说:Exception的抛出通常是因为被测应用的自身错误处理的反馈,还有一个ParseError可能在插桩过程由于php文件本身问题解析语法树发生的问题。Error产生于php本身造成的错误。
php-fuzzer 注册了错误处理的handler, 当php内部错误触发时,能及时抛出一个Error:
set_error_handler(function($errno, $errstr, $errfile, $errline) {
if (!(error_reporting() & $errno)) {
return true;
}
throw new \Error(sprintf(
'[%d] %s in %s on line %d', $errno, $errstr, $errfile, $errline));
});
同时也考虑php代码本身的逻辑除外,超时也抛出一个Error,可能当前输入陷入死循环。在错误机制的处理过程也可以看到,能检测的代码问题本身还是比较局限。并不是用来检测通常web安全里面存在的问题。
0x03 思考
这个项目整体给我还是很有启发的,这是一次有意思的尝试,其中也有人在twitter上问作者本人这个东西和其他的php lib的检测有什么不一样?也有人把它和fuzz PHP内核方面搞混,它的意义在于把model fuzzer 技术尝试到了php应用层面上。可能在php代码动态插桩和边界计算有些昂贵,正如作者说的那样,静态属性的边界表示确实很昂贵,可能考虑把这个插桩过程放在一个php 扩展上。那么试想把这个工作放在php 扩展上,需要做哪些工作呢?其实是相同的过程,php内核里面也有ast的中间过程,所以我们需要插入的是opcode中间指令,opcode抽象成函数调用,参数是特殊的基本块标志,类似LLVM里面的asan,同时也提供php层面的函数计算覆盖率。
如果把这个项目的方法 试着推广到一般web应用,其实是比较难的,还是前面那个问题如何解决间接调用,解决不了间接调用就没法构造正确的执行路径,如果尝试结合黑盒测试,爬虫拿到有效的链接,我们可以忽略前置路径的构造,然后结合php source的插桩,这种情况下我们能探索什么问题,肯定不是一般的安全问题,因为一般的安全问题,黑盒测试构造的payload足以覆盖,可能是更深的逻辑问题? Open Problem )