月度归档:2023年12月

由 Python 偶现 SSL 错误引发的 TLS cipher 问题探究

近期在用 Python (v3.12.0) 对某 API 接口持续性地调用测试过程中,发现会偶现 SSL 错误:

raise SslHandShakeException("ssl error", reason=e)
somesdk.exception.exceptions.SslHandShakeException: SslHandShakeException - ssl error
reason: SSLError(SSLError(1, '[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:1002)'))

由于拨测是在 Windows 云主机上进行,把脚本拉到本地后在本地重新安装 SDK 等相关 py 环境,发现问题依旧。

此前从同事那里得知 ipv6 网络环境曾出现过不稳定的情况,初步怀疑是是 ipv6 引起此问题,是否在走与不走 ipv6 链路的区别中产生了 “不稳定” 现象。把 Windows 系统的 ipv4/ipv6 分别禁用,排除了这种可能性。祭出 Wireshark 在本地抓包,同时不停地运行 Python 调用 API 脚本发送请求,终于在一段时间测试后发现了问题:API 所在主机有 A 、 B 两个 ipv4 解析结果,在解析结果为 B 时稳定复现此问题,解析结果为 A 时则稳定不出现问题。

由于 Python 的底层运行库提示为 SSL 错误,因此这两个 server 的 TLS 支持情况成为关键。此前从一些网络技术文章可知,对于客户端 TLS 指纹(包括但不限于 client cipher suite 的组合特征)、甚至 HTTP2 指纹的识别早已经应用到脚本、爬虫等 “非人” 客户端的识别封禁等。

调查 Python 客户端与 A 主机 TLS 握手失败的包,发现以下现象:在服务端返回握手失败包之前客户端所发送的 Client Hello 包中,客户端(Python3.12.0 及相关网络库)向服务端提供了以下 18 个加密套件:

Pyhton 3.12.0 提供的 18 个 cipher suite(点击展开)
1TLS_AES_256_GCM_SHA384(0x1302)
2TLS_CHACHA20_POLY1305_SHA256(0x1303)
3TLS_AES_128_GCM_SHA256(0x1301)
4TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384(0xc02c)
5TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384(0xc030)
6TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256(0xc02b)
7TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256(0xc02f)
8TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256(0xcca9)
9TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256(0xcca8)
10TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384(0xc024)
11TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384(0xc028)
12TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256(0xc023)
13TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256(0xc027)
14TLS_DHE_RSA_WITH_AES_256_GCM_SHA384(0x009f)
15TLS_DHE_RSA_WITH_AES_128_GCM_SHA256(0x009e)
16TLS_DHE_RSA_WITH_AES_256_CBC_SHA256(0x006b)
17TLS_DHE_RSA_WITH_AES_128_CBC_SHA256(0x0067)
18TLS_EMPTY_RENEGOTIATION_INFO_SCSV(0x00ff)
前三个属于 TLS1.3 的加密套件在本次实际交互中不被支持,最后一个 SCSV 不是真正的加密套件,只有剩下的 14 个可用,而这 14 个套件皆为 DHE 或 ECDHE 。
使用 sslscan 工具分别扫描 A 、 B 两台服务端所支持的 cipher 情况(点击展开)
A 支持的 cipher
TLSv1.2128 bits0xC013ECDHE-RSA-AES128-SHA
TLSv1.2256 bits0xC028ECDHE-RSA-AES256-SHA384
TLSv1.2256 bits0x0035AES256-SHA
TLSv1.2128 bits0x002FAES128-SHA
B 支持的 cipher
TLSv1.2256 bits0x0035AES256-SHA
TLSv1.2128 bits0x002FAES128-SHA

可见 Python 3.12.0 这样的 Client 在 TLS 握手时向服务端提供的加密套件中,和 B 没有交集,和 A 有交集 TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384,HEX 代码为 (0xc028),因此只能和 A TLS 握手成功。

在和其他部门同事复现、调试的过程中,据称同事的 Python3.6 没有这个问题,因此使用了自己另一台电脑的 Python3.9.2 环境进行复测,Python3.9.2 的相关 SSL 库在 TLS 握手 Client Hello 时提供了 43 个 cipher,最终服务端 B 在 Server Hello 中回复商定了 TLS_RSA_WITH_AES_256_CBC_SHA (0x0035) 作为加密套件。

Pyhton 3.9.2 提供的 43 个 cipher suite(点击展开)
1TLS_AES_256_GCM_SHA384(0x1302)
2TLS_CHACHA20_POLY1305_SHA256(0x1303)
3TLS_AES_128_GCM_SHA256(0x1301)
4TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384(0xc02c)
5TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384(0xc030)
6TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256(0xc02b)
7TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256(0xc02f)
8TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256(0xcca9)
9TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256(0xcca8)
10TLS_DHE_RSA_WITH_AES_256_GCM_SHA384(0x009f)
11TLS_DHE_RSA_WITH_AES_128_GCM_SHA256(0x009e)
12TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256(0xccaa)
13TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8(0xc0af)
14TLS_ECDHE_ECDSA_WITH_AES_256_CCM(0xc0ad)
15TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8(0xc0ae)
16TLS_ECDHE_ECDSA_WITH_AES_128_CCM(0xc0ac)
17TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384(0xc024)
18TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384(0xc028)
19TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256(0xc023)
20TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256(0xc027)
21TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA(0xc00a)
22TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA(0xc014)
23TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA(0xc009)
24TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA(0xc013)
25TLS_DHE_RSA_WITH_AES_256_CCM_8(0xc0a3)
26TLS_DHE_RSA_WITH_AES_256_CCM(0xc09f)
27TLS_DHE_RSA_WITH_AES_128_CCM_8(0xc0a2)
28TLS_DHE_RSA_WITH_AES_128_CCM(0xc09e)
29TLS_DHE_RSA_WITH_AES_256_CBC_SHA256(0x006b)
30TLS_DHE_RSA_WITH_AES_128_CBC_SHA256(0x0067)
31TLS_DHE_RSA_WITH_AES_256_CBC_SHA(0x0039)
32TLS_DHE_RSA_WITH_AES_128_CBC_SHA(0x0033)
33TLS_RSA_WITH_AES_256_GCM_SHA384(0x009d)
34TLS_RSA_WITH_AES_128_GCM_SHA256(0x009c)
35TLS_RSA_WITH_AES_256_CCM_8(0xc0a1)
36TLS_RSA_WITH_AES_256_CCM(0xc09d)
37TLS_RSA_WITH_AES_128_CCM_8(0xc0a0)
38TLS_RSA_WITH_AES_128_CCM(0xc09c)
39TLS_RSA_WITH_AES_256_CBC_SHA256(0x003d)
40TLS_RSA_WITH_AES_128_CBC_SHA256(0x003c)
41TLS_RSA_WITH_AES_256_CBC_SHA(0x0035)
42TLS_RSA_WITH_AES_128_CBC_SHA(0x002f)
43TLS_EMPTY_RENEGOTIATION_INFO_SCSV(0x00ff)

那么 Python3.12.0 和 Python3.9.2 的 Clinet cipher 到底有什么区别?略一对比可以看出 Python3.12.0 抛弃了相当多的 RSA 相关加密套件,而保留了和 DHE 、 ECDHE 相关的。

TLS1.3 一项重要的改进就是抛弃了 RSA 相关算法而保留了 Diffie–Hellman(DH)作为唯一的密钥交换算法。因为前者不具有前向安全性(Forward Secrecy,简称 FS),简单说,把 https 密文保存下来后,即使在未来私钥泄露的情况下,也无法解开所保存的密文。

Python3.10 的更新日志 中有一条:
The ssl module now has more secure default settings. Ciphers without forward secrecy or SHA-1 MAC are disabled by default. Security level 2 prohibits weak RSA, DH, and ECC keys with less than 112 bits of security. SSLContext defaults to minimum protocol version TLS 1.2. Settings are based on Hynek Schlawack’s research. (Contributed by Christian Heimes in bpo-43998.)
其思路和 TLS1.3 抛弃 RSA 比较一致,把不支持前向安全的加密套件废除以提高安全性。

最终服务端 B 包括域名的 v6 地址所接受的 cipher 与 A 拉齐,问题得到解决。一个看似简单又有点奇怪的 “SSL 偶现报错” 问题原来与 DNS 解析负载均衡、新版 Python 严格的 TLS 加密套件策略等一系列原因有关。