JARMを使ったTLS Fingerprinting 〜お手軽グラフ分析環境構築を添えて〜

この記事は [NTTコミュニケーションズ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/nttcom) の11日目の記事です。（振り返ってみると2019年のアドベントカレンダーも[11日目](https://qiita.com/a0x41/items/89301e4cbe2d9946a953)を担当していました）

昨日は @yuki_uchida さんの[WebTransportとWebCodecsを組み合わせてビデオチャットを実装してみる](https://qiita.com/yuki_uchida/items/b177ec07ac0379950e58)でした。

# 概要
この記事はSalesforceが先月（2020年11月）に公開したJARMというTLSフィンガープリンティングツールを検証してみた話です。

ついでにIDE環境であるJupyterLabとグラフDBであるNeo4jを組み合わせたグラフ分析・可視化環境をdocker-composeを用いてお手軽に構築する方法もご紹介します。

この記事をご覧になった方がご自身でも試せるようにハンズオンっぽく書いてあります。

# 背景・用語解説
## フィンガープリンティング（Fingerprinting）とは
**フィンガープリンティング（Fingerprinting）**とは、指紋（Fingerprint）のように個人やシステム、端末などに特徴的な情報を収集し、識別に活用する技術のことです。刑事ドラマなどで容疑者の指紋を採取して照合しているアレをイメージしてもらうと分かりやすいかと思います。

この記事で扱う**TLSフィンガープリンティング**は特に**SSL/TLS通信の特徴を使ってアプリケーションやシステムを識別すること**を目的としています。

## JARMとは
[JARM](https://github.com/salesforce/jarm)はSalesforceがOSS公開したTLSフィンガープリンティングツールで、その特徴は以下の通りです。

- 対象IP/ドメインに能動的にHTTPS通信を行ってフィンガープリントを生成する
- 可逆なハッシュと不可逆な（暗号学的）ハッシュを組み合わせてフィンガープリントを生成する

ざっくり言えば**パラメータを変えた10通りのTLS通信（Client Hello）を発生させて、その応答を元に62文字固定長のフィンガープリントを生成するツール**です。
詳細を知りたい方は[Salesforceの紹介記事](https://engineering.salesforce.com/easily-identify-malicious-servers-on-the-internet-with-jarm-e095edac525a)を参照してください。

ちなみにSalesforceはこれまでも[JA3](https://github.com/salesforce/ja3)や[HASSH](https://github.com/salesforce/hassh)と言ったフィンガープリンティングツールを開発・公開してきました。その彼らが作った新しいフィンガープリンティングツールとなれば気にならない訳がない。

さらにJARMフィンガープリントが（彼ら曰く）**ハイブリッドなFuzzy Hash**であるという点も興味をそそられました。

Fuzzy Hashとは、**似たようなものが近い値となる**といった性質を持ったハッシュです。これは言い換えればハッシュ値を比較して類似度を測ることができるとも言えます。そうとなればFuzzy Hashとしての性能を試してみたくなりますよね？ということで少し検証してみました。

# 結論だけ欲しい方へ
- JARMフィンガープリントが完全に一致した場合に同じシステムと考えてよいか？ → それなりに信頼できそう（ただし、False Positiveはある）
- JARMフィンガープリントが近い値だった場合の解釈 → 類似度のメトリックは工夫が必要（単純なハミング距離はいまいち）

# 分析環境構築
JARMを触るにあたって今回はJupyterLabとNeo4jを使いました。

JupyterLabはWeb-UIで使えるIDEです。JupyterLabについては[kirikeiさんの記事](https://qiita.com/kirikei/items/a1639954ce5ccaf7ac3c)が分かりやすいので、詳しい説明はそちらをご覧ください。

Neo4jはグラフDBです。グラフDBはグラフ構造で表現されるようなデータの操作に特化したDBです。ここで言う「グラフ」はExcelなどで作成するグラフ…ではなくて、ノード（頂点）とエッジ（辺）から構成される構造のことです。今回はJARMフィンガープリントの類似度を元に作成したグラフを可視化・分析するために使用します。Neo4jの詳細については[awk256さんの記事](https://qiita.com/awk256/items/dc528a64ecb380eabb5c)が分かりやすくまとまっています。

今回はJupyterLabを用いてJARMの実行やフィンガープリントデータの加工、類似度の計算、Neo4jへのインポートを行い、Neo4jで類似度を元に作成したネットワークグラフの可視化、分析を行いました。

## 0. 前提条件

- Docker + Docker Composeが動くこと[^1]
- それなりのメモリ容量（Neo4jの[メモリ要件](https://neo4j.com/docs/operations-manual/current/installation/requirements/)は個人利用で最低2GB）

## 1. 準備  
必要なのは`docker-compose.yml`だけです。  
Dockerイメージは全て[DockerHub](https://hub.docker.com/)にある公式イメージを使います。

```docker-compose.yml
version: "3"

services:
  jupyterlab:
    image: jupyter/datascience-notebook:399cbb986c6b
    ports:
      - 8888:8888
    volumes:
      - ./work:/home/jovyan/work
      - ./import:/import
    command: start.sh jupyter lab --NotebookApp.password='' --NotebookApp.token=''

  neo4j:
    image: neo4j:4.2.1
    hostname: neo4j
    ports:
      - 7474:7474
      - 7687:7687
    volumes:
      - ./import:/import
    environment:
      - NEO4J_AUTH=none
      - NEO4JLABS_PLUGINS=["apoc", "graph-data-science"]
      - NEO4J_apoc_import_file_use__neo4j__config=true
      - NEO4J_apoc_import_file_enabled=true
    ulimits:
      nofile:
        soft: 40000
        hard: 40000
```

**注意事項**
上記設定は簡単のため全ての認証を無効化し、HTTPS化も省略しています。利用環境に合わせて適切に設定してください。

## 2. サーバ起動

```shell
docker-compose up -d
```

## 3. 動作確認（JupyterLab）
ブラウザで`http://localhost:8888/`にアクセスするとJupyterLabにアクセスできます。
![JupyterLab.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/1d013192-d85b-c233-75d4-7aed19f333d4.png)

## 4. 動作確認（Neo4j）
ブラウザで`http://localhost:7474/`にアクセスするとNeo4jにアクセスできます。
認証を無効化しているので`No authentication`のままでNeo4jにConnectすれば、Web-UIからDBを操作できます。
![Neo4j.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/dd6a7b44-a9b8-6f77-ce56-ef68b05a8967.png)

## 5. 各種ソフトウェアインストール
ここまでの環境では今回の分析に必要なツールが足りないのでJupyterLabに必要なソフトウェアをインストールします。
LancherからTerminalを起動して下記コマンドでJARM, [python-Levenshtein](https://pypi.org/project/python-Levenshtein/), [Neo4j Python Driver](https://neo4j.com/docs/api/python-driver/current/#)をインストールします。

```shell
git clone https://github.com/salesforce/jarm.git
pip install python-Levenshtein neo4j 
```

なお今回は簡単のためにこの手順を記載していますが、この手順でインストールしたソフトウェアはコンテナを再起動すると消えてしまうため、長期的に利用したい場合にはDockerfileを記述してコンテナイメージをビルドすることをお勧めします。

# JARMフィンガープリントの分析
構築した分析環境（JupyterLab+Neo4j）を用いてJARMの簡易検証を行いました。

## 1. フィンガープリントの収集（JupyterLab）
公開されているJARMのGithubリポジトリにはAlexaランキング上位500のcsvファイルが同梱されており、これを使ったコマンド例が書かれているのでそれをありがたく使わせていただきます。
JupyterLabのTerminalからJARMを実行してフィンガープリントを集めます。

```shell
cd ~/jarm
sh ./jarm.sh alexa500.txt ../work/jarm_alexa_500.csv
```

ちなみに手元の環境で試したところ、毎回いくつかのドメインで名前解決に失敗してフィンガープリントが取れない（`0`が62個並んだフィンガープリントになる）事象が発生しましたが、完全なデータセットを作ることが目的ではないので今回は無視しました。

**注意事項**
このツールは実際に対象IP/ドメインに複数回のTLS通信を行います。攻撃を意図したツールではありませんが、結果的にDoSになる可能性もありますので実行頻度には気をつけてください。
また、環境によってはセキュリティ製品に不審な挙動として検知される場合も考えられます。ご注意ください。

## 2. 類似度計算 〜 Neo4jへのインポート（JupyterLab）
ここから先のJupyterLabの操作は、JupyterLabのホームディレクトリにある`work`ディレクトリ上でPython3 Notebookを開いて行います。

### 2.1 CSV読み込み

```python
from pandas import DataFrame, read_csv
import matplotlib.pyplot as plt
from itertools import combinations
import Levenshtein
from neo4j import GraphDatabase, Driver

# CSVの読み込み
df_jarm: DataFrame = read_csv('./jarm_alexa_500.csv', usecols=[0,2], header=None, names=['domain', 'hash'])

# フィンガープリントの取得に失敗したデータ（`0`が62個並んだフィンガープリント）を除外
df_jarm: DataFrame = df_jarm[df_jarm['hash'] != '0' * 62]
```

この段階での`df_jarm.describe()`の出力は以下の通り。35ドメインのデータが除外されました。
![data-import.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/089a7a43-9aa0-23e4-285d-cca980176753.png)

ユニークなハッシュ値（フィンガープリント）が206個なので、結構重複した値が出ていそうです。

### 2.2 類似度（ハミング距離）の計算
次に読み込んだハッシュ値（フィンガープリント）同士の類似度を計算します。JARMの開発者も類似度の指標については特に言及していないので自分で適当に考えます。

JARMは前半30文字が10通りのTLS接続試行応答の結果をエンコードしたもの、後半32文字がTLSパラメータをSHA256でハッシュ化した値です。値の位置が意味を持っているので、ここはシンプルにハミング距離を使ってみます。（結果的にはそれはあまりよい選択ではありませんでしたが）

ハミング距離は等しい文字数を持つ2つの文字列の**対応する位置にある異なった文字の個数**です。ざっくり言えば「2つのハッシュ値は何文字違うか」を表すことになり、今回のケースに当てはめて考えると、**完全一致した場合に`0`**、**全く一致しなかった場合に`62`**となります。

値が高いほど似ている指標にしたいので、62からハミング距離を引いた値を類似度としたいと思います。つまり、**完全一致した場合に`62`**、**全く一致しなかった場合に`0`**となります。

```python
# ハッシュ値のリストとドメインのリストを作る
hashes:list = df_jarm['hash'].values
domain:list = df_jarm['domain'].values

# 類似度の計算
scores: list = [(domain0, domain1, 62 - Levenshtein.hamming(hash0, hash1)) for (domain0, hash0), (domain1, hash1) in combinations(zip(domain, hashes), 2)]

# 結果を格納
df_similarity: DataFrame = DataFrame(scores, columns=['domain0', 'domain1', 'score'])
```

値の分布を見てみると以下の通り。

```python
plt.hist(df_similarity['score'],bins=62)
```
![similarity-score.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/46c3d58a-195b-2ee8-79c6-932a06019eec.png)

類似度が`15`と`30`となるところを頂点に大小の山があって、完全一致の`62`も数が多そうです。

### 2.3 Neo4jへインポート
算出した類似度を元にグラフ構造を作成して、Neo4jへインポートしてみます。
Neo4jへデータをインポートする方法はいくつかありますが、一気につっこみたいので今回は`apoc.import.csv`を使います。この機能を使うにはNeo4jの[APOC(A Package Of Component)](https://neo4j.com/developer/neo4j-apoc/)という拡張ライブラリが必要となりますが、前述のdocker-composeで設定済みです。

なお、Neo4jへデータをインポートする方法は[rinoguchiさんの記事](https://rinoguchi.hatenablog.com/entry/2020/03/23/105213)が分かりやすかったです。

そのままNeo4jに全部インポートすると計算処理が重くなりそうなので、しきい値を設けます。
今回は類似度が`40以上`のもののみエッジ（辺）を設定します。

```python
# ノード情報のCSVを作成
df_node: DataFrame = df_jarm['domain']
df_node.rename(":ID", inplace=True)
df_node.to_csv('/import/node.csv', header=True, index=False)

# エッジ情報（Neo4jではRelationと呼ぶ）のCSVを作成
df_edge: DataFrame = df_similarity[df_similarity['score'] >= 40].copy()
df_edge.rename(columns={'domain0':'Domain:START_ID', 'domain1':'Domain:END_ID', 'score':'similarity:INT'}, inplace=True)
df_edge.to_csv('/import/relation.csv', header=True, index=False)

# Neo4jドライバの定義
driver: Driver = GraphDatabase.driver('bolt://neo4j', encrypted=False)

with driver.session() as session:

    # 一度DBの中身を全部削除する
    query: str = """
        MATCH (n)
        DETACH DELETE n
    """
    session.run(query)
    
    # ノード情報とエッジ情報をNeo4jに読み込ませる
    query: str = """
        CALL apoc.import.csv(
            [{fileName: $node_csv_path, labels: ['Domain']}],
            [{fileName: $relation_csv_path, type: 'SIMILAR'}],
            {}
        )
    """
    session.run(query, node_csv_path=f'file:///node.csv', relation_csv_path=f'file:///relation.csv')
```

読み込みが完了するとNeo4j側でNodeとRelationshipの情報が表示されます。
![Neo4j-imported.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/200b12e7-76fe-5c5e-3fc3-c540d31fada8.png)

## 3. クラスタリング 〜 可視化（Neo4j）
ここからはNeo4jのWeb-UI上のコンソールで、CYPHERと呼ばれるNeo4j独自のDB操作言語（リレーショナルDBで言うところのSQLみたいなもの）で操作します。

### 3.1 クラスタリング（Louvain法）
今回定義した類似度で「似ている」と判定されたドメイン群を識別したいので、Louvain法を使ってクラスタリングします。Neo4jでは[Graph Data Science](https://neo4j.com/developer/graph-data-science/)という拡張ライブラリにクラスタリングをはじめとした各種のグラフ分析処理が実装されているので、これを使います。（こちらもdocker-composeで設定済み）

なお、本来Louvain法は大規模なグラフを複数のクラスタ（コミュニティ）に分割する際に用いるアルゴリズムの1つですが、今回はすでにしきい値を設けてグラフを分断してしまっているので、結果的にはクラスタにIDを割り当てる用途にしかなっていません。

- グラフカタログの作成

```
CALL gds.graph.create(
    'alexa500',
    'Domain',
    'SIMILAR',
    {
        relationshipProperties: 'similarity'
    }
)
YIELD graphName, nodeCount, relationshipCount, createMillis;
```

- 参考）作成したグラフカタログを削除したい場合

```
CALL gds.graph.drop('alexa500') YIELD graphName;
```

- Louvain法を適用（処理結果の統計情報を見るだけ）

```
CALL gds.louvain.stats('alexa500', { relationshipWeightProperty: 'similarity' })
YIELD communityCount;
```
![Louvain-stats.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/94f413b7-5213-caae-298c-869378e844a4.png)

170個のクラスタ（コミュニティ）に分割されるようです。

- Louvain法を適用（クラスタIDの書き込み）  
  クラスタ単位で分析するためにクラスタを識別する情報（community）を各ノードに書き込みます。

```
CALL gds.louvain.write('alexa500', { writeProperty: 'community' })
YIELD communityCount, modularity, modularities;
```

- クラスタサイズの確認 （下記コマンドにはAPOCが必要となります）

```
MATCH (n:Domain) WITH distinct(n.community) AS c
CALL apoc.cypher.run('MATCH (n:Domain) WHERE n.community = '+c+' RETURN count(DISTINCT n) as count',{}) YIELD value
RETURN c as community_id, value.count as count
ORDER BY count DESC
```

![cluster-size.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/ffc51fc4-f533-31b1-7615-e17bb0171bf2.png)

一番大きいクラスタで36ドメインが「同じ」、「似ている」と判断されたことになります。

参考までにクラスタサイズの分布をヒストグラムにすると以下の通り。
![cluster-size-hist.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/5c223e1f-0084-e04f-1a39-1ed6f744098d.png)


### 3.2 分析

#### サイズ上位のクラスタ
サイズ上位5つのクラスタを可視化してみます。

```
MATCH (n:Domain) WITH distinct(n.community) AS c
CALL apoc.cypher.run('MATCH (n:Domain) WHERE n.community = '+c+' RETURN count(DISTINCT n) as count',{}) YIELD value
WITH c, value.count as count
ORDER BY count DESC LIMIT 5
MATCH (n:Domain) WHERE n.community IN c RETURN n
```

![Top5-cluster.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/1cc651c4-5121-41e3-8e5e-747434853f13.png)

一番大きいクラスタ（図右上）はGoogleでした。`google.*`だけではなく、Google傘下の`blogger.com`や`blogpost.com`、`doubleclick.net`なども同じクラスタにいるのが興味深いです。

Amazonのクラスタ（図下）でも、`amazon.*`の他にAmazon傘下の`imdb.com`や`goodreads.com`が含まれています。

他にも上記の図には含まれていませんが、FacebookもFacebook傘下のドメインを含んだクラスタが構成されていました。

上述のTop5やFacebookのクラスタはいずれも類似度`62`、つまりJARMフィンガープリントが完全一致しているもののみで構成されています。このことから**JARMフィンガープリントの完全一致をもって同一管理主体の管理するサーバと判断する**方法は信憑性がありそうです。

#### 完全一致のFalse Positive
では、JARMフィンガープリントが完全一致したら絶対同一管理主体の管理するサーバと判断できるか、と言われれば残念ながらそうではありません。ハッシュ化している以上、偶然値が衝突することは避けられないので当たり前と言えば当たり前ですが。

ちょうどよい例として弊社グループ会社であるNTTレゾナントが運営する`goo.ne.jp`のケースでは、クラスタは以下のようになっていました。`bet9ja.com`はナイジェリアのスポーツギャンブルサービスだそうです。私の知る限り両者に関係があるとは考えられません。

![ntt-resonant.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/a681974c-b50b-52a0-9163-4a2d6654e566.png)

#### 部分一致で類似度の高いもの
完全一致は今回の類似度の計算ロジックに影響されないので、次に部分一致で値の高いものに関係性が見出せるかを考えます。あまり深い考察はできていませんが、結論としては、どうやら類似度が高いからと言って関係性があるとは言えなさそうです。

例えば`yahoo.com`と`icloud.com`は高い類似度を示していますが、両者の管理主体は異なります。

![yahoo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531283/79fca472-2391-9f6a-37e7-3f0120922f24.png)

前述の通り、JARMフィンガープリントは前半30文字と後半32文字で意味合いが異なるので、単純に全体に対してハミング距離を計算するのは悪手だったということでしょう。混ぜるな危険。

# おわりに
今回は、TLSフィンガープリンティングツールであるJARMを紹介しました。似たものを識別するのにはさらなる工夫が必要ですが、完全にフィンガープリントが一致したものについては、それなりに信憑性があるので、判断材料として使えそうです。

なおJARMはすでにShodanにも実装されており、JARMフィンガープリントで検索することができます。気になる方は使ってみてはいかがでしょうか。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">We&#39;ve added JARM fingerprinting and with it introduce a new &quot;ssl.jarm&quot; property/ filter/ facet. Here&#39;s a breakdown of the current values for JARM fingerprints in Shodan: <a href="https://t.co/njOLlE9uWR">https://t.co/njOLlE9uWR</a> (h/t <a href="https://twitter.com/4A4133?ref_src=twsrc%5Etfw">@4A4133</a> and <a href="https://twitter.com/SalesforceEng?ref_src=twsrc%5Etfw">@SalesforceEng</a>)</p>&mdash; Shodan (@shodanhq) <a href="https://twitter.com/shodanhq/status/1328911823006035970?ref_src=twsrc%5Etfw">November 18, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


明日は @kirikei さんです。お楽しみに！

# 余談
JARMって何の略なんだろうと思って調べてみましたが、どこにもその説明が書いてありませんでした。

あれ、でも開発者の名前を見ると、もしかして…

そしてJA3もよく見てみると…

[^1]: 筆者はmacOS(Catalina, 10.15.7) / Docker Desktop for Mac(2.5.0.1) で動作を確認しました。