パケットキャプチャからKubernetes APIのTLS通信を解析する


この記事はニフクラ 等を提供している、富士通クラウドテクノロジーズ Advent Calendar 2022 の 1 日目の記事です。


インフラ関連のトラブルシューティングの際にはパケットキャプチャを頼りにすることが多いのですが、 Kubernetesのトラブルシューティングの際に思うように調査できなかったことがあったため、 パケットキャプチャを利用した調査方法について調べてみました。

意地になってなんとか実現したという感じで、半分以上はあまり現実的な方法ではないので、 読み物として読んでいただくか、結論だけ知りたい人は 「まとめと所感」 の箇所まで読み飛ばしていただければと思います。

経緯

Kubernetesクラスタ内では様々なコントローラーが動作しており、大抵は kube-apiserver へ Kubernetes API の呼び出しをしています。 Kuberntes APIは送信元と送信先が相互にTLSによって認証されており、 そのためクライアント証明書の期限切れなどのトラブルが起こり得るのですが、 その原因箇所の特定がうまくできないということがありました。

うまくできなかった理由としては、以下のような要因が挙げられます。

この時は結局、直感で怪しいと思ったコントローラーについて調べて原因を特定できたのですが、 似たようなトラブルがあったときのためにkube-apiserverの通信内容をパケットキャプチャから解析する手段を確立しておきたいと感じました。

手順

eBPFによるSSLKEYLOGFILEの抽出

image

golang標準のTLSライブラリ自体はSSLKEYLOGFILEの出力機能を持っていますが、 これが有効に働くのは KeyLogWriter が指定されている場合のみであり、 kube-apiserver はこれに対応していません。

しかし、Linuxでは動作中のプログラムに対して、特定の箇所が実行されるごとに好きな処理を走らせる方法が存在しているので、 これによってSSLKEYLOGFILE相当の情報を得ることを考えましょう。 そのような方法としてイメージしやすいところでは、gdbのようなデバッガによるブレークポイントの設定などが挙げられますが、ここではeBPFとuprobeを利用することにします。

( eBPFはユーザーが記述したプログラムをカーネル空間で安全に動作させる仕組みであり、カーネル空間で処理が完結することによる効率化・高速化が狙えることから、 ネットワークの処理に活用されたり、カーネル内部の処理のプロファイリングに利用したり、活用が広がっています。 eBPFを利用する場合、eBPFプログラムを用意した上でこれをカーネルに渡し、そのeBPFプログラムをどのタイミングで呼び出させるかという指定をします(アタッチする)。 このアタッチ先には様々なものがあり、その一つにuprobeが挙げられます。 uprobeは任意のユーザ空間のプログラムの任意の箇所で別の処理を呼び出せるようにするための仕組みで、その際に呼び出す処理としてeBPFプログラムを利用できるということになります。 )

golangの標準のTLSライブラリでは、 KeyLogWriter の指定有無に関わらず、 SSLKEYLOGFILEの情報を書き出すタイミングで writeKeyLog という内部関数を呼び出しているため、 uprobeでこの関数呼び出しをフックし、関数の引数を参照してSSLKEYLOGFILE相当の情報を出力するeBPFプログラムを用意すればやりたいことができるはずです。

eBPFプログラムの作成に加え、それをアタッチしたり結果を処理する部分も含めた一連の流れを簡単に実装する手段として、bpftraceというツールがあります。 ただし、golangの場合ABIが独自であることもあり、そのまま使うのは難しいので go-bpf-gen を使います。 この中にはまさに今回必要としている writeKeyLog をフックしてSSLKEYLOGFILEを得るスクリプト tlssecrets.bt を生成する手順が含まれているので、それを使ってみましょう。

コンテナはホスト(Kubernetesノード)のカーネルをそのまま使うため、コンテナ内のプロセスが対象であったとしても、 bpftraceなどのツールはホストのOS上にインストールして動作させれば問題ありません。 uprobeで指定する実行ファイルパスには、コンテナ内で実行されるファイルのホスト上のパスを指定すればよく、 containerd利用環境では例えば以下のようにすればすぐにわかると思います。

$ sudo sh -c 'ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/*/fs/usr/local/bin/kube-apiserver'
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/usr/local/bin/kube-apiserver

関数位置の特定

(2023/5/13 追記: 関数位置の特定に関しては、この節で説明する方法ではなく、GoReSym を使う方法でもっと簡単にできました。 writeKeyLog 関数について情報を得るには -d オプションが必要になる点に注意が必要です。得られた結果からアドレスを算出するには、出力されたJSONの .TabMeta.VAwriteKeyLog についての Start の値から引いたものに、.textセクションのアドレス 0x401000 を加えればよいです。このとき得られるのはシンボルテーブル上のアドレスに相当するものなので、ファイルオフセットを求める場合はそこから.textセクションのアドレスを引いて.textのオフセット 0x1000 を加えることになります。)

では go-bpf-gen を使う準備も整ったので、早速使ってみると、以下のように上手くいきません。

$ sudo go-bpf-gen templates/tlssecrets.bt /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/usr/local/bin/kube-apiserver | sudo bpftrace -

2022/12/01 06:25:48 couldn't get regs abi (no symbol section). falling back to stack calling convention
No probes to attach

no symbol section というところからわかるように、k8s.gcr.io で配布されているコンテナイメージ内の kube-apiserver は、 symbolがstripされた状態で配布されているようです。 そして、調べた限りデバッグ用に別途シンボルが配布されているということもないようでした。 (この件については既に 指摘 があるので、今後改善されるかもしれません。)

$ sudo nm /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/usr/local/bin/kube-apiserver

nm: /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/usr/local/bin/kube-apiserver: no symbols

こうなってしまうと、 go-bpf-gen が生成するbpftraceスクリプト内でuprobeの設定先として指定されている関数名に対応するアドレスが特定できず、 uprobeを設定することができません。

かといって、トラブルシューティングのためにデバッグ用にシンボルを残した状態で kube-apiserver を自前でビルドし、差し替えるというのも大変です。 そこで、何とかして writeKeyLog 関数の位置を特定してみます。

まず kube-apiserver のビルド環境のバージョンを確認します。

$ sudo go version /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/usr/local/bin/kube-apiserver

/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/usr/local/bin/kube-apiserver: go1.19.1

このバージョンのgoビルド環境を用意し(配布されているコンテナイメージを使えば容易です)、TLSライブラリを利用した簡単なプログラムをビルドします。 ここでは、ドキュメント内のexample を利用しました。

$ docker run -it -v $PWD:/work -w /work --rm golang:1.19.1 go build tls_example.go

ここで生成されるバイナリのにはシンボルテーブルが残っており、かつ writeKeyLog 関数も含まれるため、 writeKeyLog に対応する箇所のバイト列が特定できます。

$ objdump -d tls_example

(略)
0000000000580880 <crypto/tls.(*Config).writeKeyLog>:
  580880:       4c 8d 64 24 f8          lea    -0x8(%rsp),%r12
  580885:       4d 3b 66 10             cmp    0x10(%r14),%r12
  580889:       0f 86 0d 02 00 00       jbe    580a9c <crypto/tls.(*Config).writeKeyLog+0x21c>
  58088f:       48 81 ec 88 00 00 00    sub    $0x88,%rsp
  580896:       48 89 ac 24 80 00 00    mov    %rbp,0x80(%rsp)
  58089d:       00
  58089e:       48 8d ac 24 80 00 00    lea    0x80(%rsp),%rbp
  5808a5:       00
  5808a6:       48 89 9c 24 98 00 00    mov    %rbx,0x98(%rsp)
  5808ad:       00
  5808ae:       48 89 bc 24 a8 00 00    mov    %rdi,0xa8(%rsp)
  5808b5:       00
  5808b6:       4c 89 8c 24 c0 00 00    mov    %r9,0xc0(%rsp)
  5808bd:       00
  5808be:       48 83 b8 28 01 00 00    cmpq   $0x0,0x128(%rax)
  5808c5:       00
  5808c6:       0f 84 bc 01 00 00       je     580a88 <crypto/tls.(*Config).writeKeyLog+0x208>
  5808cc:       48 89 84 24 90 00 00    mov    %rax,0x90(%rsp)
  5808d3:       00
  5808d4:       4c 89 94 24 c8 00 00    mov    %r10,0xc8(%rsp)
  5808db:       00
  5808dc:       4c 89 9c 24 d0 00 00    mov    %r11,0xd0(%rsp)
  5808e3:       00
  5808e4:       48 89 b4 24 b0 00 00    mov    %rsi,0xb0(%rsp)
  5808eb:       00
  5808ec:       4c 89 84 24 b8 00 00    mov    %r8,0xb8(%rsp)
  5808f3:       00
  5808f4:       4c 89 8c 24 c0 00 00    mov    %r9,0xc0(%rsp)
  5808fb:       00
  5808fc:       48 89 bc 24 a8 00 00    mov    %rdi,0xa8(%rsp)
  580903:       00
  580904:       44 0f 11 7c 24 50       movups %xmm15,0x50(%rsp)
  58090a:       44 0f 11 7c 24 60       movups %xmm15,0x60(%rsp)
  580910:       44 0f 11 7c 24 70       movups %xmm15,0x70(%rsp)
  580916:       48 89 d8                mov    %rbx,%rax
  580919:       48 89 cb                mov    %rcx,%rbx
  58091c:       0f 1f 40 00             nopl   0x0(%rax)
  580920:       e8 db b7 e8 ff          callq  40c100 <runtime.convTstring>
(略)

0x580920 の手前までは、関数内での相対ジャンプ以外でアドレスに関する情報がないため、 (goのバージョンが同じであれば)別のプログラムにおいても同じバイト列の並びになっていることが期待できます。 そこで、その部分のみを抜き出し、 kube-apiserver との比較を試みます。

まず、プログラム本体を含むセクションである .text のアドレスとオフセットを確認し、 objdump で見つけた問題の箇所がファイル内でどの位置になるかを特定し、抜き出します。

$ readelf -S tls_example

There are 36 section headers, starting at offset 0x270:

セクションヘッダ:
  [番] 名前              タイプ           アドレス          オフセット
       サイズ            EntSize          フラグ Link  情報  整列
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000401000  00001000
       00000000001aadf7  0000000000000000  AX       0     0     32
  [ 2] .plt              PROGBITS         00000000005abe00  001abe00
       0000000000000210  0000000000000010  AX       0     0     16
(略)

$ dd if=tls_example of=wkl.bin bs=1 skip=$((0x580880 - 0x401000 + 0x1000)) count=$((0x580920 - 0x580880))

160+0 レコード入力
160+0 レコード出力
160 bytes copied, 0.00203572 s, 78.6 kB/s

ちょうどよいツールが思い当らなかったので、雑にバイナリ列比較スクリプトを用意し、 writeKeyLog に対応するバイト列の kube-apiserver での位置を特定します。

$ cat << "EOF" > find.py
import sys

def find(pattern, source):
    results = []
    offset = 0
    while True:
        i = source[offset:].find(pattern)
        if i >= 0:
            results.append(offset+i)
            offset += i + 1
        else:
            return results

with open(sys.argv[1], 'rb') as f1, open(sys.argv[2], 'rb') as f2:
    results = find(pattern=f1.read(), source=f2.read())
print(results)
EOF

$ sudo python find.py wkl.bin /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/usr/local/bin/kube-apiserver

[2708320]

$ printf "0x%x" 2708320

0x295360

ちゃんと一意に見つかったようです。

パケットキャプチャと解析

今度こそ go-bpf-gen を使ってSSLKEYLOGFILEを取れるはずです。 先ほどの実行では couldn't get regs abi というメッセージも出ており、 ABIを理解されていないと引数の解釈が正しくできないことになってしまうので、 一旦 tls_example を使ってbpftraceスクリプトを生成します。

$ go-bpf-gen templates/tlssecrets.bt /work/tls_example | tee ~/tlssecrets.bt

// capture TLS secrets for use with wireshark.
uprobe:/work/tls_example:"crypto/tls.(*Config).writeKeyLog" {
         // func (c *Config) writeKeyLog(label string, clientRandom, secret []byte) error
         $label = str(reg("bx"), reg("cx"));
         // slices are passed as a pointer, length and then capacity so skip a register
         $clientRandom = buf(reg("di"), reg("si"));
         $secret = buf(reg("r9"), reg("r10"));

         printf("%s %rx %rx\n", $label, $clientRandom, $secret);
}

これを編集し、以下のようにします。

  // capture TLS secrets for use with wireshark.
- uprobe:/work/tls_example:"crypto/tls.(*Config).writeKeyLog" {
+ uprobe:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/usr/local/bin/kube-apiserver:0x295360 {
           // func (c *Config) writeKeyLog(label string, clientRandom, secret []byte) error

( ※ 0x295360 はバイナリファイル内からバイナリ列を検索して見つけた位置であるため、ファイル内のオフセットであり、本来アドレスではありません。 .text のロード先のアドレスやオフセットに基づきアドレスを特定する必要があると思いましたが、 何故かシンボルテーブルの無いバイナリにuprobeを設定する場合、ファイルのオフセットを設定しないとうまくいきませんでした。 bpftraceの仕様なのかバグなのか、原因は不明です。 )

これを利用して、SSLKEYLOGFILEを作成します。

$ sudo bpftrace --unsafe tlssecrets.bt | sed '1d;s/\\x//g' > sslkeylogfile

同時に、パケットキャプチャも実施します。 kube-apiserver はホストのデフォルトのネットワークネームスペースで動いている(Podの spec.hostNetworktrue)ので、 通常通りtcpdumpなどでパケットキャプチャできます。 (そうではないPodなどの場合は、 Topotestsを利用してルーティングについて勉強する の “パケットキャプチャ” の項目で紹介しているような nsenter を利用する方法が個人的にはおすすめです。)

tcpdump -i lo -w kube-apiserver.pcap tcp port 6443

(ここでは同じノード上のPodからの通信を見るため、 lo を指定)

最後に、 go-bpf-gen のREADMEの説明通り、 editcap で pcap ファイルに SSLKEYLOGFILE を埋め込んで、 それをWriresharkで解析すれば、TLS通信を復号状態で確認することができます。

editcap --inject-secrets tls,sslkeylogfile kube-apiserver.pcap decrypted.pcap

image

環境情報

まとめと所感


この記事は富士通クラウドテクノロジーズ Advent Calendar 2022 の 1 日目の記事でした。

明日は @SogoK さんが OpenID Connectクライアント実装に関する記事を投稿してくださるようです。 昨年のアドベントカレンダーで社内CTFを開催したことを紹介しましたが、 その中でOpenID Connectによる認証を利用していたり、他の業務の中でも利用しているので、個人的にも関心の高い話題です。お楽しみに!