eBPF代理:让SSH进程“溯源”,找到背后的客户端IP

咱们先聊个实际场景:服务器上突然出现可疑命令执行,比如有人偷偷查看系统文件,你查进程只能看到命令本身,却不知道是谁通过SSH登录后操作的------是远程的黑客,还是内部人员?如果能把每一个执行的进程,都对应到发起SSH连接的客户端IP、端口和用户,排查问题就会轻松很多。而这,就是eBPF代理的核心作用:给SSH相关的进程"打标签",无论经过多少层操作,都能追溯到最初的SSH客户端来源。

一、先搞懂:这个eBPF代理到底是做什么的?

简单说,这是一个运行在Linux系统上的"监控小工具",基于eBPF技术实现,专门盯着通过SSH登录到服务器的用户。只要有用户通过SSH连接上来,这个工具就会记录下他的客户端IP、端口和用户名,然后跟着这个用户的所有操作------比如执行命令、切换用户(sudo su)、打开新shell,把这些操作和最初的SSH客户端信息绑定在一起。

哪怕用户玩点"小花样",比如从本地再SSH到本地(多层localhost连接)、切换用户隐藏身份,这个工具也能精准找到最初的"源头",把每一条命令和对应的SSH客户端IP对应起来,生成日志方便排查。

举个例子:用户A从IP为192.168.85.129的电脑,通过端口50642 SSH登录到服务器,执行了ls /home、cat /etc/passwd命令,之后又用sudo su切换到root,执行了cat /etc/shadow。这个eBPF代理会记录下所有这些命令,并且都标注上"来源IP:192.168.85.129,端口:50642,原始用户:A",不会因为切换用户、打开新shell就丢失溯源信息。

它的核心价值就是:减少服务器安全取证的手动工作量,遇到可疑操作时,不用一个个查进程、查连接,直接看日志就能定位到是谁通过SSH操作的,提升排查效率。

二、核心知识点铺垫:这些基础你得懂

在讲设计和代码之前,先梳理几个关键知识点,都是这个工具的"底层支撑",大白话讲明白,不用记专业术语,理解意思就行:

1. eBPF:Linux内核里的"万能监控器"

eBPF是Linux内核自带的一项技术,简单说就是"可以在 kernel 里运行小程序,不用修改内核代码、不用重启服务器"。它能实时监控系统里的各种操作------比如进程执行、网络连接、系统调用,而且效率极高,不会给服务器带来太大负担。

咱们这个代理,就是利用eBPF的特性,在系统内核层面"监听"SSH相关的操作,不用侵入SSH本身的代码,就能拿到需要的客户端信息。

2. 几个关键的系统调用:工具的"信息来源"

这个代理能拿到SSH客户端信息,全靠两个核心系统调用,咱们不用懂具体实现,知道它们的作用就行:

  • getpeername:获取"对方"的IP和端口------比如SSH客户端连接服务器时,服务器通过这个调用,就能拿到客户端的IP和端口。
  • getsockname:获取"自己"的IP和端口------补充获取服务器这边的连接信息,配合getpeername,就能完整拿到SSH连接的两端信息。
  • execve:进程执行的"触发信号"------只要有命令执行(比如ls、cat),都会调用这个系统调用,代理就是通过监听这个调用,来记录每一个执行的进程。

3. BPF Map:临时"存信息"的容器

BPF Map是eBPF里的一种"数据结构",相当于一个临时的"数据库",用来存关键信息------比如进程ID(PID)对应SSH客户端IP、进程ID对应用户名。代理在监听过程中,会把拿到的信息存到这里,后续需要追溯时,直接从这里查就行。

4. 进程树追溯:解决"多层操作"的溯源问题

用户通过SSH登录后,可能会切换用户、打开新shell,每一步都会产生新的进程,这些进程的父进程(PPID)会关联起来,形成一个"进程树"。代理会顺着这个进程树往上找,直到找到SSH相关的进程(sshd进程),从而拿到最初的客户端信息------哪怕经过多层切换,也能精准溯源。

三、设计思路:这个代理是怎么"想"出来的?

设计这个eBPF代理的核心思路,其实很简单:"监听关键操作 → 存关键信息 → 追溯进程源头 → 生成日志",咱们分步骤拆解,结合实际场景讲,一看就懂:

第一步:明确核心需求------要解决什么问题?

痛点很明显:传统的SSH日志,只能记录谁登录了、登录时间,却没法把"登录后的操作"和"客户端IP"绑定。比如有人登录后执行了可疑命令,你只能看到命令和执行进程,却不知道这个进程是谁发起的、来自哪个IP。

所以核心需求是:把每一个通过SSH发起的进程,都和最初的SSH客户端IP、端口、用户名绑定,无论经过多少层操作(切换用户、多层SSH、打开新shell),都能追溯到源头。

第二步:确定监控对象------盯着哪些操作?

既然要绑定SSH和进程,就必须盯着三个关键操作,这也是代码里最核心的监控点:

  1. SSH连接建立时:通过getpeername和getsockname,拿到客户端IP、端口,以及服务器的连接信息,存到BPF Map里,关联当前的进程ID(PID)。
  2. 用户执行命令时:通过execve系统调用,捕捉到进程执行事件,然后顺着进程树往上找,找到对应的SSH进程,从BPF Map里取出客户端信息。
  3. 特殊场景处理:比如用户从本地SSH到本地(localhost)、切换用户(sudo su)、打开新shell,这些操作会产生新的进程,需要特殊处理,确保客户端信息不丢失。

第三步:设计流程------从监听 to 日志,完整链路

这里给大家画一个简单的流程原理图:

流程原理图:

bash 复制代码
1. SSH客户端 → 连接服务器(触发getpeername/getsockname)→ 代理监听这两个调用,拿到客户端IP、端口 → 存到BPF Map(PID → IP/端口/用户名)
2. 用户执行命令(触发execve)→ 代理监听execve,拿到当前进程ID(PID)和父进程ID(PPID)
3. 代理顺着PPID往上找进程树,直到找到sshd进程(SSH相关进程)
4. 根据sshd进程的PID,从BPF Map里取出对应的客户端IP、端口、原始用户名
5. 把进程信息(PID、PPID、命令)和客户端信息绑定,写入日志(/var/log/sshtrace.log),同时支持实时打印日志
6. 无论用户切换用户、打开新shell,重复步骤2-5,确保客户端信息始终绑定

第四步:考虑异常场景------避免溯源失败

设计时,特意考虑了4种常见场景,避免出现"溯源断档":

  • 远程SSH连接:最常见的场景,直接通过getpeername拿到客户端IP,正常溯源。
  • 本地多层SSH:比如用户先SSH到localhost,再执行命令,此时IP会显示127.0.0.1,代理会通过端口关联,找到最初的远程客户端IP。
  • 权限提升:用户用sudo su切换到root,进程ID会变化,代理通过进程树追溯,依然能找到最初的SSH客户端信息。
  • 打开新shell:用户执行/bin/bash打开新shell,新shell的进程父进程是SSH相关进程,代理会继承客户端信息,不会丢失。

四、代码实现原理:不用懂代码,也能看懂核心逻辑

cpp 复制代码
const char argp_program_doc[] =
    "Trace ssh session spawned execve syscall\n"
    "\n"
    "USAGE: sudo ./sshtrace [-a] [-p] [-v] [-w] [-h]\n"

    "EXAMPLES:\n"
    "   ./sshtrace           # trace all ssh-spawned execve syscall\n"
    "   ./sshtrace -a        # trace all execve syscalls\n"
    "   ./sshtrace -p        # printf all logs\n"
    "   ./sshtrace -v        # verbose events\n"
    "   ./sshtrace -w        # verbose warnings\n"
    "   ./sshtrace -h        # show help\n";

static const struct argp_option opts[] = {
    {"all", 'a', NULL, 0, "trace all execve syscall"},
    {"print", 'p', NULL, 0, "printf all logs"},
    {"verbose", 'v', NULL, 0, "verbose debugging"},
    {"warning", 'w', NULL, 0, "verbose warnings"},
    { "max-args", MAX_ARGS_KEY, "MAX_ARGS", 0,
		"max number of arg param logged, defaults to 20" },
    {NULL, 'h', NULL, OPTION_HIDDEN, "Show the full help"},
    {},
};

...
struct data_t {
  pid_t pid;
  uid_t uid;
  pid_t ppid;
  char command[TASK_COMM_LEN];
  int ret;
  struct sockaddr_in6 addr;
  int type_id; // 0:others 1:getpeername 2:getsockname 3:execve
};

struct event {
  pid_t pid;
  pid_t ppid;
  uid_t uid;
  int retval;
  int args_count;
  unsigned int args_size;
  char comm[TASK_COMM_LEN];
  char args[FULL_MAX_ARGS_ARR];
};
...
static int parse_arg(int key, char *arg, struct argp_state *state) {
  long int max_args;
  switch (key) {
  case 'p':
    envVar.print = true;
    break;
  case 'v':
    envVar.verbose = true;
    logLevel = LOG_TRACE;
    break;
  case 'a':
    envVar.all = true;
    break;
  case 'w':
    envVar.warning = true;
    logLevel = LOG_DEBUG;
    break;
  case 'h':
    argp_state_help(state, stderr, ARGP_HELP_STD_HELP);
    break;
  case MAX_ARGS_KEY:
		errno = 0;
		max_args = strtol(arg, NULL, 10);
		if (errno || max_args < 1 || max_args > TOTAL_MAX_ARGS) {
			fprintf(stderr, "Invalid MAX_ARGS %s, should be in [1, %d] range\n",
					arg, TOTAL_MAX_ARGS);

			argp_usage(state);
		}
		envVar.max_args = max_args;
		break;
  }
  return 0;
}

int main(int argc, char **argv) 
{

  static const struct argp argp = {
      .options = opts,
      .parser = parse_arg,
      .doc = argp_program_doc,
  };
  int argErr = argp_parse(&argp, argc, argv, 0, NULL, NULL);
  if (argErr)
    return argErr;
  log_info("%s", "Starting program...");
  log_set_level(logLevel);
  if (envVar.print) {
    printf("%-24s %-6s %-6s %-6s %-16s %-16s %-16s %-16s %-16s %-6s\n",
           "Timestamp", "PID", "PPID", "UID", "Current User", "Origin User",
           "Command", "IP Address", "Port", "Command Args");
  }

  fp = fopen("/var/log/sshtrace.log", "a"); // open file
  if (fp == NULL) {
    log_info("Log file could not be created or opened");
    return -1;
  }
  log_trace("%s", "Setting LIBBPF options");
  libbpf_set_print(libbpf_print_fn);
  char log_buf[128 * 1024];
  LIBBPF_OPTS(bpf_object_open_opts, opts, .kernel_log_buf = log_buf,
              .kernel_log_size = sizeof(log_buf), .kernel_log_level = 1, );

  log_trace("%s", "Opening BPF skeleton object");
  struct sshtrace_bpf *skel = sshtrace_bpf__open_opts(&opts);
  if (!skel) {
    log_trace("%s", "Error while opening BPF skeleton object");
    return EXIT_FAILURE;
  }

  int err = 0;

  log_trace("%s", "Loading BPF skeleton object");
  err = sshtrace_bpf__load(skel);
  // Print the verifier log
  /*
        for (int i=0; i < 10000; i++) {
                if (log_buf[i] == 0 && log_buf[i+1] == 0) {
                        break;
                }
                printf("%c", log_buf[i]);
        }
  */
  if (err) {
    log_trace("%s", "Error while loading BPF skeleton object");
    goto cleanup;
  }

  log_trace("%s", "Attaching BPF skeleton object");
  err = sshtrace_bpf__attach(skel);
  if (err) {
    log_trace("%s", "Error while attaching BPF skeleton object");
    goto cleanup;
  }

  log_trace("%s", "Initializing perf buffer");
  struct perf_buffer *pb = perf_buffer__new(
      bpf_map__fd(skel->maps.output), 8, handle_event, lost_event, NULL, NULL);
  if (!pb) {
    log_trace("%s", "Error while initializing perf buffer");
    goto cleanup;
  }

  log_trace("Setting up interrupt signal handler");
  signal(SIGINT, intHandler);

  log_trace("%s", "Start polling for BPF events...");
  while (!intSignal) {
    err = perf_buffer__poll(pb, 100 /* timeout, ms */);
  }

  log_trace("%s", "Freeing perf buffer");
  perf_buffer__free(pb);
  goto cleanup;

cleanup:
  log_trace("%s", "Closing File");
  fclose(fp);
  log_trace("%s", "Entering cleanup");
  sshtrace_bpf__destroy(skel);
  log_trace("%s", "Finished cleanup");

  return EXIT_SUCCESS;
}

If you need the complete source code, please add the WeChat number (c17865354792)

提供的代码是用C语言写的,结合了eBPF相关的库(libbpf等),核心逻辑和咱们上面讲的设计流程完全对应。咱们不用逐行看代码,重点拆解几个核心模块,大白话讲明白它们的作用:

1. 初始化模块:准备工作

代码开头会做一些准备工作:比如解析命令行参数(比如-p表示实时打印日志、-a表示监控所有进程、-v表示详细日志)、初始化日志文件(/var/log/sshtrace.log)、加载eBPF程序(skeleton对象,相当于eBPF程序的"容器")、设置信号处理(比如按Ctrl+C退出时,清理资源)。

简单说,这一步就是"启动工具,做好所有准备工作",确保后续能正常监听和记录。

2. 核心监听模块:handle_event函数(最关键)

这个函数是整个代理的"核心大脑",所有监听和溯源逻辑都在这里,咱们拆解它的核心操作:

  • 首先,拿到当前事件的类型:是getpeername、getsockname,还是execve(代码里用1、2、3区分)。
  • 如果是getpeername/getsockname:调用ipHelper函数,把获取到的IP和端口转换成人类能看懂的格式(比如把二进制的IP转换成192.168.85.129),然后存到BPF Map里,关联当前的进程ID。
  • 如果是execve(有命令执行):这是最核心的逻辑------顺着当前进程的父进程(PPID)往上找,直到找到sshd进程(通过进程命令判断,比如命令里包含sshd);找到后,从BPF Map里取出对应的客户端IP、端口和原始用户名;再获取当前执行命令的用户、命令参数,把所有信息绑定,写入日志,如果开启了-p参数,就实时打印出来。

3. 辅助函数:帮核心模块"干活"

代码里还有几个辅助函数,相当于"工具人",帮核心模块完成具体操作,比如:

  • ipHelper:把内核里的IP地址格式(二进制)转换成咱们熟悉的字符串格式(比如192.168.85.129),支持IPv4和IPv6。
  • getUser:通过用户ID(UID),获取对应的用户名(比如UID为1000,对应用户名guac),如果查不到,就返回"n/a"。
  • getPPID:通过进程ID(PID),获取它的父进程ID(PPID),用于追溯进程树。
  • getCommand:通过进程ID(PID),获取进程执行的命令(比如ls、cat)。

4. 日志模块:记录溯源信息

代码会把每一次进程执行的信息,以JSON格式写入/var/log/sshtrace.log文件,日志里包含:时间戳、进程ID(PID)、父进程ID(PPID)、用户ID(UID)、当前用户、原始用户(SSH登录用户)、执行命令、客户端IP、端口、命令参数。

这样一来,后续排查问题时,只要打开这个日志文件,就能清晰看到每一条命令对应的SSH客户端信息,实现"一键溯源"。

五、基础测试

1)只追踪SSH,并且屏幕打印

bash 复制代码
sudo ./sshtrace -p

作用:

  • 只抓 SSH 登录后执行的命令
  • 控制台实时输出
  • 日志写入 /var/log/sshtrace.log

2)全开模式:抓所有进程 + 打印 + 详细信息

bash 复制代码
sudo ./sshtrace -a -p -v

作用:

  • -a:监控所有执行的命令(不管是不是SSH)
  • -p:屏幕打印
  • -v:显示调试信息

3)限定最多记录10个命令参数

bash 复制代码
sudo ./sshtrace -p --max-args 10

4)查看帮助(验证参数是否生效)

bash 复制代码
sudo ./sshtrace -h

六、怎么测试它真的在工作?

步骤 1:运行工具

bash 复制代码
sudo ./sshtrace -p

步骤 2:新开一个终端,SSH 登录本机

bash 复制代码
ssh localhost

或者从别的机器 SSH 过来。

步骤 3:在 SSH 里随便执行命令

bash 复制代码
ls
whoami
pwd
cat /etc/hosts

步骤 4:回到 sshtrace 界面

你会立刻看到类似输出:

复制代码
Timestamp       PID     PPID    UID     Current User  Origin User   Command     IP Address
Apr 06 10:00    1234    1122    1000    ubuntu        ubuntu        ls          127.0.0.1

这就说明完全运行成功


日志在哪里?

所有记录自动保存:

bash 复制代码
tail -f /var/log/sshtrace.log

七、涉及的相关领域知识点总结

这个eBPF代理看似简单,其实融合了多个领域的知识点,也是Linux系统监控、安全取证的常用技术组合,总结一下核心领域,方便大家拓展学习:

1. eBPF技术领域

核心是eBPF的内核编程、BPF Map的使用、eBPF程序的加载和挂载(通过libbpf库)。eBPF现在是Linux系统监控、性能优化、安全审计的核心技术,除了进程溯源,还能用于网络监控、容器监控等场景。

2. Linux系统调用领域

涉及getpeername、getsockname、execve等系统调用的作用和使用场景,这些是Linux系统编程的基础,也是监控进程、网络连接的核心手段。

3. Linux进程管理领域

核心是进程树的概念、PID和PPID的关联,以及如何通过/proc目录(比如/proc/PID/stat、/proc/PID/status)获取进程信息------代码里的getPPID、getCommand、getUID函数,都是通过读取这些文件实现的。

4. 安全取证领域

这个代理本质上是一个安全取证工具,核心需求是"行为溯源"------在服务器被入侵、出现可疑操作时,通过日志快速定位攻击来源(SSH客户端IP),这也是企业服务器安全运维的重要需求。

5. C语言与Linux内核编程领域

代码用C语言编写,结合了Linux内核编程的相关知识(比如内核日志、信号处理、文件操作),同时使用了libbpf等eBPF相关库,是典型的"用户态+内核态"结合的编程模式。

总结

总结下来,这个eBPF代理的核心价值就是"简单、高效、精准"------不用修改内核代码,不用重启服务器,就能实现SSH进程的全链路溯源,解决了传统SSH日志无法关联进程和客户端IP的痛点。

它的主要应用场景的就是Linux服务器的安全运维和取证:比如服务器出现可疑命令执行、文件被篡改,运维人员可以通过这个代理的日志,快速定位到是哪个IP、哪个用户通过SSH操作的,为后续的排查和处置节省大量时间。

而且它的设计思路很有参考意义------利用eBPF监听系统调用,结合进程树追溯和BPF Map存储,实现"操作-源头"的绑定,这种思路也可以用到其他场景,比如容器进程溯源、网络连接溯源等。

Welcome to follow WeChat official account【程序猿编码

相关推荐
Shepherd06192 小时前
【IT 实战】解决 TP-Link USB 无线网卡在 Linux/PVE 下识别为存储设备的问题
linux·运维·服务器
开开心心_Every3 小时前
免费轻量电子书阅读器,多系统记笔记听书
linux·运维·服务器·神经网络·安全·机器学习·pdf
存储服务专家StorageExpert3 小时前
DELL EMC isilon/PowerScale 存储的健康检查方法
linux·运维·服务器·netapp存储·emc存储
熊文豪3 小时前
当系统在后台偷偷“记账“:KES 性能观测体系深度解析
linux·运维·服务器·数据库
哦豁灬3 小时前
ThinkPad X220 安装 Arch Linux 完美指南
linux·服务器·thinkpad·arch linux
自动化智库3 小时前
库卡机器人定义全局变量
linux·运维·机器人
Yiyi_Coding3 小时前
BUG列表:如何定位线上 OOM ?
java·linux·bug
杨云龙UP4 小时前
MySQL慢查询日志暴涨导致磁盘告警:slow query log膨胀至397G的生产故障排查:清理、参数优化
linux·运维·服务器·数据库·mysql
chQHk57BN4 小时前
DeepFlow Agent 故障排查指南:注册失败、协议解析、资源识别与配置方式
linux·运维·服务器