3.10以降のPythonで特定のサイトにTLS接続できなかったので利用する暗号化スイートを明示的に指定してみた

2024.06.10

CX事業本部@大阪の岩田です。

Python3.12からrequestsを使って特定のサイトにアクセスした際、ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] ssl/tls alert handshake failure (_ssl.c:1000)というエラーが発生してアクセスに失敗しました。最初はエラーメッセージから相手先のサーバーがSSL3しかサポートしていないのかと勘違いしたのですが、Python3.10以後からデフォルトでサポートする暗号化スイートが変更されたことが原因のようです。明示的に暗号化スイートを指定して対策してみたので手順をご紹介します。

暗号化スイートを指定する際はセキュリティ的に問題がないかユースケースやリスクを検討した上で判断して頂くようお願いします

環境

今回利用した環境です

  • Python: 3.12.3
  • urllib3: 2.2.1
  • requests: 2.31.0
  • OpenSSL: 3.3.0 9 Apr 2024

urllib3のバージョン1系と2系で挙動が異なるのでご注意ください。

やってみる

普通にrequests.getしてみる

まずは普通にrequests.get('https://対象サイト')を試みます。すると以下のようなエラーが発生しました。

>>> requests.get('https://')
Traceback (most recent call last):
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/site-packages/urllib3/connectionpool.py", line 467, in _make_request
    self._validate_conn(conn)
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/site-packages/urllib3/connectionpool.py", line 1099, in _validate_conn
    conn.connect()
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/site-packages/urllib3/connection.py", line 653, in connect
    sock_and_verified = _ssl_wrap_socket_and_match_hostname(
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/site-packages/urllib3/connection.py", line 806, in _ssl_wrap_socket_and_match_hostname
    ssl_sock = ssl_wrap_socket(
               ^^^^^^^^^^^^^^^^
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/site-packages/urllib3/util/ssl_.py", line 465, in ssl_wrap_socket
    ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls, server_hostname)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/site-packages/urllib3/util/ssl_.py", line 509, in _ssl_wrap_socket_impl
    return ssl_context.wrap_socket(sock, server_hostname=server_hostname)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/ssl.py", line 455, in wrap_socket
    return self.sslsocket_class._create(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/ssl.py", line 1042, in _create
    self.do_handshake()
  File "/Users/iwata.tomoya/.anyenv/envs/pyenv/versions/3.12.3/lib/python3.12/ssl.py", line 1320, in do_handshake
    self._sslobj.do_handshake()
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] ssl/tls alert handshake failure (_ssl.c:1000)

ネゴシエーション処理の詳細を確認

SSLV3_ALERT_HANDSHAKE_FAILUREというエラーから「もしやサーバー側がSSL3しかサポートしていない?」と疑ってopensslのs_clientコマンドでハンドシェイクの詳細を確認してみました。

 openssl s_client -connect 対象のサーバー:443 -trace

出力は以下のようになりました。

Received TLS Record
Header:
  Version = TLS 1.2 (0x303)
  Content Type = Handshake (22)
  Length = 57
    ServerHello, Length=53
      server_version=0x303 (TLS 1.2)
      Random:
...略

---
New, TLSv1.2, Cipher is AES128-GCM-SHA256
Server public key is 2048 bit
Secure Renegotiation IS supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
SSL-Session:
    Protocol  : TLSv1.2
    Cipher    : AES128-GCM-SHA256

opensslのs_clientコマンドだと接続に成功し、かつTLS1.2で接続していることが分かります。ということはサーバー側がサポートしているTLSバージョンの問題ではなさそうです。さらに原因を切り分けるためWiresharkでパケットをキャプチャしながら再度requests.getを実行してみたところ、Client Helloの後のサーバーからのレスポンスでAlert (Level: Fatal, Description: Handshake Failure)のエラーが発生していることが分かりました。

Client Helloの際に提示している暗号化スイートは以下の通りでした。

Cipher Suites (18 suites)
    Cipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)
    Cipher Suite: TLS_CHACHA20_POLY1305_SHA256 (0x1303)
    Cipher Suite: TLS_AES_128_GCM_SHA256 (0x1301)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 (0xc02c)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xc030)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xc02b)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca9)
    Cipher Suite: TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca8)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 (0xc024)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 (0xc028)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 (0xc023)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 (0xc027)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 (0x009f)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 (0x009e)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 (0x006b)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 (0x0067)
    Cipher Suite: TLS_EMPTY_RENEGOTIATION_INFO_SCSV (0x00ff)

先ほどopensslコマンドで接続する際に利用していたAES128-GCM-SHA256は含まれていないことが分かります。その後サーバー側が対応している暗号化スイートを詳細に確認していくと対応しているのは以下の6種ということが分かりました。

  • AES128-GCM-SHA256
  • AES128-SHA
  • AES128-SHA256
  • AES256-GCM-SHA384
  • AES256-SHA
  • AES256-SHA256

非デフォルトの暗号化スイートを指定してrequests.getを実行してみる

ここまででエラーの原因が切り分けられました。requestsから対象サーバーにアクセスする際の暗号化スイートとして対象サーバーがサポートしている暗号化スイートを指定できれば良さそうです。調べたところ以下のissueで暗号化スイートの指定方法が紹介されていたので、このissueを参考にしつつ実装してみました。

How can I change the allowed SSL ciphers for a requests session?

import requests
from urllib3 import ssl

ssl_context = ssl.create_default_context()
ssl_context.set_ciphers("DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK")
session = requests.session()
adapter = requests.adapters.HTTPAdapter()
adapter.init_poolmanager(1, 1,  ssl_context=ssl_context)
session.adapters.pop("https://", None)
session.mount("https://", adapter)

res = session.get('https://対象サイトのURL')
print(res)

指定している暗号化スイートですが、今回はPython3.9以前の挙動と合わせるためにDEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSKを指定しています。

CPythonのコミット履歴を確認したところPython3.9からPython3.10のバージョンアップ時に暗号化スイートがDEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSKから@SECLEVEL=2:ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES:DHE+AES:!aNULL:!eNULL:!aDSS:!SHA1:!AESCCMに更新されているようです。

https://github.com/python/cpython/pull/25778/files#diff-89879be484d86da4e77c90d5408b2e10190ee27cc86337cd0f8efc3520d60621L163

このスクリプトを実行すると無事に200OKで対象サイトにアクセスできました

python test.py
<Response [200]>

パケットキャプチャの結果を確認するとClient Helloで提示している暗号化スイートは以下のようになっていました

Cipher Suites (31 suites)
    Cipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)
    Cipher Suite: TLS_CHACHA20_POLY1305_SHA256 (0x1303)
    Cipher Suite: TLS_AES_128_GCM_SHA256 (0x1301)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 (0xc02c)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xc030)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 (0x009f)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca9)
    Cipher Suite: TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca8)
    Cipher Suite: TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xccaa)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xc02b)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 (0x009e)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 (0xc024)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 (0xc028)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 (0x006b)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 (0xc023)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 (0xc027)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 (0x0067)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA (0xc00a)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA (0xc014)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_256_CBC_SHA (0x0039)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA (0xc009)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA (0xc013)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA (0x0033)
    Cipher Suite: TLS_RSA_WITH_AES_256_GCM_SHA384 (0x009d)
    Cipher Suite: TLS_RSA_WITH_AES_128_GCM_SHA256 (0x009c)
    Cipher Suite: TLS_RSA_WITH_AES_256_CBC_SHA256 (0x003d)
    Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA256 (0x003c)
    Cipher Suite: TLS_RSA_WITH_AES_256_CBC_SHA (0x0035)
    Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA (0x002f)
    Cipher Suite: TLS_EMPTY_RENEGOTIATION_INFO_SCSV (0x00ff)

Server Helloのレスポンスは以下の通りでした。ネゴシエーションの結果、暗号化スイートはTLS_RSA_WITH_AES_128_GCM_SHA256が採用されたようです。opnesslのs_clientコマンドで接続確認した際と同じですね

Handshake Protocol: Server Hello
    Handshake Type: Server Hello (2)
    Length: 53
    Version: TLS 1.2 (0x0303)
    Random: b928b590b5dd0acc8781d1be156fac4d8188f407ce64ea423b2ecb5230dfb6ea
    Session ID Length: 0
    Cipher Suite: TLS_RSA_WITH_AES_128_GCM_SHA256 (0x009c)
...略

まとめ

requestsを利用する際に暗号化スイートを指定する方法をご紹介しました。今回はrequestsを利用する場合のコードを紹介していますが、urllib3を直接利用する場合やurllibを利用する場合もssl.SSLContextの設定方法などは流用可能です。類似の事象でお悩みの方がいれば参考にしてみてください。

参考