记录如何在客户端,服务端解密https抓包数据。
在我们日常工作中,经常需要抓包分析各种异常的网络常见。http是网络常见中最常见的一种,随着https的普及,我们生产环境中,大部分都为https的网站。而https所有的交互数据都是经过加密的。
- https的核心原理并不复杂,通过非对称加密传递密钥,然后拿这个密钥通过对称加密传递密文数据。
- tls1.2原理简单点,通过客户端随机数,服务端随机数,以及预主密钥生成主密钥,然后通过主密钥加密数据。所以tls1.2版本中,我们只要知道了主密钥,就能解密数据。
- tls1.3更复杂点,需要的密钥更多。具体原理可以查看这篇文章了解:https://halfrost.com/https-key-cipher/。
- 其实,无论原理如何,我们作为使用方,解密明文数据其实很简单,主要分为两个过程:
- 记录密钥日志,抓取数据包
- 在wireshark中导入密钥日志,打开抓取的数据包即可。
- 记录密钥这个功能,想起来可以比较复杂,其实大部分使用tls的软件,库都是有记录密钥功能的。是一个通用的功能,一般通过
SSLKEYLOGFILE
这个环境变量,设置记录密钥的日志文件。 详细内容可参考:https://firefox-source-docs.mozilla.org/security/nss/legacy/key_log_format/index.html 。下面我们通过多个方面说明,如何记录密钥,使用密钥。
添加用户环境变量
添加完之后,我们的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请求,访问的是根路径。说明我们解密数据包已完成。
- curl 也是通过设置SSLKEYLOGFILE环境变量记录密钥日志的。比如:
添加环境变量:$ export SSLKEYLOGFILE=/tmp/ssl.log
访问:$ curl https://www.baidu.com
$ 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
|
$ 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
4. 从ssl对象中获取client_random
对象其实使用多个成员变量组成,由于子节对齐的原因,我们很难通过计算得出对象的某个成员变量相对这个对象的偏移量。其实一个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查询