1. libtaxiiのdiscovery_clientがSSLサーバ証明書を検証しない件

1.1. 現象

libtaxii のスクリプトで某所のデータを引っ張っているわけだが、 普通に使うとTAXIIサーバ側のSSLサーバ証明書を検証していないことがわかっ た。 ものがモノだけに一応検証するべきかと思ったら沼だった。

僕が使っているのはごく一部で discovery_client と poll_client くらいで あるから、必ずしも libtaxii 全体を見て言っているわけではないことに留 意されたい。(間違ってたらごめんね許してねこっそり教えてねってことです。

以下では /usr/local/bin/discovery_client を使う場合を考える。

1.2. 環境

例によって FreeBSD 12.1-RELEASE-p3 amd64 上の Python 3.7.6 で githubから libtaxii の 1.1.116 を持ってきてインストールしている。 mylibtaxii に本家をforkして、ssl_verify_serverブランチで実験している。

1.3. big picture

コマンド discovery_client を起動してから TAXII サーバへのコネクション が張られるまでの呼び出し関係を下図に示す。 ただし、見易さを優先しているので必ずしも正確でない場合がある。

digraph big_picture {
// rankdir = LR
subgraph cluster_cm {
  label = "command"
  cm_comm [label="/usr/local/bin/discovery_client"]
  cm_main    [label="libtaxii/scripts/discovery_client.py:main()"]
}
subgraph cluster_dc {
  label = "DiscoveryClient11Script"
  subgraph cluster_TaxiiScript {
    label = "TaxiiScript"
    dc_call [label="__call__()"]
    dc_arg [label="get_arg_parser()"]
    dc_ccli [label="create_client()"]
  }
}
subgraph cluster_HttpClient {
  label = "HttpClient"
  init [label="__init__()"]
  https [label="set_use_https()"]
  auth_type [label="set_auth_type()"]
  dc_cts2 [label="call_taxii_service2()"]
}
subgraph cluster_hh {
  label = "LibtaxiiHTTPSHandler"
  hh_init [label="__init__()"]
  hh_conn [label="get_connection()"]
}
subgraph cluster_vc {
  label = "verifiableHTTPSConnection"
  vc_init [label="__init__()"]
}
subgraph cluster_ul {
  label = "urllib.request"
  ul_bo [label="build_opener"]
  ul_io [label="install_opener"]
  ul_uo [label="url_open"]
}
cm_comm -> cm_main   [label="1"]
cm_main -> dc_call   [label="2"]
dc_call -> dc_arg    [label="3"]
dc_call -> dc_ccli   [label="4"]
dc_ccli -> init      [label="5"]
dc_ccli -> https     [label="6"]
dc_ccli -> auth_type [label="7"]
dc_call -> dc_cts2   [label="8"]
dc_cts2 -> hh_init   [label="9"]
dc_cts2 -> ul_bo     [label="10"]
dc_cts2 -> ul_io     [label="11"]
dc_cts2 -> ul_uo     [label="12"]
ul_uo   -> hh_conn   [label="13"]
hh_conn -> vc_init   [label="14"]
}

1.4. コマンドラインから呼び出す

discovery_client コマンドを呼び出す時は、接続先となる TAXII サーバの URLやクライアント側(コマンド自身)を認証させるためのSSLクライアント証 明書とその鍵(TAXIIサーバが動くサーバ側の証明書の検証とは別の話)を指 定する。 ヘルプを見てもサーバ側証明書を検証するとかしないとかに関わるものはない ようだ。

$ discovery_client --url ${URL} --cert ${CRTFILE} --key  ${KEYFILE}

$ discovery_client --help
usage: discovery_client [-h] [-u URL] [--cert CERT] [--key KEY]
                  [--username USERNAME] [--pass PASSWORD]
                  [--proxy PROXY] [--xml-output] [--from-file FROM_FILE]

The TAXII 1.1 Discovery Client sends a Discovery Request message to a TAXII
Server and prints out the Discovery Response message to standard out.

optional arguments:
  -h, --help            show this help message and exit
  -u URL, --url URL     The URL to connect to. Defaults to
                        http://hailataxii.com:80/taxii-discovery-service.
  --cert CERT           The file location of the certificate to use. Defaults
                        to None.
  --key KEY             The file location of the private key to use. Defaults
                        to None.
  --username USERNAME   The username to authenticate with. Defaults to None.
  --pass PASSWORD       The password to authenticate with. Defaults to None.
  --proxy PROXY         The proxy to use (e.g.,
                        http://myproxy.example.com:80/), or 'noproxy' to not
                        use any proxy. If omitted, 'noproxy' will be used.
  --xml-output          If present, the raw XML of the response will be
                        printed to standard out. Otherwise, a "Rich" output
                        will be presented.
  --from-file FROM_FILE
                        Use a configuration file to load arguments into the
                        script. The contents of the configuration file will
                        take precedence over passed flags.

1.5. libtaxii/scripts/discovery_client.py:main

この discovery_client はpkg_resource.load_entry_pointを使っていて 結局は libtaxii/scripts/discovery_client の main() を呼んでいる。

/usr/local/lib/python3.7/site-packages/libtaxii-1.1.116-py3.7.egg-info/entry_points.txt
discovery_client = libtaxii.scripts.discovery_client:main

呼ばれたmain()では、DiscoveryClientScript クラスのインスタンスを作って 走らせる。

libtaxii/scripts/discovery_client.py
20
21
22
def main():
    script = DiscoveryClient11Script()
    script()

1.6. DiscoveryClient11Scriptクラス

ではその DiscoveryClient11Scriptクラスとはなんだ、というと、 TaxiiScript クラスを継承したクラスである。 子クラスに特有の設定などがあればここに書くのだろうが、Discovery だと ほとんど無い。 実際、PollClient11Scriptクラスでは特有のコマンドラインオプションを追加 していたりする。

共通のコマンドラインオプションは親クラスの TaxiiScript クラス側に書い ており、今回のSSLサーバ証明書検証なんかだとdiscoveryでもpollでも使うの で、親クラスに書くほうが良いと思う。

libtaxii/scripts/discovery_client.py
11
class DiscoveryClient11Script(TaxiiScript):

1.7. TaxiiScript.__call__() / get_arg_parser()とcreate_client()

親クラスのTaxiiScriptはlibtaxii/scripts/__init__.py にある。

上で見たようにインスタンスを関数呼び出ししているので、__call__() が最 初に実行されるってことでいいのかな。 (TaxiiScriptには__init__()がないので、呼び出し順序などで悩むことはな いが)

__call__() では、387行目で自クラスのget_arg_parser呼び出しから引き数を 受け取ってそれぞれ前処理した後、392行目で自クラスのcreate_cleint()に 渡す。

libtaxii/scripts/__init__.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
def __call__(self):
    """
    Invoke a TAXII Service based on the arguments
    """
    try:
        parser = self.get_arg_parser(parser_description=self.parser_description, path=self.path)
        args = parser.parse_args()
        TaxiiScript._parse_url_info(args, parser)
        request_message = self.create_request_message(args)
        url = urlparse(args.url)
        client = self.create_client(url.scheme == 'https',
                                    args.proxy,
                                    args.cert,
                                    args.key,
                                    args.username,
                                    args.password)

         print("Request:\n")

まずget_arg_parser()では、各スクリプトに共通のコマンドラインオプション を定義している。 ただし、先に見たようにサーバ側のSSLサーバ証明書を検証するかどうかに関 わるものは存在しない。

create_client()では、tc.HttpClient() でHHttpClientオブジェクト (create_client側での変数名はclient)を作った後、 set_use_https(), set_proxy(), set_auth_type()等を呼んで必要な初期化を 行う。

libtaxii/scripts/__init__.py
217
218
219
220
221
222
def create_client(self, use_https, proxy, cert=None, key=None, username=None, password=None):
        client = tc.HttpClient()
        client.set_use_https(use_https)
        client.set_proxy(proxy)
        tls = (cert is not None and key is not None)
        basic = (username is not None and password is not None)

1.8. TaxiiScript.create_client() / libtaxii.clients.HttpClient()

client オブジェクト作成に使った tc は libtaxii.clients のことで、 libtaxii/Clients.py で定義されている。 上で用いたset_系のmethodもこちらで定義されていて、基本的には対応する パラメータをインスタンス変数(self.hoge)に記録・初期化する役割である。 (init()も同様で引き数で渡されたパラメータを格納する。でも create_clientでは何も渡していない。)

上で初期化されたパラメータは http/https の別・proxy設定・クライアント 証明書とその鍵・BASIC認証のid/passwordなので、SSLサーバ証明書の検証 については何も触れていない。

1.9. TaxiiScript.__call__() / HttpClient.call_taxii_service2()

さて、TaxiiScript.create_client()でHttpClientオブジェクトを作っ て__call__()へ戻ると、今度は HttpClient.call_taxii_service2()を呼び出 してTAXIIサーバとの通信を行い、その結果を戻す。

libtaxii/scripts/__init__.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
#
    print("Request:\n")
    if args.xml_output is False:
        print(request_message.to_text())
    else:
        print(request_message.to_xml(pretty_print=True))

    resp = client.call_taxii_service2(url.hostname,
                                      url.path,
                                      self.taxii_version,
                                      request_message.to_xml(pretty_print=True),
                                      url.port)
    r = t.get_message_from_http_response(resp, '0')

    self.handle_response(r, args)
except Exception as ex:
    traceback.print_exc()
    sys.exit(EXIT_FAILURE)

sys.exit(EXIT_SUCCESS)

1.10. HttpClient.call_taxii_service2() / LibtaxiiHTTPSHandler

ではそのcall_taxii_service2()では何をするかといえば、 TAXIIプロトコル上のヘッダやTAXIIサーバのURLを組み立てるなどの他に、 通信のためのデフォルトハンドラーをインストールしてから、 urllib.request.Request() と urllib.request.urlopen()を使ってTAXIIサー バと通信し、その結果を返します。

まずデフォルトハンドラのインストールは、HTTPS を使う場合に LibtaxiiHTTPSHandler() を呼び出します。 この時引き数として verify_server と ca_certs(ca_file) を渡しています。

libtaxii/client.py HttpClient.call_taxii_service2()
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
if self.use_https:
    header_dict[HttpClient.HEADER_X_TAXII_PROTOCOL] = VID_TAXII_HTTPS_10

    if (self.auth_type == HttpClient.AUTH_CERT or
            self.auth_type == HttpClient.AUTH_CERT_BASIC):
        key_file = self.auth_credentials['key_file']
        cert_file = self.auth_credentials['cert_file']
        key_password = self.auth_credentials.get('key_password')
    else:
        key_file = None
        cert_file = None
        key_password = None

    if (self.auth_type == HttpClient.AUTH_BASIC or
            self.auth_type == HttpClient.AUTH_CERT_BASIC):
        header_dict['Authorization'] = self.basic_auth_header

    verify_server = self.verify_server
    ca_file = self.ca_file

    handler_list.append(LibtaxiiHTTPSHandler(
        key_file=key_file,
        cert_file=cert_file,
        verify_server=verify_server,
        ca_certs=ca_file,
        key_password=key_password))

その後、urllib.requestのbuild_opener()とinstall_opener()を呼んで デフォルトハンドラを作成・インストールしている。

このデフォルトハンドラに verify_server=True や ca_file の指定があれば TLS通信を開くときにSSLサーバ証明書の検証も行うものと思われる。 これは urllib.request の機能であり、次の節ですこし触れる。

1.11. urllib

urllib の urllib.request.Request()には、引き数に unverifiable=True (default: False)があるが、これは「 RFC2965 で定義されている unverifiable」だそうで、どうやらHTTPプロトコルにおける状態管理のために Cookieを使うけれども、HTML中の画像ファイルを取り寄せる際にはCookie漏れ を防ぐためにCookieを送らない動きとしてunverifiable、であるようです。 (よくわかっていませんので間違っている可能性大です。)

同じくurlopen()の引き数にはcafileがあるが、 call_taxii_service2()では明示していないのでdefaultのNoneのままになるは ず。 cafileには信頼できるルートCAを列挙したもの(要するにCA_ROOT_BUNDLE)を 渡せば良いと思われる。 これがNoneの場合は SSLContext.load_verify_locations() に「verify_modeがCERT_NONEでない場合に接続先の証明書ファイルの正当性検 証に使われる “認証局” (CA=certification authority) 証明書ファイル一式」 とあるので、今度は verify_mode が問題になる。

SSLContext.verify_mode では、CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED のいずれかを設定して、「接続先の証明書の検証を試みるかどうか、また、検 証が失敗した場合にどのように振舞うべきかを制御」するものとなっている。 (CERT_OPTIONALはCERT_REQUIREDと同じ意味でSSLサーバ証明書を検証して、 問題なければそのまま接続するし、問題があればエラーで終了する)

1.12. HttpClient.call_taxii_service2() / urllib.request.urlopen()

元に戻って、デフォルトハンドラをインストールした後、いくつかのタスクを 行った後に call_taxii_service2() から urlopen() を呼んで TAXII サーバ への接続を行う。 この時にデフォルトハンドラが雛形として使われるので、その設定 (verify_server, ca_file) が適切であれば SSL サーバ証明書の検証を行うは ずである。

libtaxii/clients.py HttpClient.call_taxii_service2()
341
342
343
344
345
346
347
348
349
req = urllib.request.Request(url, post_data, header_dict)
try:
    if timeout is not None:
        response = urllib.request.urlopen(req, timeout=timeout)
    else:  # Defaults to socket.getdefaulttimeout()
        response = urllib.request.urlopen(req)
    return response
except urllib.error.HTTPError as error:
    return error

1.13. patch

以上を勘案して、

  • get_arg_parser() でコマンドラインオプションを追加
  • ca_fileに書かれたルートCAの証明書をロード
  • create_client()で作成されたclientにverify_server=Trueを追加

の以上3点でdiscovery_clientがSSLサーバ証明書を検証するはずである。

パッチは下の通り。

$ git diff -u master ssl_verify_server
diff --git a/libtaxii/clients.py b/libtaxii/clients.py
index 58421f4..2e74839 100644
--- a/libtaxii/clients.py
+++ b/libtaxii/clients.py
@@ -402,6 +402,7 @@ class VerifiableHTTPSConnection(six.moves.http_client.HTTPSConnection):
     The default httplib HTTPSConnection does not verify certificates.
     This class extends HTTPSConnection and requires certificate verification.
     Borrowed from http://thejosephturner.com/blog/2011/03/19/https-certificate-verification-in-python-with-urllib2/
+    (moved to https://thejosephturner.com/blog/post/https-certificate-verification-in-python-with-urllib2/)
     """

     def __init__(self, host, port=None, key_file=None, cert_file=None,
@@ -429,7 +430,10 @@ class VerifiableHTTPSConnection(six.moves.http_client.HTTPSConnection):
             if hasattr(ssl, "create_default_context"):
                 self.context = ssl.create_default_context(
                     ssl.Purpose.CLIENT_AUTH, cafile=ca_certs)
-
+
+                self.context.load_verify_locations(cafile=ca_certs)
+                self.context.check_hostname = True
+                self.context.verify_mode = ssl.CERT_REQUIRED
                 if cert_file or key_file:
                     self.context.load_cert_chain(
                         cert_file, key_file, password=key_password)
diff --git a/libtaxii/scripts/__init__.py b/libtaxii/scripts/__init__.py
index ab51710..4d22bea 100644
--- a/libtaxii/scripts/__init__.py
+++ b/libtaxii/scripts/__init__.py
@@ -194,6 +194,13 @@ class TaxiiScript(object):
                             type=open,
                             help="Use a configuration file to load arguments into the script. The contents of the "
                                  "configuration file will take precedence over passed flags.")
+        parser.add_argument("--verify-server",
+                            dest="verify_server",
+                            action="store",
+                            default=None,
+                            metavar="CA-ROOT-FILE",
+                            help="Specify ca-bundle file to verify server-side certification. "
+                                 "None (default) implies not to verify.")

         return parser

@@ -207,7 +214,7 @@ class TaxiiScript(object):
         else:
             print(response.to_xml(pretty_print=True))

-    def create_client(self, use_https, proxy, cert=None, key=None, username=None, password=None):
+    def create_client(self, use_https, proxy, cert=None, key=None, username=None, password=None, verify_server=None):
         client = tc.HttpClient()
         client.set_use_https(use_https)
         client.set_proxy(proxy)
@@ -225,6 +232,10 @@ class TaxiiScript(object):
         elif basic:
             client.set_auth_type(tc.HttpClient.AUTH_BASIC)
             client.set_auth_credentials({'username': username, 'password': password})
+        if verify_server is not None:
+            client.set_verify_server(verify_server=True, ca_file=verify_server)
+            client.verify_server = True
+            client.ca_file = verify_server

         return client

@@ -394,7 +405,8 @@ class TaxiiScript(object):
                                         args.cert,
                                         args.key,
                                         args.username,
-                                        args.password)
+                                        args.password,
+                                        args.verify_server)

             print("Request:\n")
             if args.xml_output is False:

1.14. 備考

  • 2020/Apr/15頃書いた