メール1通でメールサーバーをクラッシュさせる方法
Joran Greef
2018年8月1日
0 分で読めます最近、Node.js向けの人気メールパーサーのうち5つが、単純なサービス拒否(DoS)脆弱性の影響を受けることが判明しました。この脆弱性は、一般的なメールサイズ制限(通常20 MB以下)をすり抜けるメールに、数百万個の空の添付ファイルを詰め込むことで悪用できます。脆弱なメールサーバーにそのメールを送信すると、添付ファイルの数が膨大なため、Node.jsのイベントループが数秒間フリーズします。添付ファイルごとに内部オブジェクトが作成されるため、メモリ使用量は2 GB以上に急増し、通常はメモリ不足によるクラッシュでサーバー全体が停止します。では、あなたのNode.jsサーバーはメールを解析しますか?どのメールパーサーを使っているか把握していますか?確認する前に、影響を受けるものを見てみましょう。
先に進む前に、お約束のXKCDをご覧ください。

サービス拒否攻撃がこんなに簡単にできるなんて、ありえませんよね?
この脆弱性は説明も悪用も簡単で、何千ものシステムに影響します。たとえばmailparserライブラリは、月間最大249,400回ダウンロードされ、Sendgridを含む214のプロジェクトで依存関係として使われています。もう一つの影響を受けるライブラリHarakaは、Craigslist、Fort Anti-Spam、ThreatWaveで使用されています。
修正は簡単な1行で済みます。Cloudflareが必要なわけではありません。ユーザーデータを検証すればよいのです。添付ファイル(テキスト部分を含む)の数を数え、たとえば1,000を超えたら対処すればよいでしょう。添付ファイルが1万個あるメールを見たのは、いつが最後ですか?10万個もの添付ファイルを送る必要があったことはありますか?100万個もの添付ファイルを解析し続けているなら……やりすぎだと分かりますよね!
ちょっと待ってください。なぜ見落としていたのでしょう?私たちが言うように修正が簡単なら、少なくとも5つの実装すべてがなぜ間違っていたのでしょうか?また、どうやって脆弱性を発見したのでしょう?そして、改善するために何ができるでしょうか?
メールパーサーを書いているところを想像してみてください……
読み(解釈し)なければならないRFCがいくつあるかは分かっています。RFCにできる限り準拠するために、どれだけのテストを書く必要があるかも分かっています。「まず動くようにし、それから速くする」というソフトウェア開発の教えも耳にしたことがあるでしょう。しかし、メールパーサーの開発は難しく、動くだけでも満足してしまいがちです。一度書いてみると、設計上のマルチパートオブジェクト1つがどれだけのメモリを割り当てるか、ざっと概算しないことがあるのも理解できるでしょう。計算量を分析する場合には、
メモリではなく、CPUだけを測定しがちです。一般的なメモリ使用量のベンチマークは行わないでしょう。SMTP環境を問わない設計にしてしまい、解析対象メールの90%がスパムであるにもかかわらず、パーサーに高速処理の経路を用意しません。パーサーに厳格なポリシーを設けすぎないようにします。ユーザーは、メールあたりの添付ファイル数に上限を設けられることを快く思わないかもしれません。すべてを解析し、必要ならSMTPトランザクション中にユーザーにメールを拒否させるほうがよいと考えます。結局のところ、あなたはメールサーバーの管理者ではありません。
メールサーバーを運用しているところを想像してみてください……
最初に行うことの一つは、メールサイズの上限を決めることでしょう。メールが大きいほど、ほかのサーバーに受け入れてもらえる可能性は低くなります。解析時間も妥当な範囲に収まるはずだと考え、メールサイズの上限を20MB程度に設定します。実績のある人気のメールパーサーを使うことにし、その計算量はメールサイズに対して線形、つまりO(N)だと信頼します。最大サイズの20MBのメールでCPU使用率を測り、サーバーは毎秒数千通を処理できると期待します。20MBのメールはどれも、おおむね同じ時間で処理できると思うでしょう。ユーザーが急増するとは考えないため、毎秒200通の同時処理には、わずか8GBのRAMで十分だと見積もります。サーバーが20MBのメールを10通同時に処理できるか賭けを持ちかけられても、自信を持って応じるでしょう。頭に浮かぶこともないのは、0バイトの添付ファイルです。空のファイルが何か悪さをしたことなんてあるでしょうか?
そして、こんなものを目にします。
脆弱性をどうやって発見したのか?
Ronomonはプライベートベータ版を提供しているメール関連のスタートアップです。開発を始めた頃、受信メールによってV8のガベージコレクターがNode.jsのイベントループを一度に数十秒間もブロックするという、厄介な問題に遭遇しました。何が起きているのかを調べながら負荷を下げるため、休暇中の2日間、数分おきに機能フラグを切り替え続けました。最終的にVyacheslav Egorovの助けを借りて、V8のCollectAllAvailableGarbage関数の内部処理をコメントアウトしました。この関数は、数GBにもなる巨大なヒープを、任意の7回分も喜んで回収していたのです。振り返ると、これは非常に良い学びになりました。ヒープ上でのオブジェクト割り当てや、イベントループをブロックすることには、極めて慎重になりました。
昨年の初め、新しいメールパーサーの開発に着手しました。これは後に@ronomon/mimeとしてオープンソース化されています。目標は、以前のパーサーより10Ã高速で、割り当てを最小限に抑え、生のバッファーを処理し、RFCに準拠し、ファズテストを含むテストカバレッジを100%にすることでした。実現は困難で、バッファーから文字列への変換をなくすことや、78文字で折り返されたBase64による分岐予測ミスを減らすことなどに取り組みました。
開発を進める中で、メールサーバーではなくメールパーサーでポリシーを決めたほうがよい場合もあり、その逆もあると学びました。たとえば、明らかに悪意のある、破損した、または途中で切れたBase64、Quoted-Printable、文字エンコーディングの拒否、重要なヘッダーの重複の拒否、マルチパートの数の制限、誤検出されたマルチパート境界によるバックトラッキングの制限などです。
今年の初め、Node.jsのイベントループをブロックしないための優れたガイドを書いたJamie Davisに連絡しました。Jamieはイベントループ攻撃を調査しており、マルチパート攻撃に対して脆弱なメールパーサーがあるかもしれないと提案しました。テストしたNode.js向けメールパーサーがすべて、この仮説を裏付けたことには驚きました。
改善のためにできる10のこと
検討してほしいヒントとベストプラクティスを10個紹介します。
DoS攻撃はリソースの不足を悪用します。ハードウェアの特性を尊重し、機械のためにコードを書いていることを忘れないでください。託されたハードウェアリソースを大切にし、適切に管理しましょう。無駄遣いを避けてください。CPU使用量だけでなく、メモリ割り当てや、触れるあらゆるリソースについてもO(N)に抑えましょう。CPU、メモリ、ディスク、ネットワークの点でコードが効率的なら、リソース枯渇に対する脆弱性が低くなり、システムリソースすべてに妥当な上限を設定しやすくなります。
設計の初期段階から、すべてのリソースについてざっと見積もりを行いましょう。設計の問題を早期に明らかにし、「実現不可能」な実装に取り組まずに済みます。パフォーマンスとセキュリティは、後から最適化したり付け足したりするものではありません。最初から設計に組み込む必要があります。モジュールが普及するまで待ってはいけません。
あらゆる側面でリソース使用量のバランスを取りましょう。スループットの目標を達成するのに十分なCPUがあっても、その前にメモリが枯渇しませんか?ここでも、リソースの各側面で使用量のバランスを保ち、設計上のボトルネックを避けるために、概算が必要です。
パフォーマンスの問題は、いつDoSにつながってもおかしくありません。特にイベントループを使っている場合はそうです。次にユーザーからパフォーマンスの問題が報告されたら、セキュリティ問題を未然に防ぐ機会と捉えましょう。
ユーザーデータは「量」だけでなく「数」も検証しましょう。実際、注意すべきなのは小さなものだったりします。小さなものは発生頻度が高く、増幅されて被害につながる余地も大きいからです。
何が現実的だと想定できるか、常に自問しましょう。現実的な閾値の10Ãを超えるものは許容しないでください。想定をコードに組み込みましょう。
モジュール境界の隙間に注意しましょう。「誰かがやってくれる」と決めつけてはいけません。ポリシー上の判断が抜け落ちないようにしましょう。依存関係をより深く理解する必要があるかもしれません。
明らかに不正なデータは有害なものとして扱いましょう。決して触れず、できるだけ早く排除してください。
悪意のあるユーザーなら何をするか、自問してください。コードをレビューするだけでなく、積極的に悪用を試みましょう。公開する前に、各モジュールで少なくとも3つの攻撃方法を考え、対策してください。目標を定めて見つけましょう。攻撃方法は必ず存在します。驚くことになるでしょう。
ファズテストは想像力に富んでいます。有効・無効な引数をランダムに幅広く生成する、シンプルなファズテストを自作しましょう。有効な引数では別の実装と戻り値を比較して正しさを確認し、無効な引数では例外を確認します。関数の引数の組み合わせを数百万通り試すファズテストは、Linusの法則を極端な形で実現し、ほんの数秒で何千もの目によるバグ発見能力をシミュレートします。
非公開および公開の脆弱性開示スケジュール
この脆弱性は2018年4月23日、影響を受けるモジュールの所有者に非公開で報告されました。90日間の公開開示期限の数日前に、所有者には理由を問わず公開を延期する機会が与えられました。また、依存プロジェクトの中でも特に利用の活発なものについては、(GitHubで連絡先が確認できた場合)担当者に連絡し、2018年6月25日の公開開示に備えました。
公開開示への協力、このブログ記事の提案、追加調査を行ってくれたSnykのKaren Yavine、Simon Maple、Danny Granderに感謝します。特に、迅速に対応してくれたHarakaのMatt Sergeantにも感謝します。
haraka
https://snyk.io/vuln/npm:haraka:20180625
mailparser
(全バージョン)
https://snyk.io/vuln/npm:mailparser:20180625
emailjs-mime-parser
(全バージョン)
https://snyk.io/vuln/npm:emailjs-mime-parser:20180625
mailsplit
https://snyk.io/vuln/npm:mailsplit:20180625
mailparser-mit
(全バージョン)
https://snyk.io/vuln/npm:mailparser-mit:20180625
CTFを始めよう
オンデマンドのバーチャル入門ワークショップで、CTFチャレンジの解き方を学びましょう。
