HashDoS脆弱性との戦い! Rubyコミッター・卜部昌平が明かすプログラム堅牢化のノウハウ
過去、HashDosの影響を受けたRuby。言語開発者はいかにしてこうした問題に対応してきたのでしょうか。コミッターである卜部氏の貴重な記録を公開します。
2011年の末頃、HashDoSという脆弱性が公表され、Rubyもこの影響を受けた。本稿の筆者である卜部昌平(うらべ・しょうへい/@shyouhei/以下、卜部)は、報告当初からRuby側のチームメンバーとしてプログラム本体の修正を担当した。以下はその記録である。言語開発者たちが普段どのようなことを考え、どういった技術を用いて開発やバグフィックスを行っているのか。その概要を知ってもらえれば幸いだ。
- HashDoSの概要
- なぜ約6年後の今、修正内容を公開するに至ったか?
- 前史:すでに内包されていたリスク
- 報告から初期対応
- 後期対応:乱数をrotate shiftし、ハッシュ値の衝突を回避する
- 脆弱性公開
- おわりに
- 脆弱性と戦う言語開発者たちが、人々の暮らしを支えている
HashDoSの概要
ごく簡単に脆弱性の概要を解説する。この脆弱性のターゲットはハッシュ関数と呼ばれる関数である。関数というからには定義域と値域があるわけで、定義域に相当するものは(任意の)文字列、値域は整数だ。
2011年頃のRubyの場合はCのintを利用していたので、整数とは典型的には32bit符号つき整数で、-2,147,483,648から2,147,483,647(両端を含む)の範囲ということになる。ある文字列にハッシュ関数を適用して得られる整数のことを、「その文字列のハッシュ値」と表現することがある。
ハッシュ関数はさまざまなものが考えられるが、基本的にはハッシュ値が値域の全体に一様に散らばって分布するのが良いハッシュ関数である、とされる*1。
また、ハッシュテーブルと呼ばれるデータ構造があり、このデータ構造におけるO(1)アクセスを理論面から保証しているのがハッシュ値の一様性だ。ハッシュ値の分布に偏りがある場合、一定の時間や一定のメモリ消費で終わることが期待される処理が、意図通り行われなくなる(要するに、パフォーマンスに課題を抱える)。
そして、HashDoSの概要は一言で言うと「完全に同じハッシュ値を持つ文字列の組を発見した」というものである。このような攻撃は"Algorithmic Complexity Attack"と呼ばれており、そもそも脆弱性を理解するには計算量についての理解や議論が不可欠であり、修正するにも計算量を悪化させないように用心する必要がある。
なぜ約6年後の今、修正内容を公開するに至ったか?
当時、これは貴重な記録だと思い、修正内容のメモを残していた。しかし、修正直後の段階では攻撃の影響を受けるシステムが多数残っていたため、詳細な修正点の公表はためらわれた。また、他の作業に忙殺されたこともあり公表せずじまいになっていた。
その後、約6年を経てHashDoSという攻撃手法は多くのエンジニアに認知されていること。また、卜部がHashアルゴリズムを修正したRubyバージョンはすでにEOL(サポート終了)となっており、新しいRubyバージョンではさらに本格的なアルゴリズム変更が入っていることから、公開しても問題ないと判断した。
同時に、当時の卜部が何を考えどう修正したかを記すことに歴史的意義が出てきたと考え、筆を執った。以下より時系列ごとに、RubyがどのようにしてHashDoS対応をしてきたのか。その道のりを振り返りたい。
前史:すでに内包されていたリスク
1999年ごろ
このアタックに最初に気づいたのは、イリノイ大学シカゴ校の数学および暗号学教授であるダニエル・ジュリアス・バーンスタインだと言われている。彼が開発したDNSサーバ用ソフトウェア「djbdns」のソースコードには、そのようなアタックを懸念するコメントが含まれているという。
しかし、djbdnsが採ったのは「アタックっぽい変なデータはキャッシュしない」という対策であった。これは、DNSキャッシュサーバの実装としては妥当であるが、他のツールや言語などでは、この対策を採ることは必ずしも可能ではない。
2003年ごろ
HashDoSの概要や危険性について記されたオリジナル論文「Denial of Service via Algorithmic Complexity Attacks」が発表される。
この時点ではまだ具体的な攻撃方法にまでは言及されておらず「こういう攻撃が理論上可能だよね」といったレベルの指摘にとどまっている。が、論文内でPerlは名指しで危険性を指摘されていたため、早急に修正が入りPerl バージョン5.8.1がリリースされた。
2005年7月9日
どういった経緯か不明ながら、Rubyコミッターの田中哲(@tanaka_akr)さんがPerlの変更に気づく。
2008年6月20日
さらに、どういった経緯か不明ながら、田中さんがバージョン1.9のRubyにハッシュ関数のランダム化を入れる。
2009年1月21日
手元のメモによるとこの日、卜部がRuby Bug #929の追跡中にバージョン1.9における田中さんの変更に気づく。しかし、そのままではバージョン1.8にすんなり持ってこれないので、バックポートを断念した。ハッシュ関数の初期化で乱数が必要になるということは、それ以前に srand
していないといけないので、初期化順序に依存関係がある。当時そこをきちんと整理できていなかった。
また、正直なところ、まだ攻撃が世間にほとんど認知されていない状態で、バックポートに大きな労力をかける気にはなれなかった。ちょっと試してバックポートできたのであれば、入れていたと思う。が、実際は簡単に適応できず断念した。後悔先に立たず。
この時点でのRuby 1.8系のハッシュ関数はだいたいこんな形だった。
int hash(str, len) char *str; long len; { int key = 0; while (len--) key = key * 65599 + *p++; return key + (key >> 5); }
このハッシュ関数は当時としては一般的な、他のプロジェクトでもよく使われていたアルゴリズムであり、オリジナリティはあまりない。
報告から初期対応
2011年11月1日
オープンソースのセキュリティ問題に対処する機関「oCERT」から、Rubyセキュリティチームに連絡が入る。この時点での連絡内容は以下の通りだ。
- oCERTはすでにPerl バージョン5.8.1における修正を把握していた
- oCERTはすでにRubyバージョン1.9系に田中さんの変更が入っていることを知っていた
- 「具体的な攻撃が存在する」とは共有されたが、詳細な攻撃手段は教えてくれなかった
- 12月27日まで秘密にしてね、というお願い入り
卜部としては、この時点ではまだどのような攻撃手段なのかよく分かっていなかったが、ともかく重い腰をあげて田中さんのパッチを解読しはじめた。oCERTとの連絡は他のメンバーにお任せ。
2011年11月2日
oCERT経由で、報告者からハッシュ値が衝突するような文字列が200万個ほど送られてきた。Rubyセキュリティチーム一同、実際に手元のPCが固まるのを見て頭を抱える。
2011年11月6日
卜部、とりあえずパッチを作成するも全然ダメ。急いで田中さんの変更を当てようとしたがどうも上手くいかないので、変更を当てるのではなくtrunkのソースコードをじっくり読んで、どのような意図でコードが書かれているのか理解しないとダメということに考えを改める。
2011年11月22日
だいぶ悩んだ結果、ついにハッシュ関数のランダム化が成る。しかしパッチは結構大きく、以下のようなものになった。
ChangeLog | 17 ++++++++++ ext/openssl/extconf.rb | 3 + ext/openssl/ossl_ssl.c | 3 + inits.c | 2 + random.c | 74 ++++++++++++++++++++++++++++++++++++----------- string.c | 17 ++++++++++ test/ruby/test_string.rb | 13 ++++++++ 7 files changed, 112 insertions(+), 17 deletions(-)
この時点でのハッシュ関数はだいたいこう。
static unsigned hash_seed; /* <- hash_seedは乱数、起動時に初期化 */ int hash(str, len) char *str; long len; { int key = 0; while (len--) key = key * 65599 + *p++; key = key + (key >> 5); return key ^ hash_seed; /* <- new! */ }
卜部の作ったパッチはプロセス起動時に初期化した乱数でハッシュ値を撹乱するので、攻撃者が予測することが困難になる。前バージョンのコードと1行しか変わってないじゃん、と思うかもしれないが、この変数を初期化する部分がむずいのよ。
後期対応:乱数をrotate shiftし、ハッシュ値の衝突を回避する
2011年11月26日
風邪をひいて寝込みつつ病床で気づいたのだが、ハッシュ関数がランダムになっただけでは、報告者の報告した文字列がやっぱり衝突してしまう。つまり修正としては不完全なのだ。
最初のハッシュ関数で文字列Aと文字列Bが同じハッシュ値になるのであれば、前述のバージョンのハッシュ関数でhash_seed
をどんなに変えても同じハッシュ値になってしまうのは当然だ(6年後のいま考えりゃ当然だが、当時は本気で気づいていなかった)。
2011年11月28日
ただランダムにするだけじゃだめだ、というわけで考え直し。乱数をrotate shiftしながらハッシュ値に練り込んでいくのはどうだろうとチームに提案。チームメンバーのみんなの意見も参考にしつつ、このころのハッシュ関数はこんな感じになった。
int hash(str, len) char *str; long len; { unsigned long key = hash_seed; /* <- new! */ #define width (sizeof(key) * CHAR_BIT) while (len--) { int x = *p % width; key ^= (hash_seed << x) | (hash_seed >> (width - x)); /* <- new! */ key = key * 65599 + *p++; } return key + (key>>5); }
コードのループ内に追加された行は、hash_seed
をrotate shiftしている。シフト幅は入力文字から得ているので、ループの中で毎回変わっていく。このようにすることで、とあるhash_seed
値の元でハッシュ値が一致した文字列Aと文字列Bがあったとしても、hash_seed
が入力文字によって変更されることで、衝突しなくなる。
2011年11月30日
セキュリティチームの他のメンバーが突如「Ruby 1.8.7 程度のハッシュ関数なら、その逆計算はプログラミングコンテストレベルの問題です」と言って攻撃コードをメーリングリストに送ってくる……。すごい人が集まっているチームだ。
この攻撃コードより、“任意の”文字列とハッシュ値が衝突するような文字列を“無限に”作り出せる、第二原像攻撃が成立していることが明らかとなる。これは、ハッシュ関数に対する攻撃としてはいちばん強烈。
2011年12月5日
報告者やセキュリティチームに改訂した修正案を開示し意見を募る。すると「まあたぶん大丈夫なんだけど、ローテーションの幅が入力文字の下の方のビットだけに依存するのは好きじゃない」という趣旨の返答。32bitローテーションだと下5bitしか使いようがない、ということは逆から言えば文字の上3bitが無視されているわけで、これはたしかに良くない。
こうした意見を反映して、この頃のハッシュ関数はこう。
int hash(str, len) char *str; long len; { unsigned long key = hash_seed; #define width (sizeof(key) * CHAR_BIT) while (len--) { key = key * 65599 + *p++; key = (key << (width - 13)) | (key >> 13); /* <- new! */ } return key + (key>>5); }
ループの途中でrotate shiftする変数を hash_seed
から key
に変更。rotate shiftする幅13は(記憶が確かなら)提案された素数の中から選んだはず。8より大きいので、隣り合うbyte同士が同じbitに情報を重ねないようにする、という配慮だったと記憶している。
2011年12月9日
「*65599
ってさあ、だいたい16bit左シフトだよねえ(65599=216+63)。それを >>13
で13bit右に戻すのって、なんか処理が部分的にキャンセルされてるよね?」 という疑問が湧き上がり、以下のような実装はどうだろう、と再度チームに提案してみる。
int hash(str, len) char *str; long len; { unsigned long key = hash_seed; #define width (sizeof(key) * CHAR_BIT) while (len--) { key = key * 65599 + *p++; key ^= (key << 13) | (key >> (width - 13)); /* <- new! */ /* ↑ XOR */ } return key + (key>>5); }
しかしこれは報告者に即、ダメ出しされてしまう。rotしてXORとると全射じゃなくなるからデータが欠けていくのだそうだ。へぇー。
% cat b.c #include <stdio.h> #include <stdint.h> #include <stdlib.h> int main() { srand(time(NULL)); uint32_t a = rand(); int i; for (i = 0; i < 33; i++) { printf("%08x\n",a); a ^= (a<<13)|(a>>19); } return 0; } % gcc b.c % ./a.out 73aff684 8d7f78f1 6261495e 4b4a8512 1be8cc7b 0267af06 f7876f4a 1a6e31ba dc5972f7 f207897c 0328173c 01cf9759 f324b760 65c8a904 70e825bd 745f8ba0 852b852b f58ef58e 2b3f2b3f ce58ce58 d793d793 ad61ad61 98cd98cd 2bd42bd4 aeaeaeae 7b7b7b7b 14141414 96969696 44444444 cccccccc 55555555 ffffffff 00000000
2011年12月11日
ハッシュ関数の実装を固める。最終的なアルゴリズムは以下のようになった。
int hash(str, len) char *str; long len; { unsigned long key = hash_seed; #define width (sizeof(key) * CHAR_BIT) while (len--) { key = key * 65599 + *p++; key = (key << 13) | (key >> (width - 13)); } return key + (key>>5); }
途中で考えていたものとほぼ同じで、rotationの向きだけ違う。65599倍との相殺を避けようというせめてものあがきだが、今考えるとどのくらい効果があったか疑わしい。
脆弱性公開
2011年12月13日
JRubyはRubyバージョン1.8由来のハッシュを捨て、バージョン1.9と同じmurmurhashに移行する、という連絡をJRuby開発チームから受ける*2。
2011年12月14日
Rubiniusはそもそも全く違うFNV hashを使っているから今回の攻撃は対応不要だ、という連絡がRubinius開発チームからあった*3。つまり、メジャーなRuby処理系3種はHashDoSの影響を受けなくなったことになる。
2011年12月27日
oCERTよりセキュリティ・アドバイザリ公開。
oCERT-2011-003 multiple implementations denial-of-service via hash algorithm collision
追ってMRI(CRuby)、JRubyともにリリースを出す。
Denial of service attack was found for Ruby's Hash algorithm (CVE-2011-4815)
JRuby 1.6.5.1 Released — JRuby.org
その後、報告者によるプレゼンが行われ、Rubyセキュリティチームへの謝辞があり胸が熱くなる。
おわりに
Rubyのハッシュ関数に脆弱性が発見された当時、卜部が行ったようなシンプルな撹乱が有効だったのはラッキーだった。暗号学の素人でも、大学院レベルの計算機科学の基礎知識でなんとか立ち向かえたからだ。
2018年の現在では、HashDoSは攻撃側も防御側もよく研究されており、対策も進んでいる。今となっては、素人が浅く考えたような対策ではなく、きちんと専門家が考えたアルゴリズムを採用することが求められている状況だ。
本対応を通し、卜部が学んできたことを、最後に読者である若手エンジニアに伝えておきたい。
問題を正しく理解すること
HashDoSに限らず、サイバーセキュリティは研究の対象になっており、攻撃法などは論文として公表されることがままある。これらの論文を読んで意味を理解できないと対策は困難なので、セキュリティ領域に携わりたいと考えている方は、プログラミングの最低限の素養に加えて論文を読めるだけの計算機科学の基礎知識は身につけておいてほしい。
もちろん、修正するにあたってはコードのデバッグ能力も求められるが、これについては恐れる必要はないと思う。問題を正しく理解することの方が、問題を正しく修正するより何倍も難しいからだ。
他のメンバーとのコミュニケーションの重要性
本対応でラッキーだったもうひとつの点は、脆弱性公表以前に報告者らと修正案をやりとりし、レビューを受けられたことだ。前述のとおり、修正案は何度も書き直しており、最初の修正案では不十分だった。
不適切な修正を世の中に公表してしまうのは攻撃を誘発しているようなものなので、公表するならば同時に完璧な修正が求められる。だが、その作業をひとりで全部やるというのは大変なストレスである。報告者や他のメンバーとのコミュニケーションが(自分にしては奇跡的なことに)円滑に進んだのは本当に良かったと思う。
脆弱性と戦う言語開発者たちが、人々の暮らしを支えている
2018年の現在、卜部はRubyセキュリティーチームで脆弱性を修正する担当ではなくなった。だが、脆弱性の報告そのものはなくなったわけではない。統計は取っていないが、報告数はむしろ増加傾向にあると思う。その脆弱性を突かれてサービスが攻撃されれば、私たちの生活も脅かされてしまう。
今この瞬間も、言語やツールの脆弱性をハンドリングしている人々がいるからこそ、私たちは安心して暮らすことができる。普段はあまり表に出てこないし、出るにしてもこの記事のように数年後とかになるわけだが、世の中には人知れず活躍しているエンジニアたちがいる。彼らが素晴らしい仕事をしていることを、どうか知っておいてほしい。
執筆者プロフィール
卜部昌平({$image_2}@shyouhei)
企画:中薗昴(サムライト)
【2018年1月10日16時 修正】ご指摘により、記事の公開日付を修正しました。