はじめに
瀧澤です。
自分のMacを30日ほど観測してみたら、37カ国と通信していました。世界旅行しすぎですね。今回の話は、SNI, QUICなどを元に、どうやって宛先を明らかにするかの話です。(とはいえど、これでも全ての宛先は把握できないのですが・・)
前々回は Yamaha RTX配下の通信をWebで可視化した話、前回は Cisco IOSにも対応させた話 を書きました。どちらも情報源は ルーターのNATセッションでした。
今回はMac用のAgentを作り、「どのアプリが、どこへ通信しているか」をアプリ単位で見えるようにしています。
- Mac Agent(署名・公証済み
.pkg): https://dl.egressview.com - GitHub: https://github.com/yo1t/egressview
- ライセンス: AGPL-3.0(無償のOSS)
できるようになったこと
どのアプリが、どこへ通信しているか。それをわかりやすく可視化します。Sniffer / Wireshark likeな通信ログ画面もあるのですが、下記のような画面も用意しました。
おまけとして、Anthropic, Open AI, Ollama, もしくは、AWSのBedrock経由のモデルアクセスによる、通信内容の洞察を得るAIエージェント機能も用意しています(各サービスのアクセスキーなどは、ご自身の契約されているものをお使いください。)
どこの国と通信しているかや、左下のサンキー図を使って、どのアプリがどの宛先に通信しているかを可視化します。知らないアプリから太い線が出ていれば、そこから調べ始められます。
私のMacは、30日間で、37カ国と通信していたわけですが、 日本、アメリカ、カナダ、シンガポール、アイルランド、ドイツ、オーストラリア、という順で、アクセス数が多かったです。--- ずいぶんいろんな国に世界旅行していたのね。
なお、ルーターから通信先を見ていた時は、こういう行でした。
192.168.1.42 → 142.250.196.132 tcp dport=443
192.168.1.42 → 142.250.196.132 udp dport=443
同じ通信が、Mac用Agentではこうなりますので、どのアプリからかもわかります。
Google Chrome Helper www.cloudflare.com 14:02 12 sessions
Slack Helper slack-edge.com 14:03 3 sessions
(見覚えのない常駐プロセス) ****.*********.com 03:11 288 sessions
ルーターでは対応していなかった(IPv4 NATセッションを見る仕組みだったので)、IPv6の宛先、60秒のポーリングに収まらない短命セッションも見ることができます。
このアプリで取得した通信の内容は、基本的にどこにも送りません
- 観測した記録は、Macのローカル SQLite に保存されます。既定ではどこへも送りません
- 自分の環境に構築したEgressView Hubへの送信は明示的なopt-inになっています
- URLのパスやクエリは、そもそも取得しません。取るにはTLSを終端する(ルート証明書を入れて中間者になる)しかなく、それはやっていません。読むのは接続先のホスト名までです
- 宛先の名前を読む機能(TLS SNI / QUIC)は既定OFFです
既定OFFで、外部に何らかの情報を出すものとしては2つあります。1) 位置情報を第三者APIから引くとき(宛先IPが渡ります。既定はHubのキャッシュ経由)と、2) 脅威フィードの直接取得(リストを受け取るだけで、こちらの通信内容は送りません)です。位置情報の解決と脅威フィードの取得先は、ドキュメントを確認してください。
ソースを公開しているので(AGPL-3.0)、上記の内容が本当かも自分で検証できます。(なお、法令対応の観点でも、私は、そのような情報を持ちたくないですし、管理もしたくないですし。)
Mac Agentは署名・公証済みの .pkg を配布していますので、インストーラ経由で簡単に導入できます。完全にデータも削除するアンインストーラー機能も含めています。
どうやって実現しているか -- Network Extension
Network Extension の NEFilterDataProvider を使いました。
macOSのコンテンツフィルタ機構で、新しい通信フローが張られるたびに呼ばれます。ここには プロセス名とBundle IDが載っています。
// NEFilterSocketFlow から取れるもの
flow.sourceAppSigningIdentifier // com.apple.Safari
flow.remoteEndpoint // 142.250.196.132:443
宛先の名前の取得方法
NEFilterSocketFlow には remoteHostname があり、macOSが知っていれば入っています。実測してみました。直近1時間・15,160件。
| 件数 | |
|---|---|
| remoteHostnameでホスト名が把握できた通信 | 6,496件(42.8%) |
しかしながら、半分以下でした。アプリ別に見ると、下記のような特性が・・。
| アプリ | 通信 | 名前あり |
|---|---|---|
| mDNSResponder | 2,855 | 2,812 |
| claude | 2,324 | 1,702 |
| Google Chrome Helper | 2,507 | 0 |
| Codex (Service) | 1,404 | 0 |
| Evernote Helper | 652 | 0 |
| Slack Helper | 263 | 0 |
ブラウザは1件も名前が付きません。 Network.framework や NSURLSession を使うアプリには macOS が名前を教えてくれますが、独自のネットワークスタックを持つアプリには付きません。
SNIやQUICだからなのですが、さて、他の方法はないものか、、、
あと、通信先の国名も見たいのですよね。
何らかの、OSの標準コマンドで見えるか?
-
lsof -i— プロセス名も宛先IPも出ます。QUIC(UDP/443)も見えます。ただしroot権限なしでは自分のプロセスしか見えません -
netstat -anv—-vを付けるとprocess:pidの列が出ます(codex:3550のように)。Linuxの-pにあたるものは無いと思い込んでいましたが、あります -
nettop— プロセス単位のツリーで、バイト数・再送・RTTまで出ます -
tcpdump—pktap経由でプロセス名も出せます(-k)
QUICもフローとしては見えます。
$ netstat -anv -p udp
udp4 192.168.xx.yy.52932 34.160.167.81.443 ... Evernote:5289
「Evernoteが 34.160.167.81 の443番へUDPを出している」までは標準コマンドで分かります。ただしどれもQUICの中身は解釈しないので、SNIは出ません。nettop に出る名前は逆引きの結果です。
(私も大好きな) Wireshark は復号できる
Wireshark は QUIC Initial を復号してSNIを表示できます。標準のsaltを使う方式ですね。
$ tshark -i en0 -a duration:25 \
-Y 'quic && tls.handshake.extensions_server_name' \
-T fields -e tls.handshake.extensions_server_name
a-api.anthropic.com
国名も ip.geoip.country で出せますが、MaxMindのGeoLite2を自分で置く必要があり、既定では空でした。
あと、当たり前ですが、パケットキャプチャなので、どのプロセスが出した通信かは分かりません。
並べてみる
| プロセス名 | 宛先IP | 宛先の名前 | QUICのSNI | 通信量 | 履歴 | 図で見る | AI分析 | 網全体 | 常時の負荷 | |
|---|---|---|---|---|---|---|---|---|---|---|
netstat -anv |
✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | 単発 |
lsof -i |
△ | ✓ | △ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | 単発 |
nettop |
✓ | ✓ | △ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | 単発 |
tcpdump -k |
✓ | ✓ | △ | ✗ | ✓ | 自分で保存 | ✗ | ✗ | ✗ | 全パケット |
Wireshark(tshark) |
✗ | ✓ | ✓ | ✓ | ✓ | 自分で保存 | ✗ | ✗ | ✗ | 平均 4.11% |
| EgressView Hub(ルーター) | ✗ | ✓ | ✓ | ✗ | △ | ✓ | ✓ | ✓ | ✓ | ルーター側 |
| EgressView Agent (今回作ったもの) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | 平均 1.06% |
「宛先の名前」の △ は逆引きの結果で、「そのIPを誰が持っているか」であって「アプリが繋ぎに行った名前」ではありません。lsof のプロセス名の △ は、root権限なしでは自分のプロセスしか見えないという意味です。
負荷は Wireshark と Agent を同じ時間帯・同じトラフィックで測った値です。参考程度で。tshark が平均4.11%(最大10.20%)、Agent の System Extension が平均1.06%(最大2.80%)でした。Wireshark は流れるパケットを全部取って解釈しますが、Agent が見るのは新しいフローの最初の数データグラムだけです。もっとも Wireshark は調べるときに開く道具で、常時つけっぱなしにする前提のものではありません。
Little Snitch との違い
アプリ別に通信先を見るツールとしては Little Snitch が20年以上前からあり、機能面では成熟しています。今回のAgentは ブロックはしません。見るだけです。
違うのは、宛先の名前をどこから得るかだと理解しています。Little Snitch は名前解決の記録からIPと名前を結びつける方式で、今回のAgentは TLSのSNIとQUICのInitial、つまり アプリが実際に送ったハンドシェイクを読みます。
後者を選んだのは、前述の実測が理由です。Google Chrome Helper の2,507件で、macOSが名前を知っていたのは0件でした。独自のネットワークスタックを持ち、名前解決も自前で済ませるアプリでは、名前がOSを通りません。ハンドシェイクなら、繋ぐ相手の名前はそこに載っています。
もっとも、私の方法でも足りないものがあります。ECHが使われると読めません(後述)。まぁ、見ている場所が違うということになります。
そのうえで、今回のAgentは、OSS(AGPL-3.0)なので「本当にどこへも送っていないか」をコードで確認でき、そして ルーター側の観測と統合して、Mac1台ではなく家やオフィス全体を同じ画面で見られることが特徴になります。
TLSのSNIを読むには?
ホスト名は、TLSのClientHelloに SNI(Server Name Indication) として平文で入っています。ここを読めば名前が分かります。
既定はOFF。設定画面で、何を読むのか・何が読めないのかを書いた上でのopt-inにしました。
QUICのInitialを復号する
問題はUDP/443、つまりQUIC(HTTP/3)です。QUICのハンドシェイクにもClientHelloがあり、SNIも入っていますが、暗号化されています。
ただしこの暗号化は独特で、Initialパケットは、パケット自身に載っている値から鍵を導出して暗号化されています。 秘密の鍵は要りません。盗聴対策ではなく、中間装置に勝手に中身をいじらせないためのものです(RFC 9001)。
手順
1. ロングヘッダから Destination Connection ID を取り出す。 接続IDは平文で載っています。バージョンも確認します(v1とv2でsaltが違うので、実装はv1に限定しました)。
2. 初期鍵を導出する。 仕様に書かれた固定のsaltと接続IDから HKDF-Extract します。
let initialSecret = HKDF<SHA256>.extract(
inputKeyMaterial: SymmetricKey(data: destinationConnectionID),
salt: QUICInitial.version1Salt // RFC 9001 §5.2 の固定値
)
let client = expandLabel(secret: initialSecret, label: "client in", length: 32)
let key = expandLabel(secret: client, label: "quic key", length: 16)
let iv = expandLabel(secret: client, label: "quic iv", length: 12)
let hp = expandLabel(secret: client, label: "quic hp", length: 16)
expandLabel は TLS 1.3 の HKDF-Expand-Label(RFC 8446 §7.1)で、QUICはこれを自分のラベルで使います。
3. ヘッダ保護を外す。 ここが独特で、パケット番号が何バイトなのかすら、外すまで分かりません。暗号文の一部をサンプルとして取り、quic hp 鍵でAES-ECBを1ブロック回してマスクを作り、ヘッダにXORします。
CryptoKitには単体のAES-ECBが(意図的に)無いので、ここだけ CommonCrypto を使いました。認証なしのブロック暗号が要る場面は本来ほとんど無く、ここは例外です。
4. ペイロードをAEADで復号する。 quic key と、パケット番号をXORした quic iv をnonceにして AES-GCM で開けます。
5. CRYPTOフレームを繋ぐ。 復号したペイロードはQUICフレームの列です。タイプ 0x06 のCRYPTOフレームをオフセット順に並べると TLS ClientHello が現れます。
6. ClientHelloからSNIを読む。 ここから先は、TCP上のTLSと同じパーサが使えます。
ClientHelloにおいて、2つのInitialに分かれた場合の対応
復号そのものは、RFC 9001 のテストベクタで固定できますが、その手前での処理が必要でした。
たとえば、GREASE・ALPN・ECHを積んだChromeのClientHelloは、1200バイトに収まっていませんでした。 QUICのInitialにはサイズの制約があるため、ClientHelloが2つのInitialパケットに分かれて届きます。 TCP上のTLSでは1つのレコードに収まることがほとんどなのですが、こういった事象も拾ってみることにします。
- 1フローにつき複数回、データグラムを見る。 最初のデータグラムで判断を返してしまうと、2つ目が来ません
- データグラムをまたいでCRYPTOフレームを組み立てる。 片方ずつのかけらは、単体では何にもなりません
組み立てには上限を付けました(1フローあたり4データグラムまで)。際限なく溜め込むと、利用者の通信経路にそのまま影響します。
実測
| 指標 | 分割対応前 | 対応後 |
|---|---|---|
| callback / フロー | 1.03 | 2.07 |
| うち offset 0 | 全部 | 28/56(残りは2つ目のデータグラム) |
| Initial候補 / フロー | 1.0 | 2.0 |
| 読めた名前 | 0 | 27(フロー数と一致) |
| 2つ目のデータグラム待ち | — | 28 |
ポート番号が「UDP/443」としか言わなかったところに、名前が出るようになりました。
と、ここまで書いておきながら、まだ、読めない宛先もあります。
それは ECH(Encrypted Client Hello) の外側の公開名です。ECHが使われると、本当のSNIは暗号化された内側に入り、外からは読めません。 見えているのは「Cloudflareに繋いでいます」という、それ自体は正しいけれど目的地ではない名前です。
ChromeはECHを使わない接続にも同じ拡張を送ります(GREASE)。 観測者の位置から、本物のECHとGREASEは区別できません。それがECHの設計目的そのものです。
ECHが普及するにつれ、この方式では、限界が訪れていくのですが、まずは、一旦ここまで。
まとめ - 試し方
ここまで読んでくれてありがとうございます。
EgressView はOSSです。Mac Agentは署名・公証済みの .pkg で配布しています。
- Mac Agent: https://dl.egressview.com
- リポジトリ: https://github.com/yo1t/egressview
QUICのSNI読み取りは 既定OFF です。設定画面で、何を読むのか・何が読めないのかを確認したうえで有効にしてください。
免責
本記事の内容は個人の検証結果であり、動作を保証するものではありません。また、所属している組織とは関係もありません。ネットワークの監視は、ご自身が管理する機器・ネットワークに対してのみ行ってください。ソースコード、アプリケーションの利用においては、ご自身の責任で行なってください。
商標について
Yamaha、RTX は ヤマハ株式会社 の商標または登録商標です。Cisco、Cisco IOS は Cisco Systems, Inc. の商標または登録商標です。Mac、macOS、Safari は Apple Inc. の商標です。Chrome、Google は Google LLC の商標です。その他、記載されている会社名・製品名は各社の商標または登録商標です。
