分析SSL握手失败问题
最近业务反馈在我负责的Serverless平台上执行的python脚本,访问azure的speechsdk失败,报错日志如下
1 | 2024-09-02 18:47:16.455 Azure OpenAI is listening. Say 'Stop' or press Ctrl-Z to end the conversation. |
业务把wss协议改成ws协议以后,就可以正常访问服务了
同时在Serverless平台以外,直接在该镜像下也是可以正常访问服务的
因此合理怀疑是Serverless平台的python脚本,使用SSL会存在问题
根据SSL协议大致定位问题
从speechsdk上看不太出来SSL的报错,因此写了一个简单的脚本复现问题
1 | import ssl |
经过平台的sdk适配后,在平台上运行报错
1 | py throw ex:Traceback (most recent call last): |
这下可以确定是SSL握手错误了,抓包看下具体错误情况,在两个Hello结束后,服务端发送了证书,客户端返回了Fatal,内容为Description: Unknown CA (48)
查看了一下服务端的证书机构,红框内可以看到根证书是BE颁发的GlobalSign Root CA:
随后查看机器上有没有这个来自于BE的GlobalSign Root CA证书
1 | ~ awk -v cmd='openssl x509 -noout -subject' ' /BEGIN/{close(cmd)};{print | cmd}' < /etc/ssl/certs/ca-certificates.crt|grep GlobalSign|grep BE |
证书的4个item都对的上,确实是存在的,那就可以猜测问题原因:握手失败是因为客户端没有正确读取到证书
指定python使用的证书验证猜测
查阅了python的文档:https://docs.python.org/zh-cn/3/library/ssl.html#ssl-contexts
其中SSLContext.load_verify_locations是用来加载证书的,那么使用certifi包额外下载python所需证书再加载应该就可以了
修改代码如下
1 | import ssl |
经过平台的sdk适配后,成功执行,并输出
1 | ~ python3 test.py |
深入定位根本原因
既然明确握手失败是因为客户端没有正确读取到证书,那么就要查一下python不依赖额外的certifi包的时候,是如何定位证书的了
在python的ssl库,有get_default_verify_paths
这么一个函数,用来获取证书
1 | import ssl |
经过平台的sdk适配后,在握手失败的Serverless平台上调用输出:
1 | DefaultVerifyPaths(cafile=None, capath=None, openssl_cafile_env='SSL_CERT_FILE', openssl_cafile='/usr/local/ssl/cert.pem', openssl_capath_env='SSL_CERT_DIR', openssl_capath='/usr/local/ssl/certs') |
而在握手成功的直接linux运行上则输出:
1 | DefaultVerifyPaths(cafile=None, capath='/usr/lib/ssl/certs', openssl_cafile_env='SSL_CERT_FILE', openssl_cafile='/usr/lib/ssl/cert.pem', openssl_capath_env='SSL_CERT_DIR', openssl_capath='/usr/lib/ssl/certs') |
可以发现capath,openssl_cafile,openssl_capath的值不同,其中capath最可疑,直接为None了,查看ssl库的实现,发现他会调用python的c接口来获取值
1 | def get_default_verify_paths(): |
编写脚本看下这个_ssl的返回值
1 | import _ssl |
经过平台的sdk适配后,在握手失败的Serverless平台上调用输出,得到的结果/usr/local/ssl/certs
是不存在的路径:
1 | ('SSL_CERT_FILE', '/usr/local/ssl/cert.pem', 'SSL_CERT_DIR', '/usr/local/ssl/certs') |
因此ssl.get_default_verify_paths()
会返回cpath为None
而在握手成功的直接linux运行上则输出,得到的结果/usr/lib/ssl/certs
是存在的路径:
1 | ('SSL_CERT_FILE', '/usr/lib/ssl/cert.pem', 'SSL_CERT_DIR', '/usr/lib/ssl/certs') |
那么根据ssl.get_default_verify_paths()
的实现,问题十有八九出在_ssl.get_default_verify_paths()
上
环境变量覆盖_ssl.get_default_verify_paths()返回值,验证猜测
1 | #新增3行代码如下 |
经过平台的sdk适配后,成功执行!看来问题就出在_ssl.get_default_verify_paths()
上了!
定位_ssl.get_default_verify_paths()
异常原因
它的实现在https://github.com/python/cpython/blob/3.10/Modules/_ssl.c#L5290
1 | static PyObject * |
cpython的四个返回值,看起来是对openssl库的X509_前缀函数的简单转发,其中ssl.get_default_verify_paths()
的返回值capath对应X509_get_default_cert_dir()
的返回值
查看openssl库的源码,crypto/x509/x509_def.c:
1 |
|
X509_get_default_cert_dir
指向了一个宏X509_CERT_DIR
,定义在crypto/cryptlib.h
1 | ifndef OPENSSL_SYS_VMS |
X509_CERT_DIR的值基于OPENSSLDIR和OPENSSL_SYS_VMS,而两者都是openssl库的编译参数
那么可以基本确定,Serverless平台和直接linux上运行的差异应该是openssl库的差别导致的
Serverless平台依赖/data/app/taf/tafnode/data/LEAF.ServerLessNode/data/ScriptEngineServerBin/817186/libcrypto.so.1.1
:
1 | ~ lsof -p 645125|grep crypto |
它的编译参数如下,是不符合证书路径的
1 | ~ strings /data/app/taf/tafnode/data/LEAF.ServerLessNode/data/ScriptEngineServerBin/817186/libcrypto.so.1.1|grep OPENSSLDIR |
直接linux上运行则依赖/lib/x86_64-linux-gnu/libcrypto.so.1.1
:
1 | ~ strace python3 test.py 2>&1|grep crypto |
它的编译参数如下,是符合证书路径的
1 | strings /lib/x86_64-linux-gnu/libcrypto.so.1.1|grep OPENSSLDIR |
替换python依赖的ssl动态库来验证猜想
1 | import ctypes |
经过平台的sdk适配后,成功执行!
解决问题
刚才替换的ssl动态库,不能直接用来解决问题
因为20.04上自带的ssl动态库,依赖了高版本libc的符号,如果Serverless平台直接使用这个动态库,会导致在16.04上无法运行
而16.04自带的ssl动态库,则版本号太低了
1 | ~ openssl version -v |
这个版本不支持TLS 1.3
所以需要在16.04上自己编译一个动态库版本
查看20.04上的安装版本,编译出完全一致的即可
1 | ~ openssl version -a |
那么我也需要用1.1.1f来编译
1 | wget https://www.openssl.org/source/openssl-1.1.1f.tar.gz --no-check-certificate |
先别急着make,直接make出来的ENGINESDIR会是/usr/lib/x86_64-linux-gnu/lib/engines-1.1
,简单修改下Makefile
1 | INSTALLTOP=/usr/lib/x86_64-linux-gnu |
保存然后make,这里有个重点
不能make -j
,因为早期版本的openssl还没有多核电脑,因此不支持多线程编译
1 | ~ make |
由于我只需要动态库,不打算make install
,因此编译出的openssl的二进制需要patchelf
一下
1 | ~ patchelf --set-rpath . apps/openssl |
然后看下编译结果的版本,其中的compiler调换顺序,就和20.04自带的openssl的编译选项一毛一样辣~
1 | ~ ./apps/openssl version -a |
将ssl.so和crypto.so打入Serverless平台的引擎中,azure的speechsdk成功执行!
完结撒花!
总结
python依赖的openssl动态库,它的编译参数OPENSSLDIR一定要和机器上的证书实际目录一致
如果不一致,可以通过以下方法解决:
load_verify_locations手动指定证书
对于已经封装好的sdk,例如azure的speechsdk,由于没有暴露ssl.SSLContext,无法使用这种办法
此时可以通过覆盖SSL_CERT_FILE和SSL_CERT_DIR的环境变量来解决
也可以通过ctypes包,强制修改依赖的ssl库来解决