Https 抓包-密文转明文

记录如何在客户端,服务端解密https抓包数据。

在我们日常工作中,经常需要抓包分析各种异常的网络常见。http是网络常见中最常见的一种,随着https的普及,我们生产环境中,大部分都为https的网站。而https所有的交互数据都是经过加密的。

  1. https的核心原理并不复杂,通过非对称加密传递密钥,然后拿这个密钥通过对称加密传递密文数据。
  2. tls1.2原理简单点,通过客户端随机数,服务端随机数,以及预主密钥生成主密钥,然后通过主密钥加密数据。所以tls1.2版本中,我们只要知道了主密钥,就能解密数据。
  3. tls1.3更复杂点,需要的密钥更多。具体原理可以查看这篇文章了解:https://halfrost.com/https-key-cipher/。
  4. 其实,无论原理如何,我们作为使用方,解密明文数据其实很简单,主要分为两个过程:
    1. 记录密钥日志,抓取数据包
    2. 在wireshark中导入密钥日志,打开抓取的数据包即可。
  5. 记录密钥这个功能,想起来可以比较复杂,其实大部分使用tls的软件,库都是有记录密钥功能的。是一个通用的功能,一般通过SSLKEYLOGFILE这个环境变量,设置记录密钥的日志文件。 详细内容可参考:https://firefox-source-docs.mozilla.org/security/nss/legacy/key_log_format/index.html 。下面我们通过多个方面说明,如何记录密钥,使用密钥。

添加用户环境变量 https://s2.loli.net/2023/02/13/ACW5sN9LhaSbMxk.png 添加完之后,我们的windows电脑就以及打开了密钥记录功能。我们重启浏览器,访问https://www.baidu.com/ 后,查看D:\ssl.log文件内容,就会发现密钥以及记录到文件中了。

  • 点击编辑->首选项 -> protocol
  • 在Protocols 标签下,找到TLS标签,旧版本可能是SSL标签
  • 将密钥日志地址填入(Pre)-Master-Secret log filename中。然后点击ok保存。
  • 输入过滤条件,比如我们要抓取www.baidu.com这个网站的数据包。我们就在过滤器输入框中填入:host www.baidu.com
  • 查看抓包内容,跟踪其中一条流,我们就会发现其中有tls的握手包,通过我们可以看到此条https请求,访问的是根路径。说明我们解密数据包已完成。

https://s2.loli.net/2023/02/13/Rou45FkYxUP2O6J.png

  • curl 也是通过设置SSLKEYLOGFILE环境变量记录密钥日志的。比如:

添加环境变量:$ export SSLKEYLOGFILE=/tmp/ssl.log 访问:$ curl https://www.baidu.com

  • 我们还可以选择tls的版本,比如 tls1.2:

$ curl --tlsv1.2 https://www.baidu.com

  • 查看ssl.log文件,发现密钥日志文件只有一行密钥内容。第一列是标签,tls1.2中就是客户端随机数。第二列就是客户端随机数。一次会话中客户端的随机数是固定的。所以,一般会用第二列标识会话。第二列相同就是同一个会话。第三列就是密钥了。在tls1.2中就是主密钥。
1
2
3
$ cat /tmp/ssl.log 
# SSL/TLS secrets log file, generated by NSS
CLIENT_RANDOM 1b152f16c50cae5bcae6845dc1190e2b66d2ce5dfdcbcc3c59371911290aab08 dbc10f1d8ba7ee7b0cc6abc80ce51c2707cd547fa59e04810b2eabd455c59953430b53a06318e3ea9f946cfd4bbf0355
  • 我们把tls版本切换为tls1.3:

$ curl --tlsv1.3 [https://www.baidu.com](https://www.baidu.com)

  • 再次查看密钥文件,就会发现多了几行,这是tls1.3 版本的https在整个协议过程中,需要用到的密钥,比较多。当然,我们仅仅是使用的话,不用过分深究。
1
2
3
4
5
6
CLIENT_RANDOM 1695f088e0d89b5fd878799216638024a2ec2beac41ce6b62eefcae03f64fd18 49301b8e74869d647449177a4fe38a70632f72290dbad620de5f8f056e88789cf99fee2ee328c7e4cd0e06491ff835d9
CLIENT_HANDSHAKE_TRAFFIC_SECRET 212a11025b3beb91bf6b155053df0fd810ccf87fe0aa9f77a96aabfba904e85d 9dba8701d6610613fc33d803117a31d74b9b586e7e122442d8703558a7af2d3599ad3e3f282def968d782e1ba0c80af1
SERVER_HANDSHAKE_TRAFFIC_SECRET 212a11025b3beb91bf6b155053df0fd810ccf87fe0aa9f77a96aabfba904e85d 909915c792f5d8f831540b878d39c8aba37ef1b7562f512a0bc58c99fe91951a1cb617ab7b62f9d712ae43b330cf2888
CLIENT_TRAFFIC_SECRET_0 212a11025b3beb91bf6b155053df0fd810ccf87fe0aa9f77a96aabfba904e85d 0d6ebd5ace7d3a98fee708b6ef8a49806e723cceccfb6460b28b905a3941f4c59ba254ba6536776ac5080304b8e75156
SERVER_TRAFFIC_SECRET_0 212a11025b3beb91bf6b155053df0fd810ccf87fe0aa9f77a96aabfba904e85d eac9be50abd2489cea9f6b9cb1ca382e0a75a23e6df4a2d5f04e2d350806507e43f1cb2d940c266d4e7edc260bf0e6ea
EXPORTER_SECRET 212a11025b3beb91bf6b155053df0fd810ccf87fe0aa9f77a96aabfba904e85d 389bf874068ab12d872a4da94703150fac9255c9d4c5dc1abda242df9197c840746d94e5af220fe215c6a909e582ff98

我们可以查看curl的文档:https://everything.curl.dev/usingcurl/tls/sslkeylogfile, 其实并不是说curl自己实现了密钥日志文件记录,而且利用各种ssl库,比如openssl。这些库本身提供了记录密钥日志文件的功能。 这句话很重要后面会考。

作为sre,其实我们大部分接触的反而是服务器,我们知道,客户端和服务器能通过密文传递数据,既然客户端都能获取密钥。那是否服务端也能呢?答案是显而易见的。 在生产环境中,我们常常使用nginx做反向代理,并让其代理tls。那我们如何在nginx中记录密钥文件呢。主要有以下思路:

比如:https://github.com/tiandrey/nginx-sslkeylog。 我们在编译nginx的时候,把这个开源库编译进nginx。nginx就可以通过访问日志的记录形式,记录tls密钥。我测试了tlsv1.2 ,因为会记录client 随机数和主密钥,所以是可以使用的。tlsv1.3的话,感觉其提供的数据不足,估计不太行。

代码地址如下:https://git.lekensteyn.nl/peter/wireshark-notes/tree/src/sslkeylog.c 操作如下: https://security.stackexchange.com/questions/216065/extracting-openssl-pre-master-secret-from-nginx 当然,我没侧出来。 原理如下:

  • LD_PRELOAD这是共享库的标准的环境变量,LD_PRELOAD指定的共享库中的函数会覆盖nginx依赖的openssl共享库中的函数。
  • 也就是sslkeylog.c中的函数会把openssl中的函数覆盖掉,从而达到改写共享库中某几个函数而不影响其他函数的目的。

此抓包软件,通过ebpf实现。通过ebpf在程序或库中注入,数据处理的ebpf代码。直接在用户态抓取数据包。 此工具我倒是没测试过,因为ebpf比内核版本是有要求的,比如:linux内核4.18。而我们常用的centos7的内核版本是3.10。

题外话: 作为一名sre,我们最常用的排查故障的方法是什么?监控,日志,抓包。监控数据是宏观的,日志能不能记录到错误信息,全凭开发的经验。哪怕开发记录了日志,也不一定与错误信息相关。最好的情况可能就是,错误能在线下复现,通过gdb等调试工具排错。要是只能在生产复现,生产又不能随随便便更新呢。uprobe,kprobe以及后来的ebpf就登场了。

uprobe相关的内容可以查看文档:https://www.kernel.org/doc/html/latest/trace/uprobetracer.html

其实uprobe的原理很简单,我们都知道,我们编译后的程序其实都是汇编程序,一堆汇编指令。uprobe就是可以在这对汇编指令中插入其他的指令。比如我们在bash命令的readline函数入口出,插入uprobe,每当bash命令执行到readline函数时,会先执行uprobe,然后才会执行readline函数本身。除了函数的开始,我们还能在函数的返回处插入uprobe。当程序,比如bash,执行到readline处时,我们就可以获取到此时的函数参数。其实本质上上寄存器,以及内存中的值。比如函数的第一个参数保存在rdi这个寄存器中,第二个保存在rsi这个寄存器中,后面就是rdx、rcx、r8、r9等等。详细介绍可参考:https://cclinuxer.github.io/2021/04/Linux-kprobe%E5%B7%A5%E5%85%B7%E6%B7%B1%E5%BA%A6%E4%BD%BF%E7%94%A8/ 所以,只要我们找到处理ssl的函数,在函数入口处插入uprobe,在找到保存各种密钥的对象,将其输出出来。就可以获取到密钥信息,解析https密文了。 总体过程较为繁琐,如下所示:

前文我们以及说过,openssl这种常用的库,都提供了记录密钥日志文件的功能。只要我们找到了记录密钥的函数,是不是通过探测它就可以获取密钥了? 密钥记录函数如下所示:

1
2
3
4
int ssl_log_secret(SSL *ssl,
                   const char *label,
                   const uint8_t *secret,
                   size_t secret_len)

ssl的加密过程主要在tls13_change_cipher_state函数中,我们通过查看此函数,如下所示:在tls的各种状态中,都会执行ssl_log_secret函数,并不会先判断环境变量再执行。所以哪怕我们没开启密钥日志记录功能,此函数也会执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
 if (!tls13_hkdf_expand(s, md, insecret,
                                   early_exporter_master_secret,
                                   sizeof(early_exporter_master_secret) - 1,
                                   hashval, hashlen,
                                   s->early_exporter_master_secret, hashlen,
                                   1)) {
                SSLfatal(s, SSL_AD_INTERNAL_ERROR,
                         SSL_F_TLS13_CHANGE_CIPHER_STATE, ERR_R_INTERNAL_ERROR);
                goto err;
            }
            if (!ssl_log_secret(s, EARLY_EXPORTER_SECRET_LABEL,
                                s->early_exporter_master_secret, hashlen)) {
                /* SSLfatal() already called */
                goto err;
            }

此函数有四个参数,第一个参数是ssl对象,ssl对象里会保存客户端,服务端的随机数。第二个参数label,就是密钥日志文件的第一列,密钥标签。第三个参数secret,就是我们需要的密钥。第四个参数secret_len就是就是密钥的长度。 所以,这四个参数我们都是构成密钥日志文件的重要组成部分。

1
2
3
4
(SSL ssl,
                   const char label,
                   const uint8_t *secret,
                   size_t secret_len)

要是了解uprobe的话就知道,ssl对象的指针保存再rdi这个寄存器中,label对象的指针保存在rsi这个寄存器中,secret的指针保存rdx这个寄存器中,secret_len的值保存在rcx寄存器中。 我们由简单到复杂进行说明,如何解析这些参数。 secret_len最简单,因为保存的时值,uprobe可以直接读取。所以就是: secret_len=%cx (在uprobe表达式中,我们一般不带r,直接把寄存器记做di,si,dx,cx等) label 作为字符串指针也简单,%cx 保存这label指针,uprobe中通过+偏移量圆括号形式: +0() 将指针解析成地址。比如+0(%cx):string, +0 表示我们要读取(%cx)这个地址偏移量为0的地址,:string 表示我们要从+0(%cx)这个地址中读取字符串。所以解析label这个参数就是: label=+0(%cx):string secret 是一个子节数组,暂且我们通过u64 无符号整型进行解析。即为:secret1=+0(%dx):u64

对象其实使用多个成员变量组成,由于子节对齐的原因,我们很难通过计算得出对象的某个成员变量相对这个对象的偏移量。其实一个c 对象的值就是一个定长的子节数组。第一个成员变量的地址为 对象的地址+偏移量0 的地址。第二个成员变量的地址为 对象的地址+第一个对象的字节长度 的地址,第三个 成员变量的地址就是 对象的地址+第一个对象的子节长度+第二个对象的子节长度+子节对齐的长度。 如果没有子节对齐,其实我们很容器计算出对象某个成员,相对于对象的偏移量。从而得到成员的地址。但是一定会存在子节对齐,所以获取某个成员的地址,颇为复杂。 debuginfo主要保存的是程序的元数据,比如符号表等。我们可以使用gdb解析包含debuginfo的程序。就可以很容易的获取成员的偏移。

系统内的openssl库是不带符号表的。nginx主要依赖libssl.so这个库。我们可以通过包管理工具安装debuginfo包。因为openssl本身编译比较简单。所以我们直接编译带debug信息的libss.so

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// centos7系统
// 检查nginx依赖的openssl版本
$ nginx -V 2>&1 |grep SSL
built with OpenSSL 1.1.1k  FIPS 25 Mar 2021

// 安装编译环境
$ yum install -y wget tar make gcc perl pcre-devel zlib-devel

// 下载对应版本的openssl
$ wget https://www.openssl.org/source/old/1.1.1/openssl-1.1.1k.tar.gz --no-check-certificate

// 解压
$ tar -xf openssl-1.1.1k.tar.gz

cd openssl-1.1.1k

// -d选项就是带debuginfo的编译配置
$ ./config -d --prefix=/usr/local/ssl --openssldir=/usr/local/ssl -Wl,-rpath,/usr/local/ssl/lib shared
make
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 执行gdb
gdb libssl.so.1.1
// 通过ptype 命令查看 ssl对象,ssl结构体的定义名为:struct ssl_st
// 输出信息中,第一列为成员相对于结构体的偏移量,第二列为成员的大小,第三列就是成员的源码。
// 注意偏移量为 168 的对象:struct ssl3_state_st *s3;
// client_random 其实是保存在struct ssl3_state_st 结构体中。
(gdb) ptype /o struct ssl_st
/* offset    |  size */  type = struct ssl_st {
/*    0      |     4 */    int version;
/* XXX  4-byte hole */
/*    8      |     8 */    const SSL_METHOD *method;
/*   16      |     8 */    BIO *rbio;
/*   24      |     8 */    BIO *wbio;
/*   32      |     8 */    BIO *bbio;
/*   40      |     4 */    int rwstate;
/* XXX  4-byte hole */
/*   48      |     8 */    int (*handshake_func)(SSL *);
/*   56      |     4 */    int server;
/*   60      |     4 */    int new_session;
/*   64      |     4 */    int quiet_shutdown;
/*   68      |     4 */    int shutdown;
/*   72      |    60 */    OSSL_STATEM statem;
/*  132      |     4 */    SSL_EARLY_DATA_STATE early_data_state;
/*  136      |     8 */    BUF_MEM *init_buf;
/*  144      |     8 */    void *init_msg;
/*  152      |     8 */    size_t init_num;
/*  160      |     8 */    size_t init_off;
/*  168      |     8 */    struct ssl3_state_st *s3;
/*  176      |     8 */    struct dtls1_state_st *d1;
/*  184      |     8 */    void (*msg_callback)(int, int, int, const void *, size_t, SSL *, void *);
/*  192      |     8 */    void *msg_callback_arg;
/*  200      |     4 */    int hit;
/* XXX  4-byte hole */
/*  208      |     8 */    X509_VERIFY_PARAM *param;
/*  216      |    64 */    SSL_DANE dane;
/*  280      |     8 */    struct stack_st_SSL_CIPHER *peer_ciphers;
/*  288      |     8 */    struct stack_st_SSL_CIPHER *cipher_list;
/*  296      |     8 */    struct stack_st_SSL_CIPHER *cipher_list_by_id;
/*  304      |     8 */    struct stack_st_SSL_CIPHER *tls13_ciphersuites;
// 我们继续通过pteyp命令查看 struct ssl3_state_st 结构体。
// 偏移量为 184的位置就是我们需要的client_random
(gdb) ptype /o struct ssl3_state_st
/* offset    |  size */  type = struct ssl3_state_st {
/*    0      |     8 */    long flags;
/*    8      |     8 */    size_t read_mac_secret_size;
/*   16      |    64 */    unsigned char read_mac_secret[64];
/*   80      |     8 */    size_t write_mac_secret_size;
/*   88      |    64 */    unsigned char write_mac_secret[64];
/*  152      |    32 */    unsigned char server_random[32];
/*  184      |    32 */    unsigned char client_random[32];
/*  216      |     4 */    int need_empty_fragments;
/*  220      |     4 */    int empty_fragment_done;
/*  224      |     8 */    BIO *handshake_buffer;
/*  232      |     8 */    EVP_MD_CTX *handshake_dgst;
/*  240      |     4 */    int change_cipher_spec;
/*  244      |     4 */    int warn_alert;
/*  248      |     4 */    int fatal_alert;
/*  252      |     4 */    int alert_dispatch;
/*  256      |     2 */    unsigned char send_alert[2];
/* XXX  2-byte hole */
/*  260      |     4 */    int renegotiate;
/*  264      |     4 */    int total_renegotiations;
/*  268      |     4 */    int num_renegotiations;
/*  272      |     4 */    int in_read_app_data;
找到了我们需要的成员,uprobe表达式改如何写呢?,我们这样写:client_random=+192(+168(%di)):u64
di 寄存器就是ssl_log_secret函数的第一个参数。保存的是对象ssl的地址。
+168(%di):就是相对 struct ssl_st 这个结构体的地址偏移168的位置,此位置是一个结构体地址:struct ssl3_state_st *s3;
+192(+168(%di)):u64: 再次通过圆括号对上面的地址进行解析,就是相对于
struct ssl3_state_st这个地址偏移量192的位置。此位置是一个字符数组。然后我们通过u64,无符号整型保存。

uprobe的表达式如下所示:

1
‘p:/usr/lib64/libssl.so.1.1:ssl_log_secret lable=+0(%si):string client_random=+184(+168(%di)):u64   secret=+0(%dx):u64 secret_len=%cx’

其中 p 表示,探测函数入口。

  • /usr/lib64/libssl.so.1.1 为我们要探测是进程的地址。
  • ssl_log_secret 就是我们要探测是函数。
  • lable=+0(%si):string 是我们要探测的其中一个参数,其他参数类似,这种形式的表达式可以写多个。其实label 表示变量名,主要是表标识这个表达式的结果。

如果我们使用原生的uprobe,执行过程较为复杂,需要多次与文件系统交互。brendangregg大神写了一个工具集:perf-tools,可以帮我们避免这个繁琐的过程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 假定已经安装了nginx,并监听443端口。此处较为简单,不赘述。
// 查看nginx依赖的libssl.so 路径
# lsof -p 1722 |grep libssl
nginx   1722 root  mem    REG              253,0  1684112  35442440 /usr/lib64/libssl.so.1.1
//所以uprobe需要探测:/usr/lib64/libssl.so.1.1
//下载工具
git clone https://github.com/brendangregg/perf-tools.git
//因为是bash shell,所以可以直接执行
perf-tools/bin/uprobe -F 'p:/usr/lib64/libssl.so.1.1:ssl_log_secret lable=+0(%si):string client_random=+184(+168(%di)):u64   secret=+0(%dx):u64 secret_len=%cx'


//新开一个终端,触发一个https请求
curl --tlsv1.2 https://127.0.0.1 -k
// 执行后,uprobe输出内容如下:
           nginx-1723  [002] d...  3268.340921: ssl_log_secret: (0x7fd981420b1e) lable="CLIENT_RANDOM" client_random=0x87fccb2cdefa29cc secret=0xc0306bfaf3d08ed2 secret_len=0x30
// 如果是tls1.3 ,则结果如下:
[root@localhost ~]# perf-tools/bin/uprobe -F 'p:/usr/lib64/libssl.so.1.1:ssl_log_secret lable=+0(%si):string client_random=+184(+168(%di)):u64   secret=+0(%dx):u64 secret_len=%cx'
Tracing uprobe ssl_log_secret (p:ssl_log_secret /usr/lib64/libssl.so.1.1:0x49b1e lable=+0(%si):string client_random=+184(+168(%di)):u64 secret=+0(%dx):u64 secret_len=%cx). Ctrl-C to end.
           nginx-1723  [003] d...  3326.488290: ssl_log_secret: (0x7fd981420b1e) lable="SERVER_HANDSHAKE_TRAFFIC_SECRET" client_random=0x63d2968d89ea94b0 secret=0x49883dda8f0b7c86 secret_len=0x30
           nginx-1723  [003] d...  3326.488410: ssl_log_secret: (0x7fd981420b1e) lable="CLIENT_HANDSHAKE_TRAFFIC_SECRET" client_random=0x63d2968d89ea94b0 secret=0x31eb42e8febba398 secret_len=0x30
           nginx-1723  [003] d...  3326.489574: ssl_log_secret: (0x7fd981420b1e) lable="EXPORTER_SECRET" client_random=0x63d2968d89ea94b0 secret=0xd97044ace73cc7cd secret_len=0x30
           nginx-1723  [003] d...  3326.489671: ssl_log_secret: (0x7fd981420b1e) lable="SERVER_TRAFFIC_SECRET_0" client_random=0x63d2968d89ea94b0 secret=0x2084502737799d92 secret_len=0x30
           nginx-1723  [003] d...  3326.490778: ssl_log_secret: (0x7fd981420b1e) lable="CLIENT_TRAFFIC_SECRET_0" client_random=0x63d2968d89ea94b0 secret=0x3205df8d939b7734 secret_len=0x30

到此,我们就通过uprobe抓取了https的密钥信息。

当然,以上的密钥信息并不全,因为密钥一般为32位或者48位,一个u64只有8位。暂时没找到太好的方式,我就先用多个表达式,保存密钥信息。 client_random 一般为32位,需要4个表达式。 secret 一般为48位需要56个表达式。 所以最终uprobe的命令如下所示:

1
./uprobe -F 'p:/usr/lib64/libssl.so.1.1:ssl_log_secret lable=+0(%si):string client_random1=+184(+168(%di)):u64   client_random2=+192(+168(%di)):u64 client_random3=+200(+168(%di)):u64 client_random4=+208(+168(%di)):u64  secret1=+0(%dx):u64 secret2=+8(%dx):u64 secret3=+16(%dx):u64 secret4=+24(%dx):u64 secret5=+32(%dx):u64 secret6=+40(%dx):u64  secret_len=%cx'

后续我们需要再写一个程序,把uprobe的输出组织成ssl 密钥日志文件的格式。其中有一个注意点,uprobe的字节序是反的。 举个例子:

1
nginx-1727  [003] d...  3943.752729: ssl_log_secret: (0x7fd981420b1e) lable="CLIENT_RANDOM" client_random1=0x5a742665803ba288 client_random2=0xae1b995ef4fada50 client_random3=0xb45ca5b3b6b85d56 client_random4=0x41f04b1e53a88afc secret1=0x8ab89fc0f29edce2 secret2=0x6de1ba3d0393bb91 secret3=0x6fdef1660f81eaaa secret4=0xffa172d15b0bfcb6 secret5=0x8179eaca1a43e0ca secret6=0xa94e8f17b2ed6be0 secret_len=0x30

其中client_random前8个子节为:client_random1=0x5a742665803ba288。但其实client_random前8个子节真正的内容为:88a23b806526745a。完整的client_random为:88a23b806526745a50dafaf45e991bae565db8b6b3a55cb4fc8aa8531e4bf041

注: 所有uprobe表达式仅仅适用于openssl-1.1.1k版本。其实版本可能需要重新编译,通过gdb查询