ぼちぼち日記

おそらくプロトコルネタを書いていることが多いんじゃないかと思います。

OpenSSLの脆弱性(CVE-2015-1793)によるAltチェーン証明書偽造の仕組み

TL;DR やっぱり書いていたら長文になってしまいました。あまりちゃんと推敲する気力がないので、変な文章になっているかもしれません。ご了承いただける方のみお読みください。

1. はじめに

昨晩未明にOpenSSL-1.0.2d, 1.0.1pがリリースされました。事前に予告されていた通り深刻度高の脆弱性CVE-2015-1793が修正されています。Advisoryを見ると、この脆弱性がiojs/Nodeに影響があるということが判明したので直ちにiojs/Nodeのアップデートを行い、今朝未明に無事脆弱性対応版をリリースしました。

今回が初めてではありませんが、深夜に日欧米のエンジニアがgithub上で互いに連携しながら速やかにセキュリティ対策のリリース作業を行うことは何回やってもなかなかしびれる経験です。時差もありなかなか体力的には辛いものがありますが、世界の超一流のエンジニアと共同でリアルタイムにプロジェクトが進めることができる環境はエンジニア冥利に尽きます。

さて今回の脆弱性Alternative chains certificate forgery は、日本語に訳すと「代替えチェーン証明書の偽造」になるんでしょうか? この Alternative chains certificate の機能(以下 Alt Cert Chainと書きます)は、実はなかなか自分と因縁めいた関係があります。一番最初は、昨年末の「Node-v0.10.34がはまったクロスルート証明書とOpenSSLの落とし穴」の出来事からでした(もしまだ読んでいない方は是非)。

その時は OpenSSLに修正が入らず、Node側で1024bitの証明書を復活対応して issue を回避しました。その後 openssl の master(1.1.0系) に alt cert chain の機能が実装されました。その時に1.0.2へのバックポートするかどうか聞いたのですが、機能変更になるのでバックポートはしないという返事をもらったため、iojsでは独自にopensslにパッチをあてて運用してました。

その後やっぱり1024bitの証明書廃止の流れに負けてか、OpenSSLプロジェクトが1.0.2/1.0.1系へalt cert chainのバックポートが行われました。もう独自パッチをあてる必要がなくなるので喜んだのですが、よく見るとなんかいらないものまでバックポートされている。そこで問い合わせたところ間違ってパッチを入れ込んだということで 'Revert "Fix verify algorithm." 'の修正が入りました。このおかげでOpenSSLのコミットログにわざわざクレジットを入れてもらい、とても嬉しかったです。

まずは、今回の修正コミット "Fix alternate chains certificate forgery issue" を見てみましょう。

diff --git a/crypto/x509/x509_vfy.c b/crypto/x509/x509_vfy.c
index 8ce41f9..33896fb 100644
--- a/crypto/x509/x509_vfy.c
+++ b/crypto/x509/x509_vfy.c
@@ -389,8 +389,8 @@ int X509_verify_cert(X509_STORE_CTX *ctx)
                         xtmp = sk_X509_pop(ctx->chain);
                         X509_free(xtmp);
                         num--;
-                        ctx->last_untrusted--;
                     }
+                    ctx->last_untrusted = sk_X509_num(ctx->chain);
                     retry = 1;
                     break;
                 }

いやはや、わずか2行です。得てしてこういうものかもしれません。これまでこのパッチ部分は結構読み込んでいたにも関わらず、今回のは全く見つけることができませんでした。まぁ無念です。バグを探し出したのは Google BoringSSL の agl さんらのチーム。やっぱ流石です。

そういう反省を踏まえつつ、今回の Alt cert chain の脆弱性がどういう理由で行ったのか、少し解説してみたいと思います。

2. 証明書検証のキホン

まずは、一般的にTLS接続で証明書がどう検証されているかの説明です(図1)。

クライアントにはあらかじめルート証明書等自分が信頼する証明書がOSなりアプリなりにインストールされています。クライアントがTLSサーバに接続すると初期のハンドシェイクでサーバから、サーバ証明書や中間証明書が送信されてきます。証明書には自分自身を表す Subject と発行者を表す Issuer が記載されており、クライアントは、接続するTLSサーバ証明書から Issuerをたどっていき、最終的にルート証明書までのチェーンが作られます。

そこで、それぞれの署名検証や証明書フィールドのチェックを経て、最終的に正当なサーバ証明書であることを判断します。
この証明書チェーンの正当性の検証は、TLS接続の安全性を確保する根本的な仕組みです。ここにほころびがあるともうダメです。そのため今回の脆弱性は、深刻度高にカテゴライズされました。

また、ここで出てくるルート証明書や中間証明書は誰でも発行できるものではありません。ポリシー的な制限もありますが、証明書の X509v3 Basic Constraints が CA:TRUE になっている必要があります。

今回の脆弱性は、CA:FALSEとなっている通常中間証明書として使えない証明書を、中間証明書と偽造し、正当な証明書チェーンとして検証をパスさせるものです。

例えば、自分のサーバ証明書をCAとして使って勝手にいろんなサーバ証明書を発行して利用したとしても、なんの問題なく使えてしまうということです。これはTLS通信の信頼性の根本を揺るがす問題です。

3. クロス証明書とは

今回問題となった Alt Cert Chain は、クロス証明書と一緒に使われる機能です。クロス証明書について簡単に触れます。

以前はルート証明書は1024bitsのが主流でしたが、計算機資源の発達によりもはや1024bitでは安全でなくなってきました。そこで本格的に危なくなる前に2048bitsのルート証明書へ更新が行われることとなりました。ただ古い端末では2048bitsの証明書を扱うことができず、クロス証明書をいれることによって互換性を持たせながら運用していくことが行われています(図2)。

Alt Cert Chainとは、このクロス証明書が使われている場合に古いルート証明書へのパスが作れなくなった時にもう一方の証明書チェーンを作って正当性の検証を行う機能を指します(図3)。

OpenSSLの場合、最初にサーバから送られた証明書リストを元にチェーンを作成するため、どうしても左側の長い方が最初に検証されることになります。

4. OpenSSLによる署名検証と Alt Cert Chain の作成のやり方

OpenSSLは、どうやって署名検証や Alt Cert Chainを作っているのでしょうか?

細かい部分を省くと図4の通り単純なスタック構造に入れ込み、サーバから送られて来た証明書を untrusted なものとして色分けしています。

図の場合では、サーバから送られたuntrusted な証明書は8つ、クライアントに保存されている最後のルート証明書が trusted で1つ、合計9つのスタックによる Cert Chain の出来上がりです。

Alt Cert Chainは、サーバから送られた一旦このスタックを検証してから作成します。ルート証明書までのパスが検証できないので上から順番に保存している trusted の証明書の中から該当するものがないか探しに行きます(図5)。

図の場合は、中間証明書Dのところでルート証明書Eが見つかりました。そこで中間証明書Dより上の部分を捨て去って Alt Cert Chainを作成します(図6)。

この場合、untrusted certの数を再計算するのですが、4つ取り除いたので 8-4=4 で4の untrusted とルートの合計5つの Alt Cert Chainスタックの完成です。これで署名検証が成功すれば正当性が無事保証されます。この検証を行う場合、untrusted で番号2以上のものは中間証明書であるため CA:TRUE であるかのチェックが行われます。

実は、このuntrustedの証明書を求める引き算にバグがあったのです。

5.CVE-2015-1793による証明書偽造のやり方

CVE-2015-1793で問題となった証明書チェーンを図7に示します。

これまでと違うのは、最初のチェーンで trustedな中間証明書Dが存在すること。そして中間証明書BのCA:FALSEになっているところです。このチェーンは、中間証明書Dの issuerのルート証明書がないので検証は失敗します。そこで Alt Cert Chainを探しに行きます。

中間証明書Bのところで Alt Cert Chain ができるので、再作成してみると図8の様になります。

証明書を2つスタックから捨てたので untrusted の数は、3-2=1 です。おっと、でもサーバから送られた untrusted証明書はAとBの2つです。AからCまでの証明書チェーンの検証は正当なため成功します。しかし untrusted な証明書の数が1であるため、証明書Bに対して CA:TRUE のチェックが行われません。なので本来中間証明書として使うことができない証明書Bを中間証明書と偽造することに成功しているわけです。

どうしてこういうことが起きたのか?

それは untrusted の計算方法にありました。

捨てた証明書2つですが証明書Dは trusted なものです。 trustedを捨てたのに untrusted の数を引き算するため数が合わなくなる、そういうバグに起因した脆弱性でした。最初の方に記載したパッチを見てもらえればわかりますが、untrusted の decrement をやめて、untrusted はルート証明書Cがのっかる前のスタック数(=2)を代入するよう変更しています。こうすれば最終的に untrusted の数のつじつまが合います。

6. 実際に CVE-2015-1793を試す。

実際にためしてみましょう。https://github.com/openssl/openssl/tree/master/test/certs脆弱性を試験する一連の証明書があります。それを流用してみます。

//
// Test for CVE-2015-1793 (Alternate Chains Certificate Forgery)
//
// RootCA(missing)
//   |
// interCA
//   |
// subinterCA       subinterCA (self-signed)
//   |                   |
// leaf(CA:false)----------------
//   |
//  bad(CA:false)

var tls = require('tls');
var fs = require('fs');
var bad = fs.readFileSync('./bad.pem');
var bad_key = fs.readFileSync('./bad.key');
var interCA = fs.readFileSync('./interCA.pem');
var subinterCA = fs.readFileSync('./subinterCA.pem');
var subinterCA_ss = fs.readFileSync('./subinterCA-ss.pem');
var leaf = fs.readFileSync('./leaf.pem');

var opts = {
  cert: bad,
  key: bad_key,
  ca: [leaf, subinterCA]
};
var server = tls.createServer(opts);
server.listen(8443, function() {
  var opts = {
    host: 'bad',
    port: 8443,
    ca: [interCA, subinterCA_ss]
  };
  var client = tls.connect(opts, function() {
    console.log('connected');
    client.end();
    server.close();
  });
});

脆弱性のある iojs-2.3.1では、

ohtsu@ubuntu:~/tmp/CVE-2015-1793$ ~/tmp/oldiojs/iojs-v2.3.1/iojs alt-cert-test.js
connected

途中に CA:FALSEの中間証明書が挟まっているのに正常に接続できてしまってます。

脆弱性対応版では、

ohtsu@ubuntu:~/tmp/CVE-2015-1793$ ~/github/io.js/iojs alt-cert-test.js
events.js:141
      throw er; // Unhandled 'error' event
            ^
Error: unsupported certificate purpose
    at Error (native)
    at TLSSocket.<anonymous> (_tls_wrap.js:989:38)
    at emitNone (events.js:67:13)
    at TLSSocket.emit (events.js:166:7)
    at TLSSocket._finishInit (_tls_wrap.js:566:8)

おー、ちゃんとチェックされています。

実際にこの脆弱性を突く証明書の構成が現状可能かどうかまでは調べていませんが、TLS接続の根本にかかわる証明書検証をバイパスする穴は本当に危険です。ホントちょっとしたバグですが、セキュリティに関わる部分は本当に致命的な欠陥につながるなと改めて思いました。