knqyf263's blog

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

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じゃない可能性もあり、そういう場合はうまくサイドチャネル攻撃が成立しないと思われます。

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

まとめ

面白かった