eBPF 抓取 TLS 明文

Jun 14, 2024 23:30 · 4551 words · 10 minute read Linux eBPF Network

原文:https://medium.com/@yunwei356/ebpf-practical-tutorial-capturing-ssl-tls-plain-text-using-uprobe-fccb010cfd64


随着 TLS 在现代网络环境中的广泛使用,追踪微服务 RPC 消息越来越有挑战。数据加密限制了传统的流量嗅探技术观察原始通信内容,阻碍调试和分析系统。

现在有一种新的解决方案:通过 eBPF 及其在用户空间的进行探测的能力,有个方法可以重新拿到明文数据,使我们能够直观地查看加密前的通信内容。尽管如此,每个应用程序可能使用不同的库,而且每个库又有多个版本,给追踪过程带来了复杂性。

在这篇博客中,我们介绍一个跨越各种用户空间 SSL/TLS 库的 eBPF 追踪技术,不仅可以同时追踪像 GnuTLS 和 OpenSSL 这样的用户空间库,而且与之前的方法相比大大减少了对多版本的维护工作。

在深入主题之前,我们要先温习一下背景知识。

SSL 和 TLS

SSL(Secure Sockets Layer):由网景在 1990 年代初开发,用于加密在网络上的两台机器之间的通信。但是由于已知的安全漏洞,SSL 被其继任者 TLS 所取代。

TLS(Transport Layer Security):提供更强更安全的数据加密方法。TLS 通过一个握手过程运作,客户端和服务器在此期间选择一个加密算法及相应的密钥。一旦握手完成,就开始传输数据了,所有数据都使用选定的算法和密钥加密。

TLS 原理

TLS 的主要目的是利用密码学(如证书)为多个通过网络通信的应用程序提供安全保护,由两个子层组成:TLS 记录协议和 TLS 握手协议。

握手过程

  1. 初次握手:客户端连接启动了 TLS 的服务器,请求建立安全连接,并提供一张支持的密码套件表(加密算法和哈希函数)。
  2. 选择加密套件:服务器选择一个它支持的加密套件并通知客户端其决定。
  3. 提供数字证书:通常服务器随后会提供一种数字证书形式的身份验证。这个证书包括服务器的名称、受信任的证书颁发机构(保证证书的真实性)以及服务器的公钥。
  4. 证书验证:客户端验证证书是否有效。
  5. 生成会话密钥:客户端又两种方法:
    • 使用服务器的公钥加密一个随机数并发送给服务器(只有服务器能够用其私钥解密);然后双方利用这个随机数生成一个唯一的会话密钥,用于在会话期间加解密数据。
    • 使用 Diffie-Hellman 密钥交换(或其变体,椭圆曲线 DH)来安全生成一个唯一的会话密钥用于加解密。这个密钥具有向前保密的额外属性:即使以后服务器的私钥泄漏了,也无法用它来解密当前会话,即使第三方截获并记录了该会话。

这些步骤完成,握手过程就结束了,加密连接开始。这个连接使用会话密钥加解密直到连接关闭。

TLS 与 OSI 模型

TLS 和 SSL 并不完全对应得上 OSI 或 TCP/IP 模型。TLS 位于传输层之上,为更高层(通常是表示层)提供加密。然而使用 TLS 的应用程序经常将其视作传输层,尽管 TLS 的应用程序必须主动控制握手以及交换证书

eBPF 和用户探针(uprobe)

eBPF(Extended Berkeley Packet Filter):是一种内核技术,允许用户在内核空间运行预定义的程序,而无需修改内核源码或重载模块。它创建了一个桥梁,使得用户空间和内核空间交互成为可能,为系统监控、性能分析和网络流量分析提供了前所未有的能力。

什么是 eBPF

uprobe 是 eBPF 的一个重要特性,允许在应用程序用户空间中动态插入探测点,特别适用于追踪 SSL/TLS 库中的函数调用。

用户空间库

SSL/TLS 协议的实现很大程度上依赖于用户空间的库,以下是一些常见的:

  1. OpenSSL:广泛用于很多开源和商业项目中
  2. BoringSSL:Google 维护的 OpenSSL 分支,专注于简化和优化以满足 Google 的需求。
  3. GnuTLS:GNU 项目的一部分,在 API 设计、模块结构和许可证方面与前两者不同。

OpenSSL API 分析

在 OpenSSL 中,SSL_read() 和 SSL_write() 是两个用于读取和写入 TLS/SSL 连接的核心 API 函数。

  1. SSL_read 函数

    可以使用 SSL_readSSL_read_ex 函数从一个已建立的 SSL 连接读取数据,函数签名如下:

    int SSL_read_ex(SSL *ssl, void *buf, size_t num, size_t *readbytes);
    int SSL_read(SSL *ssl, void *buf, int num);
    

    SSL_readSSL_read_ex 尝试从给定的 ssl 连接读取最多 num 字节的数据倒缓冲区 buf。一旦读取成功,SSL_read_ex 将真实读到的字节数量存到 *readbytes 中。

  2. SSL_write 函数

    使用 SSL_writeSSL_write_ex 函数向已建立的 SSL 连接写入数据,函数签名如下:

    int SSL_write_ex(SSL *s, const void *buf, size_t num, size_t *written);
    
    int SSL_write(SSL *ssl, const void*buf, int num);
    

    SSL_writeSSL_write_ex 数据从缓冲区 buf 写入最多 num 字节的数据倒给定的 ssl 连接。一旦写入成功,SSL_write_ex 将真实写入的字节数量存到 *written 中。

写 eBPF 内核代码

在我们的示例中,使用 eBPF 挂钩 SSL_readSSL_write 函数,在从 SSL 链接读取或写入数据时执行自定义操作。

首先定义一个数据结构 probe_SSL_data_t 来在内核和用户空间之间传递数据:

#define MAX_BUF_SIZE 8192
#define TASK_COMM_LEN 16

struct probe_SSL_data_t {
    __u64 timestamp_ns;  // Timestamp (nanoseconds)
    __u64 delta_ns;      // Function execution time
    __u32 pid;           // Process ID
    __u32 tid;           // Thread ID
    __u32 uid;           // User ID
    __u32 len;           // Length of read/write data
    int buf_filled;      // Whether buffer is filled completely
    int rw;              // Read or Write (0 for read, 1 for write)
    char comm[TASK_COMM_LEN]; // Process name
    __u8 buf[MAX_BUF_SIZE];  // Data buffer
    int is_handshake;    // Whether it's handshake data
};

挂钩函数

定义一个 SSL_exit 函数来处理这那个函数的返回值,根据当前的进程和线程 ID 确定是否追踪和收集数据:

static int SSL_exit(struct pt_regs *ctx, int rw) {
    int ret = 0;
    u32 zero = 0;
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 pid = pid_tgid >> 32;
    u32 tid = (u32)pid_tgid;
    u32 uid = bpf_get_current_uid_gid();
    u64 ts = bpf_ktime_get_ns();

    if (!trace_allowed(uid, pid)) {
        return 0;
    }

    /* store arg info for later lookup */
    u64 *bufp = bpf_map_lookup_elem(&bufs, &tid);
    if (bufp == 0)
        return 0;

    u64 *tsp = bpf_map_lookup_elem(&start_ns, &tid);
    if (!tsp)
        return 0;
    u64 delta_ns = ts - *tsp;

    int len = PT_REGS_RC(ctx);
    if (len <= 0)  // no data
        return 0;

    struct probe_SSL_data_t *data = bpf_map_lookup_elem(&ssl_data, &zero);
    if (!data)
        return 0;

    data->timestamp_ns = ts;
    data->delta_ns = delta_ns;
    data->pid = pid;
    data->tid = tid;
    data->uid = uid;
    data->len = (u32)len;
    data->buf_filled = 0;
    data->rw = rw;
    data->is_handshake = false;
    u32 buf_copy_size = min((size_t)MAX_BUF_SIZE, (size_t)len);

    bpf_get_current_comm(&data->comm, sizeof(data->comm));

    if (bufp != 0)
        ret = bpf_probe_read_user(&data->buf, buf_copy_size, (char *)*bufp);

    bpf_map_delete_elem(&bufs, &tid);
    bpf_map_delete_elem(&start_ns, &tid);

    if (!ret)
        data->buf_filled = 1;
    else
        buf_copy_size = 0;

    bpf_perf_event_output(ctx, &perf_SSL_events, BPF_F_CURRENT_CPU, data,
                            EVENT_SIZE(buf_copy_size));
    return 0;
}

这里的 rw 参数表示这是读还是写操作:0 表示读;1 表示写。

数据采集过程

  1. 拿到当前进程和线程的 ID,还有当前用户的 UID。
  2. 使用 trace_allowed 来确定该进程是否允许追踪。
  3. 拿到开始时间以计算函数的执行时间。
  4. 尝试从 bufsstart_ns 表获取相关数据。
  5. 如果数据检索成功,创建一个 probe_SSL_data_t 结构来填充数据。
  6. 从用户空间复制数据到缓冲区,确保不会溢出。
  7. 最后向用户空间发送数据。

注意:我们用了两个用户级返回探针 uretprobe 分别挂钩 SSL_readSSL_write 的返回:

SEC("uretprobe/SSL_read")
int BPF_URETPROBE(probe_SSL_read_exit) {
    return (SSL_exit(ctx, 0));  // 0 indicates read operation
}

SEC("uretprobe/SSL_write")
int BPF_URETPROBE(probe_SSL_write_exit) {
    return (SSL_exit(ctx, 1));  // 1 indicates write operation
}

挂钩握手过程

在 SSL/TLS 中,握手是客户端和服务器建立安全连接的特殊过程。为了分析该过程,我们挂钩到 do_handshake 函数以追踪握手的始终。

进入握手

使用 uprobe 来为 do_handshake 函数建立探针:

SEC("uprobe/do_handshake")
int BPF_UPROBE(probe_SSL_do_handshake_enter, void *ssl) {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 pid = pid_tgid >> 32;
    u32 tid = (u32)pid_tgid;
    u64 ts = bpf_ktime_get_ns();
    u32 uid = bpf_get_current_uid_gid();

    if (!trace_allowed(uid, pid)) {
        return 0;
    }

    /* store arg info for later lookup */
    bpf_map_update_elem(&start_ns, &tid, &ts, BPF_ANY);
    return 0;
}
  1. 获取当前 PID、TID、TS 和 UID。
  2. 通过 trace_allowed 验证是否当前进程允许追踪。
  3. 将当前时间存在 start_ns 表中,稍后用于计算握手的持续时间。

退出握手

同样为 do_handshake 的返回建立一个 uretprobe

SEC("uretprobe/do_handshake")
int BPF_URETPROBE(handle_do_handshake_exit) {
    // Code to execute upon exiting the do_handshake function.
    return 0;
}

在这种情况下,当 do_handshake 函数退出时,uretprobe 会执行回调函数:

SEC("uretprobe/do_handshake")
int BPF_URETPROBE(probe_SSL_do_handshake_exit) {
    u32 zero = 0;
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 pid = pid_tgid >> 32;
    u32 tid = (u32)pid_tgid;
    u32 uid = bpf_get_current_uid_gid();
    u64 ts = bpf_ktime_get_ns();
    int ret = 0;

    /* use kernel terminology here for tgid/pid: */
    u32 tgid = pid_tgid >> 32;

    /* store arg info for later lookup */
    if (!trace_allowed(tgid, pid)) {
        return 0;
    }

    u64 *tsp = bpf_map_lookup_elem(&start_ns, &tid);
    if (tsp == 0)
        return 0;

    ret = PT_REGS_RC(ctx);
    if (ret <= 0)  // handshake failed
        return 0;

    struct probe_SSL_data_t *data = bpf_map_lookup_elem(&ssl_data, &zero);
    if (!data)
        return 0;

    data->timestamp_ns = ts;
    data->delta_ns = ts - *tsp;
    data->pid = pid;
    data->tid = tid;
    data->uid = uid;
    data->len = ret;
    data->buf_filled = 0;
    data->rw = 2;
    data->is_handshake = true;
    bpf_get_current_comm(&data->comm, sizeof(data->comm));
    bpf_map_delete_elem(&start_ns, &tid);

    bpf_perf_event_output(ctx, &perf_SSL_events, BPF_F_CURRENT_CPU, data,
                            EVENT_SIZE(0));
    return 0;
}

这段函数:

  1. 获取当前 PID、TID、TS 和 UID。
  2. 使用 trace_allowed 再次检查是否允许追踪。
  3. start_ns 表中查找开始时间来计算握手持续时间。
  4. 使用 PT_REGS_RC(ctx) 来得到 do_handshake 的返回值并判断是否握手成功。
  5. 查找或初始化当前线程的 probe_SSL_data_t 数据结构。
  6. 更新数据结构的字段,包括时间戳、持续时间、进程信息等。
  7. 使用 bpf_perf_event_output 向用户空间发送数据。

以上代码不仅追踪 SSL_readSSL_write 的数据传输,还关注 SSL/TLS 的握手过程。这些信息对深入理解和优化安全连接的性能至关重要。

通过这些钩子函数,我们可以获得握手是否成功,所用时间以及相关信息。

用户空间辅助代码分析

在 eBPF 生态系统中,用户空间和内核空间代码通常协同工作。内核空间的代码负责数据收集,而用户空间代码管理,处理这些数据。我们来解释下上述用户空间代码如何与 eBPF 协作以追踪 SSL/TLS 交互。

1. 支持的库

根据 env 环境变量的设置,程序可以选择附加到三个常见的加密库(OpenSSL、GnuSSL 和 NSS):这意味着我们可以在同一个工具中追踪多个库的调用。

要实现该功能,首先使用 find_library_path 函数确定库的路径。然后根据库的类型,调用相应的 attach_ 函数将 eBPF 程序附加到库函数上。

if (env.openssl) {
        char *openssl_path = find_library_path("libssl.so");
        printf("OpenSSL path: %s\n", openssl_path);
        attach_openssl(obj, "/lib/x86_64-linux-gnu/libssl.so.3");
    }
if (env.gnutls) {
    char *gnutls_path = find_library_path("libgnutls.so");
    printf("GnuTLS path: %s\n", gnutls_path);
    attach_gnutls(obj, gnutls_path);
}
if (env.nss) {
    char *nss_path = find_library_path("libnspr4.so");
    printf("NSS path: %s\n", nss_path);
    attach_nss(obj, nss_path);
}

NSS 原先由网景开发,目前由 Mozilla 维护,其他两个库不再赘述。

2. 细节

attach 函数:

#define __ATTACH_UPROBE(skel, binary_path, sym_name, prog_name, is_retprobe)   \
    do {                                                                       \
      LIBBPF_OPTS(bpf_uprobe_opts, uprobe_opts, .func_name = #sym_name,        \
                  .retprobe = is_retprobe);                                    \
      skel->links.prog_name = bpf_program__attach_uprobe_opts(                 \
          skel->progs.prog_name, env.pid, binary_path, 0, &uprobe_opts);       \
    } while (false)

int attach_openssl(struct sslsniff_bpf *skel, const char *lib) {
    ATTACH_UPROBE_CHECKED(skel, lib, SSL_write, probe_SSL_rw_enter);
    ATTACH_URETPROBE_CHECKED(skel, lib, SSL_write, probe_SSL_write_exit);
    ATTACH_UPROBE_CHECKED(skel, lib, SSL_read, probe_SSL_rw_enter);
    ATTACH_URETPROBE_CHECKED(skel, lib, SSL_read, probe_SSL_read_exit);

    if (env.latency && env.handshake) {
        ATTACH_UPROBE_CHECKED(skel, lib, SSL_do_handshake,
                            probe_SSL_do_handshake_enter);
        ATTACH_URETPROBE_CHECKED(skel, lib, SSL_do_handshake,
                                probe_SSL_do_handshake_exit);
    }

    return 0;
}

int attach_gnutls(struct sslsniff_bpf *skel, const char *lib) {
    ATTACH_UPROBE_CHECKED(skel, lib, gnutls_record_send, probe_SSL_rw_enter);
    ATTACH_URETPROBE_CHECKED(skel, lib, gnutls_record_send, probe_SSL_write_exit);
    ATTACH_UPROBE_CHECKED(skel, lib, gnutls_record_recv, probe_SSL_rw_enter);
    ATTACH_URETPROBE_CHECKED(skel, lib, gnutls_record_recv, probe_SSL_read_exit);

    return 0;
}

int attach_nss(struct sslsniff_bpf *skel, const char *lib) {
    ATTACH_UPROBE_CHECKED(skel, lib, PR_Write, probe_SSL_rw_enter);
    ATTACH_URETPROBE_CHECKED(skel, lib, PR_Write, probe_SSL_write_exit);
    ATTACH_UPROBE_CHECKED(skel, lib, PR_Send, probe_SSL_rw_enter);
    ATTACH_URETPROBE_CHECKED(skel, lib, PR_Send, probe_SSL_write_exit);
    ATTACH_UPROBE_CHECKED(skel, lib, PR_Read, probe_SSL_rw_enter);
    ATTACH_URETPROBE_CHECKED(skel, lib, PR_Read, probe_SSL_read_exit);
    ATTACH_UPROBE_CHECKED(skel, lib, PR_Recv, probe_SSL_rw_enter);
    ATTACH_URETPROBE_CHECKED(skel, lib, PR_Recv, probe_SSL_read_exit);

    return 0;
}

我们进一步检查 attach_ 函数,它们都使用了 ATTACH_UPROBE_CHECKEDATTACH_URETPROBE_CHECKED 宏来实现特定的逻辑。这两个宏分别用于设置 uprobe(函数入口)和 uretprobe(函数返回)。

考虑到不同的库 API 不一样(例如 OpenSSL 使用 SSL_write;GnuTLS 使用 gnutls_record_send),我们需要给每个库编写单独的 attach_ 函数。

例如在 attach_openssl 函数中,我们为 SSL_writeSSL_read 设置了探针。如果用户还想追踪握手延迟(env.latency)和握手过程(env.handshake),我们会为 SSL_do_handshake 设置探针。

在 eBPF 生态系统中,perf_buffer 是一种用于将数据从内核空间传输到用户空间的高效机制。这对于内核空间的 eBPF 程序特别有用尤其有用,因为它们不能直接与用户空间交互。通过 perf_buffer 可以在内核空间的 eBPF 程序中收集数据,然后在用户空间异步读取这些数据。我们使用 perf_buffer__poll 函数来读取内核空间上报的数据,如下:

while (!exiting) {
    err = perf_buffer__poll(pb, PERF_POLL_TIMEOUT_MS);
    if (err < 0 && err != -EINTR) {
        warn("error polling perf buffer: %s\n", strerror(-err));
        goto cleanup;
    }
    err = 0;
}

最后,在 print_event 函数中将数据打印到标准输出:

// Function to print the event from the perf buffer
void print_event(struct probe_SSL_data_t *event, const char *evt) {
    ...
    if (buf_size != 0) {
        if (env.hexdump) {
            // 2 characters for each byte + null terminator
            char hex_data[MAX_BUF_SIZE * 2 + 1] = {0};
            buf_to_hex((uint8_t *)buf, buf_size, hex_data);

            printf("\n%s\n", s_mark);
            for (size_t i = 0; i < strlen(hex_data); i += 32) {
                printf("%.32s\n", hex_data + i);
            }
            printf("%s\n\n", e_mark);
        } else {
            printf("\n%s\n%s\n%s\n\n", s_mark, buf, e_mark);
        }
    }
}

完整代码在此:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/30-sslsniff

编译和执行

得先编译 sslsniff(作者提供的源码有些问题,请跟随我的步骤执行):

$ git clone --recurse-submodules https://github.com/eunomia-bpf/bpf-developer-tutorial.git
$ cd bpf-developer-tutorial/src/30-sslsniff
$ yum install libbpf-static clang -y # rocky linux here
$ ln -s /usr/lib64/libbpf.a  .output/libbpf.a
$ sed -i 's#"/lib/x86_64-linux-gnu/libssl.so.3"#openssl_path#g' sslsniff.c
$ make
# a lot of output
  BPF      .output/sslsniff.bpf.o
  GEN-SKEL .output/sslsniff.skel.h
  CC       .output/sslsniff.o
  BINARY   sslsniff

执行 sslsniff 命令:

$ ./sslsniff

在另一个终端内执行:

$ curl https://blog.crazytaxii.com
    <!DOCTYPE html>
<html lang="zh-Hans">

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta property="og:type" content="website" />
    <meta property="og:title" name="title"
        content="风与云原生">
    <meta name="author" content="HF">
    <meta property="og:description" name="description" content="Software Engineer @ 99Cloud">

    <meta name="generator" content="Hugo 0.127.0">
    <title>风与云原生</title>

在 curl 命令执行后,sslsniff 会输出以下内容:

READ/RECV    0.015863851        curl             4135156 4096
----- DATA -----

                            <h2></h2>


                </a>
            </li>




            <li>
                <aside class="dates">Mar 10</aside>
                <a href='https://blog.crazytaxii.com/posts/kvm_host_in_a_few_lines_of_code/'>
                    一百来行代码弄个 KVM 虚机


                            <h2></h2>


                </a>
            </li>




            <li>
                <aside class="dates">Feb 21</aside>
                <a href='https://blog.crazytaxii.com/posts/transactions/'>
                    事务


                            <h2></h2>

执行下面命令显式延迟和握手过程(同时 curl):

$ ./sslsniff -l --handshake
OpenSSL path: /lib64/libssl.so.3
GnuTLS path: /lib64/libgnutls.so.30
NSS path: /lib64/libnspr4.so
FUNC         TIME(s)            COMM             PID     LEN     LAT(ms)
HANDSHAKE    0.000000000        curl             4135193 1      0.081  WRITE/SEND   0.000134499        curl             4135193 24     0.049
----- DATA -----
PRI * HTTP/2.0

总结

eBPF 是一项非常强大的技术,可以帮助我们更深入地了解 Linux 系统的工作原理。本文只是一个简单的示例,演示如何使用 eBPF 监控 SSL/TLS 通信。如果你对 eBPF 技术感兴趣并希望进一步学习和实践,可以访问教程代码库:https://github.com/eunomia-bpf/bpf-developer-tutorial 和教程网站:https://eunomia.dev/zh/tutorials/

作者提供的源码存在一些问题,运行 sslsniff 无法加载操作系统上已经安装好的 libssl.so.3 动态链接库导致无法追踪 curl TLS 通信,我将尝试提交 PR 来帮助修复。


另外,想使用 bcc 工具包又苦于安装大量依赖的同学可以使用我最新构建的 bcc 镜像:

podman run -it --rm \
  --privileged \
  -v /lib/modules:/lib/modules:ro \
  -v /usr/src:/usr/src:ro \
  -v /etc/localtime:/etc/localtime:ro \
  --workdir /usr/share/bcc/tools \
  crazytaxii/bcc:latest