ぼちぼち日記

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

FLoCとはなにか

1. はじめに

GoogleChrome/89よりトライアルを開始しているFLoC (Federated Learning of Cohorts)技術に対して、現在多くの批判が集まっています。 批判の内容は様々な観点からのものが多いですが、以前より Privacy Sandbox に対して否定的な見解を示してきたEFFの批判「Google Is Testing Its Controversial New Ad Targeting Tech in Millions of Browsers. Here’s What We Know.」が一番まとまっているものだと思います。

これまで Privacy Sandbox 技術に関わってきた身としては、各種提案の中でFLoCは特にユーザへの注意が最も必要なものだと思っていました。しかし、これまでのド直球なGoogleの進め方によって、FLoCのトライアル開始アナウンスから即炎上という流れに結果的になってしまったことは非常に残念で、なかなか複雑な気持ちで見守るしかありませんでした。

このエントリーを書くにあたり、各種批判を否定するつもりは全くありませんし、このブログを読んで頂いた方にその考え直してもらいたいとも思いません。いくつかの批判を読むと自分でも納得する指摘もあります。

しかし先日、個人的に FLoC Simulator を作成して、いくつかFLoCの不明点をGoogle側に確認し、そのシミュレーションを行いました。「FLoC Simulation and Algorithm #95」 その際、FLoCを実際に自分の手で計算してみると「なるほどなぁ、こんな仕組みだったのか」と関心してしまうことが多かったのも事実です。

そこで、今騒がれている FLoC とはいったいどういう技術なのか? どうやってidを生成しているのか? その中身を純粋に広く知ってもらうのもよいのではないかと考え、自分の備忘録として書いてみたいと思います。

結局、長いエントリーになっちゃいました。長文嫌いな方は、読むのを避けてください。

2. Disclaimer

繰り返しになりますが、このエントリーはFLoCの技術的な仕組みについてだけ書いたものです。 技術以外の部分、例えばFLoCに関する倫理的・法的な問題などについては全く触れるつもりはありませんので、ご了承ください。

3. Privacy Sandboxとは

Googleが提唱しているPrivacy Sandboxは、大きく以下の2つを機能を実現することを指します。

  • ブラウザフィンガープリントやキャッシュ探索などによる隠れた(covert) tracking の防止
  • 3rd party cookie の廃止に伴う代替技術群の実現

その目的は、ユーザプライバシーに(一定の)配慮をしながらWeb上の広告エコシステムを(ある程度)維持していくことである、と私は理解しています。

現在のChrome/90で Privacy Sandbox の Origin Trial (OT) を開始しているのは、Trust Token API、 Attribution Reporting API (旧 Conversion Measurement API)、 First Party Set、 FLoC の4つになります。

OTとは、Chrome に導入された先行試験実装に対して token をサイトに仕込み、期限限定で試験機能の評価・検証を行う仕組みです。昔、先行試験機能はベンダープレフィックスなどを使ってリリースされていましたが、いつの間にか試験機能のAPIが広く使われて固定化されてしまう事例が発生しました。OTはその問題(API Burning)を避けるために開発されました。

Privacy Sandbox で提唱されている新機能の多くは、W3CのWICG(Web Platform Incubation Community Group)上で仕様ドラフトが議論されます。ChromeではこのOTを通じて Privacy Sandbox のフィールド試験が進められ、その効果や仕様の見直しなどが行われます。WICGで議論・検証された仕様ドラフトが十分評価されれば、晴れて将来的に標準化への道が拓ける見込みになります。

他のPrivacy Sandboxの各機能を説明すると、それぞれブログが数本書けてしまうほどなので、ここではFLoCだけに限定します。

Privacy Sandboxの技術の中で特に広告技術に関連した提案には、鳥にちなんだコード名が付けられます。FLoCは、英語で「群れ」を表す「flock」に掛けていると言われています。

現在のOTは、 FLoC の頭文字になっている Federated Learning(連合学習) を利用していませんので注意してください。当初Googleは連合学習を利用したアルゴリズムを検討したけれども、結局今回のOTでは連合学習を使わない方法になり、しかし名前を変えずにそのままにしてあるといった経緯のようです。

以下、FLoCの説明については、2021年5月時点でOTを行っている "chrome.2.1" のアルゴリズムバージョンをベースに話を進めます。 現在のFLoC OTは、アルゴリズムの効果測定や評価検証も大きな目的の一つですので、将来的にFLoCアルゴリズムが大きく見直される可能性が高い、ということを前提にエントリーを読んでください。

4. FLoCとは

Googleを始めとしてWeb上で提供されている広告は、一般的に大きく以下の3つのカテゴリーに分かれてます(自分はadtechの専門家じゃなく、世の中にこの3種類しかないとはっきり断言できないので、他にもあるかもしれません)。

  1. コンテンツ連動型広告(Contextual Advertising)
  2. ユーザーの興味/関心に基づく広告(Interest-Based Advertising)
  3. 追従型広告(Remarketing/Retargeting)

FLoCは、2の「ユーザーの興味/関心に基づく広告(オーディエンス ターゲティング)」に用いられる技術です。FLoC によって一定期間内ユーザの閲覧履歴、主に広告表示されているサイトドメイン(eTLD+1)の履歴が、数千人単位でグループ化されます。

ただしFLoCだけで全てが実現できるわけではありません。FLoCは単純にユーザグループに割り振られた番号(Cohort Id)を出力するだけです。そのidと広告主やadtechベンダーが持つオーディエンスデータ(ユーザ属性・興味・関心・購買志向のデータ)を組み合わせることによって、初めてFLoCを使ったオーディエンス ターゲティングが可能になります。

実はこのオーディエンス ターゲティングは、現在でも 3p cookie を最大限に利用して実現できているものです。

3p cookie はユーザ行動を第3者に丸見えの状態にし、これまでそのデータを活用してオーディエンスデータの収集・分析が行われていました。世間からの要請もあり Googleは 3p cookieを廃止し、ダダ漏れ状態であるユーザの閲覧履歴を収集できないようにすることを決めました。しかしその代わりに、ユーザの閲覧行動をブラウザ内に閉じた処理で数値化し、それを数千人単位にまとめるFLoCを開発しました。これで現在よりもユーザプライバシーが配慮された、かつこれまでと同じぐらいの効果を持つオー ディエンス ターゲティングが実現できるであろうとの考えです。「今までよりマシな仕組みを作ったのになんでこんなに非難されなきゃいけないの?」といった愚痴が聞こえてきそうです。

5. FLoC Simulator

今までよりマシで効果も高い、と言われても本当にそうなのか。技術的にその仕組をちゃんと知っておきたいものです。そこでFLoCの仕組みを理解し、効果を評価できるよう Chrome のFLoCアルゴリズムの実装部分をそのままGoに移植した FLoC Simulator を作成しました。

FLoC Simulator は、7ドメイン以上のホストリストをJSON形式で用意すると(host_list.json)、コマンドラインで Cohort Id を出力してくれるものです。

例えばこんなドメインリストでは、以下のような Cohort Idが計算されます。

$ cat host_list.json
[
  "www.nikkei.com",
  "jovi0608.hatenablog.com",
  "www.nikkansports.com",
  "www.yahoo.co.jp",
  "www.sponichi.co.jp",
  "www.cnn.co.jp",
  "floc.glitch.me",
  "html5.ohtsu.org"
]

$ ./floc_sample host_list.json
domain_list: [nikkei.com hatenablog.com nikkansports.com yahoo.co.jp sponichi.co.jp cnn.co.jp floc.glitch.me ohtsu.org]
sim_hash: 779363756518407
cohortId: 21454

上記シミュレーションでは、21454 の Cohort Id が計算されています。検証・比較のため、下図の同じドメイン履歴を持つ Chrome でも同じ 21454 が出力されているのがわかります。 f:id:jovi0608:20210506151511p:plain f:id:jovi0608:20210506151449p:plain

ソースを見ていただければ、わずか数キロバイトの処理Google側のサーバから配布されるファイルを組み合わせてCohort Idの計算が行われていることがわかります。関数や変数名はできるだけChromeの実装に合わせています。

6. FLoCの仕組み

FLoCアルゴリズムのコア部分は、データマイニングなどの分野で利用されている Locality Sensitive Hash(LSH: 局所性鋭敏型ハッシュ) という技術を使います。 これは似たような文章を探したり、同じ特徴を持つ画像を探したり、といった最近傍探索(Nearest neighbor search) で利用される技術です。FLoCではLSHの一種であるSimHashが採用されています。 SimHash は 、16年ほど前にGoogle検索で(ほぼ)重複したWebコンテンツ(near-duplicated web contents)を検知するのに使われていたアルゴリズムです。

6.1. だいたいのFLoC計算の流れ

Chrome内で行われているFLoCの処理は、おおよそ以下の4つの部分に別れています。

  1. navigation時にFLoC対象のページ履歴にフラグを付ける
  2. 7日間に一回、履歴からSimHashを計算し、Chrome SyncでGoogle側に送る
  3. (FLoCバージョンで1回実施) Google側でユーザのSimHashをまとめ上げてクラスター化する。ブロックするCohort Idを特定し、クラスターファイルを生成し、全Chromeユーザーへ配布する
  4. サイトのFLoC APIの実行に伴い、SimHashとクラスターデータから Cohort Id を計算し、出力する

f:id:jovi0608:20210506151227p:plain

このうち 3. は、事前にGoogle側で処理が行われます。OT期間中に再度3の一部処理が行われ、FLoCバージョンが更新される予定であると github issue で回答がありましたが、現在のところまだ実施されていません。

6.2. FLoCアルゴリズムの詳細

各ステップをもう少し細かく見ていきましょう。

6.2.1. navigation時にFLoC対象のページ履歴にフラグを付ける

ユーザが navigation したページが、以下の3つの条件が全て満たされる場合に FLoC の計算対象となります。

  1. navigation の IP がPublicルーティング可能であること
  2. メインドキュメントの permission policy がFLoCを許可していること
  3. 広告が表示されているページ、又は FLoC API (document.interestCohort()) が実行されているページであること

navigation 完了後ユーザ履歴に保存される際に、上記FLoCの対象ページかどうかのチェックが行われ、フラグが付けられることになります。

実は 1. のIP チェックは socket アドレスに対するものなので、「Private IPを持つ内部Proxy経由で外部のサイトを閲覧した場合、全部 FLoC の対象外になっちゃうんじゃない?」とgithubで聞いたら、「試してないけどそうなる」との回答でした。FLoCの利用を回避するハックとして使えるかもしれません。興味のある方がいらっしゃれば検証してください。

6.2.2. SimHashを計算する

ユーザ履歴が7ドメイン以上残っている場合、履歴中の ドメインデータ (= eTLD+1) から計算されます。SimHash計算は、7日毎 にスケジュールされています。

現在の FLoC OT のコアアルゴリズム、LSHの一種であるSimHashとはどういうものでしょうか?

Hashという名前が付いているように、SimHashは任意のサイズのデータを一定の小さなサイズに変換させる機能を持ちます。5年前に20億人を超えたと公表されたChromeユーザは、現在では世界で7割近くのシェアを持つと言われます。FLoC計算対象となるChromeユーザの履歴パターンは膨大なものになるでしょう。それを使えるようにするには、一定のサイズ以下に収めないといけません。そのためユーザ履歴のHash化は必須です。

しかし通常利用されるSHA256などの暗号学的ハッシュをユーザ履歴に適用すると、データサイズを減少させることはできるものの、ハッシュ後は複数の元データ間の関連性をうかがい知ることはできません(原像攻撃に対する防止)。

先に述べたLSHは、似ているデータを近いハッシュ値に変換させるのが特徴です。そこで FLoC の SimHash では、データを多次元ベクトルで表現し、データ間の近さを表す指標としてベクトルの角度を利用します。 SimHashのキモは、いくつかランダムなベクトルを用意し、ユーザデータとランダムベクトルとの角度の関連性からデータの類似性を導出し、同時により低次元のベクトルにサイズを減らす変換を行うことにあります。

あるユーザデータのベクトル(User)に対して1bitのSimHashを計算する場合、1つのランダムベクトル(rv)を用意して下図のような計算を行います。赤色のrv と User のベクトル角度θに注目し、cosθが正値又は0の場合は1,負値の場合は0のビットで表します。この場合2つのベクトル内積の値を計算すると正負が求められます。 f:id:jovi0608:20210506151434p:plain 赤色rvと垂直な平面(破線のrandom hyperplane)から見てみると、ユーザベクトルの先が赤色破線上部のエリアにいるとSimHash=1となり、下側にいると0になります。SimHash:1の複数ユーザデータ間で比べると、SimHashが1のデータは似ているもの、0のユーザデータは似ていないもの、と区別することができます。そしてユーザデータは類似する1bit値にハッシュ化されました。

1つのユーザデータを1bitのSimHashに変換するだけではわかりにくいので、2つのユーザデータ(UserA、 UserB)を使い、3bitのSimHashに変換してみます。

下図の様に、赤・緑・青の3つのランダムベクトル(rv1, rv2, rv3)を用意します。3種類のランダムベクトルとユーザベクトル(UserA, UserB)の角度でSimHash Bitを決めます。その結果、UserA の SimHash は 100、UserB の SimHash は 101 となることがわかります。。頭2bit分だけ同じの3bitハッシュ値に変換されたことになります。もしUserB の角度がもう少しrv3の random hyperplane を超えて User A に近ければ同じハッシュ値になっていたでしょう。 f:id:jovi0608:20210506151252p:plain ランダムベクトルが増えていけば、囲い込まれる部分が狭まり、複数のユーザデータの類似性をより精度高く比較することができます。FLoC では、50個のランダムベクターを使い、ユーザ履歴の情報を50bitのSimHashに変換させます。

6.2.3 ユーザ履歴のドメインデータをベクトル化し、SimHashを計算

では、ユーザ履歴のドメインデータをどうやってベクトル化するのか。インターネット上のドメイン(eTLD+1)の上限数を決めることはできません。そこでGoogleが開発した CityHash64 という非暗号学的ハッシュを使い、ドメイン名を64bitの数字に変換します。そうすると任意のドメイン名が、264個のどれかの数字にマッピングされます。そこでドメイン履歴を264次元のベクトルにして、該当するドメインドメイン名のCityHash64値をインデックスの要素値を1としたベクトルとして表します(one-hot domain encoding)。

example0.com ドメインの場合、CityHash64をかけると 3771499692761966109 になります。example0.com ドメインのユーザ履歴は、3771499692761966109番目の値を1にした264次元のベクトルに変換するわけです。

その結果、例えばユーザが [example0.com, example1.com, … , example99.com] のドメイン履歴を持っているとしたら、それぞれのドメイン名のCityHash64値番目のインデックスが1となるベクトル(0,...,1,...,1,..1,...,0)のような、100個の1を持つベクトルになります。

このユーザ履歴のドメインベクトルを50bitのSimHashを変換するには、50個のランダムベクトルを用意します。ユーザごとにランダムベクトルが変わってしまうのは困るので、要素の位置をSeedにして決定論的に決まる乱数ベクトルを生成します(コード中では randomGaussian 関数を指します)。こうすれば異なるブラウザ内でもユーザごとに変わらず、同じ乱数ベクトルを生成できます。

ここで生成したガウス分布の乱数ベクトルとユーザ履歴ドメインのベクトルとの内積を取ると、下の図の様に単純に乱数値を加算することと同じになり、加算後に正負を判定すると SimHash ビットを求めることができます。これを50個のランダムベクトルに対して同じ処理すれば、50bitのSimHashの計算が完了です。 f:id:jovi0608:20210506151412p:plain SimHashのコアアルゴリズムのコード部分を以下に示します。SimHashの計算自体、非常にスッキリしていることがわかるでしょう。

func simHashBits(input []uint64, output_dimention uint8) uint64 {
        var result uint64 = 0
        var d uint8;
        for d = 0; d < output_dimention; d++ {
                var acc float64 = 0;
                for _, pos := range input {
                // randomGaussian は2つの整数をSeedにしてガウス分布の乱数を生成する関数
                        acc += randomGaussian(uint64(d), pos)
                }
                if (acc > 0) {
                        result |= (1 << d)
                }
        }
        return result
}

func SimHashString(domain_list []string) uint64 {
        var input []uint64
        for _, domain := range domain_list {
                hash := CityHash64V103([]byte(domain))
                input = append(input, hash)
        }
        sim_hash := simHashBits(input, 50)
        return sim_hash
}

早速 floc_simulator で、example0〜98.comまで一緒で最後の100個目のドメイン履歴(example99.com と example100.com)が異なる場合のSimHashを計算してみます。

SimHash([example0.com, example1.com, ..., example98.com, example99.com]):  10011000111000001101111010001011010110010000010110 (672366295082006)
SimHash([example0.com, example1.com, ..., example98.com, example100.com]): 10011000111000001100111010001011010110010001010110 (672365221340246)

上記の通り、50bit中48bitが同じで2bit分が異なるSimHash値が計算できています。このデータを使えば、ユーザ閲覧ドメインの類似性が評価できます。

6.2.4. SimHashのクラスター化とセンシティブ情報のブロック

ブラウザー内でSimHashを計算できればFLoCは終わりではありません。 SimHashでユーザ履歴のドメインリストが近いユーザ集団を特定することはできますが、SimHashの値をそのまま利用すると以下の問題が発生します。

  • 同じSimHashを持つユーザが少ない場合、個人が特定される恐れがある。
  • センシティブなカテゴリーの割合が多いユーザ履歴では、センシティブな情報に偏ったCohort Idが生成されてしまい、本来他人に知られたくないセンシティブなユーザ情報が第3者にわかってしまう。

そのためFLoCではSimHashの値をそのまま利用することはせず、計算したユーザのSimHash値を一旦Google側に送り、いくつかまとめるクラスタ化処理を入れます。 Googleへのデータ送信は、ユーザ履歴やプロファイルなどをGoogleアカウントと一緒に同期する Chrome Sync の仕組みが利用されます。

Google側でのSimHashのクラスタ化では以下の2つの処理が行われます。

  1. ユーザから送られたSimHash値をソートし、2000人以上のユーザを含むようにSimHash群に分割する。SimHashの分割群に対して小さい順にIdを割り振りCohort Idとする。
  2. Cohort Id 内に含まれるドメインを洗い出し、センシティブなカテゴリーを含む割合が平均より10%以上大きければそのCohort Idに対して blocked のフラグを付ける。

ここではSimHashを集めてソートして分割しているだけなので、教師あり学習を全く実施していません。なので現在のFLoC OTは、連合学習(Federated Learning)を利用していないということになります。

センシティブなサイトのカテゴリーとして、Googleは以下の項目を定義・管理しています。もちろん具体的なサイトがどこなのかは非公表です。

  1. 禁止カテゴリ: アルコール、ギャンブル、カジノ、臨床試験の被験者募集、制限付き薬物に関するもの、13歳未満のユーザ
  2. 個人的な苦難: 健康、厳しい経済状況、人間関係、犯罪、虐待や心的外傷、マイナス思考の強制
  3. アイデンティティや信条: 性的指向、政治的思想、政治に関するコンテンツ、労働組合への加入状況、人種や民族、信仰、社会的に阻害された集団、トランスジェンダーの性別認識
  4. 性的な関心: 避妊、性的なコンテンツ
  5. 機会へのアクセス(米国、カナダ向け): 住居、求人、クレジット情報

クラスタ化した結果のデータは、Google側がバイナリーファイルにまとめてcomponent updator によって各ユーザに配布されています。

MacOSの場合、FLoCのクラスターファイルは、~/Library/Application Support/Google/Chrome/Floc/1.0.6/SortingLshClusters として保存され、クラスターデータが1バイト長でエンコードされています(blocked flag:1bit長とクラスタのSimHashサイズのビット数:7bit長)。

SimHashからクラスターデータを使ってカウントすると下図のように Cohort Id が計算できます。 f:id:jovi0608:20210506151313p:plain 現在 Cohort Id:2 がセンシティブカテゴリーを平均より10%以上の割合で含んでブロックされています。そこに当てはまった全てのユーザの Chort Idは、空の文字列が返されます。Cohort Id:2 以外にも全体の2.3%にあたる792個の Cohort Id がブロックされています。

SimHashの値に該当するスロットを見つければ Cohort Id の計算は完了です。5の例で計算した SimHash は 779363756518407 なのでちょうど21454番目のクラスターに入ります。例で示した通り Cohort Id は 21454 です。

クラスタ化の結果、Cohort Idの最大値は33871でした。各Cohort Idには最低2000人のユーザが含まれているので 33,871x2,000人=67,742,000人となり、最低でも6774万人以上のユーザ履歴データがGoogleで集計されていることがわかります。

クラスターデータが変わってしまうと過去のCohort Idとの互換性が損なわれてしまいます。今回のFLoCのOT中は、blocked の更新を行うことはするが、クラスタを変更を加えるようなアップデートは行わないと回答をもらっています。

7. まとめと今後

ということで、FLoCのCohort Id計算がどう行われているのか、技術的にかなり細かく説明しました。

example???.com というドメイン名を使った自分のシミュレーション結果では、1000ドメイン近くの履歴がないと多少の履歴の違いでも Cohort Id が異なってしまうという結果が出ました。一般のユーザが7日間に1000ドメインも閲覧することはなく、数十レベルだろうとのコメントをgithubでもらっています。もちろんハッシュをかけるドメイン名によって、シミュレーション結果はがらっと変わってしまいます。ドメイン数が少ないと少し履歴が違うだけで異なるCohort Idに計算されてしまうことが多いので、果たして現在のアルゴリズムでどこまで的確なオーディエンス ターゲティングができるのだろうか、と考えてしまいます。

また最近、ChromeのFLoCに関して、以下2つの処理が新しく入りました。

  • SimHash計算に weight が導入された。

現在は全てのドメインが weight:1 で計算されています。今後どんな重み付けがされるのか。その結果 Cohort Id のオーディエンスは改善されるのか?

  • FLoC対象とするページを API を利用しているページだけに限定するフラグが導入された。

もしかしたら批判を受けて、FLoC対象のページの条件をこれまで以上に制限する予定だろうか?

FLoCに対する批判はまだ止まりませんが、Googleも引き続きいろんな検討を進めていくのではないかと思っています。W3Cでの議論を注目したいです。

FLoC技術の参考情報

このエントリーを書くのに参考にした情報は、こちらにまとめています。

GnuTLSの脆弱性でTLS1.3の再接続を理解する(Challenge CVE-2020-13777)

(TLDR; めちゃくちゃ長くなったので、長文読むのが苦手な方は読まないようお願いします。)

1. はじめに

前回の「求む!TLS1.3の再接続を完全に理解した方(Challenge CVE-2020-13777)」 の記事にて、GnuTLSの脆弱性(CVE-2020-13777)のPoCを募集しました。 短い期間にも関わらず2名の方から応募を頂き、本当にありがとうございます。

また、応募しなかったけど課題に取り組んで頂いた方もいらしゃったようです。この課題を通じて、いろいろな方がTLS1.3仕様(RFC8446)に触れる機会を持っていただいたことを非常に嬉しく思います。

全くの初心者ではやはり課題が難しいとの意見もいただきました。今後はもう少し幅広い人に手をつけやすいよう工夫が必要であると感じていますが、はてさてどうしたらいいか、なかなか難しい。なんにせよ初めての試みでしたが、やってみてよかったと思っています。

2. 結果発表

応募を見させていただきました。結果、ペロトン(@prprhyt)さんが見事に解答されていると判断し、Vさんから(ブーストされた)3万円のアマゾン商品券と副賞として希望するラムダノート社の本1冊、私から1万円のアマゾン商品券を贈呈いたしました 。おめでとうございます!

応募内容を公開することを前提とした募集でしたので、この記事では応募解答についてコメントを付けながらTLS1.3の解説を進めます。

まずは課題の背景からです。

3. 課題の背景

今回のGnuTLSの脆弱性から課題を思いついたのは、前回のエントリーで述べた以下の理由です。

この脆弱性、実際にPoCを書いてみると非常に奥が深いです。TLS 1.3をちゃんと理解していないと無理です。

TLS 1.2 と TLS 1.3 でなぜ影響に違いがあるのか、そこはTLS 1.3の進化が見えるところです。またアナウンスには記述されていませんが、TLS 1.3の0-RTTを利用していた場合、この脆弱性によって大きな影響が新しく出てきます。

具体的にTLS1.2から1.3へどのような進化があったのかを見るには、その背景を理解しておくことが重要です。

そこで、今回の背景を理解するために必要な「Forward Secrecy(前方秘匿性)」と「TLS1.2におけるTicket方式の問題点」について説明します。

3.1. Forward Secrecy (前方秘匿性)

Forward Secrecy(前方秘匿性)は、TLSの機能を理解する上で非常に重要な概念です。ただ数年前私が初めてこの言葉に触れた時、何が「Forward」なのかすぐにわかりませんでした。

TLSを始めとした暗号通信は、通信している間だけセキュリティを確保できていればいいわけではありません。現在でもかなりのインターネット通信が傍受・保管されていると言われています。時間が経っても暗号通信した内容が外部に漏れないよう守られていることが必要です。

Forward Secrecyは、盗聴者が全部の通信データを保管しておき、数年後に暗号鍵が漏洩して通信データの内容がばれしてしまうリスクを防ぐ技術です。 「未来の安全」を確保するという意味で、時が進む「Forward」を指すと解釈して、やっと自分もこの言葉が腑に落ちました。日本語だと「通信後の秘匿性」という言葉にした方がわかりやすいんじゃないでしょうかね。

f:id:jovi0608:20200703085337p:plain

TLS1.2のRSA鍵交換は、クライアントで生成した暗号鍵(master secret)をサーバ証明書の公開鍵で暗号化し、サーバに送付します。この方法では、後日サーバ証明書秘密鍵が漏洩すると、TLSハンドシェイクデータから master secret を入手することが可能になってしまいます。Forward Secrecyが保てません。

そのため、現在ではTLS1.2でRSA鍵交換の利用は非推奨です。代わりにセッション確立時に一時的(Ephemeral)に暗号鍵を生成するECDHEの利用が推奨されています。

ECHDEでも楕円暗号曲線で利用する秘密鍵は生成されますが、TLSハンドシェイクが終了したらもう必要なくサーバで保管する必要はありません。なので後日漏洩して危殆化する可能性はありません。(実際はメモリ中に一時秘密鍵が残っていることもかもしれませんが)

3.2 TLS1.2におけるTicket方式の問題点

しかしTLS1.2も、ECDHEを使っていれば大丈夫、どんな時もForward Secrecyが保たれて安全というわけではありません。通常複数のサーバでTLSを運用する場合、再接続時にクライアントが別のサーバに振られてフルハンドシェイクしないよう、セッションIDやTLS Ticket方式が利用されることが多いです。

セッションID方式では、複数サーバ間でセッション情報を共有しておく仕組みが必要です。大規模になるとなかなか運用していくのが大変です。

その点 TLS Ticket方式は、セッション情報を暗号化してチケットにしてクライアントに渡しますので、大規模なセッション共有システムは必要ありません。再接続時に送られたチケットを復号化する暗号鍵だけをサーバ間で共有しておけばいいので楽です。

しかしTLS1.2での Ticket方式は、チケットを暗号化した鍵が漏洩すると Forward Secrecy がなくなるという致命的な問題を持っていました。

f:id:jovi0608:20200703085415p:plain

チケットの中には、再接続用に master secretなど入ったセッションデータがまるまる暗号化されて入っています。後日もしなんらかの理由でチケットを暗号した鍵が漏洩し攻撃者に渡るとしたら、master secret を使って昔のTLSの通信データの中身が丸わかりになってしまいます。

そのためTLS1.2でTicket方式を使う場合、チケットの暗号鍵をいかに安全に運用・管理していくのかが大きな課題となっています。

そのような課題を克服するため、TLS1.3では再接続の仕組みを大きく変更し、チケット利用でForward Secrecyがなくなる、といったTLS1.2におけるTicket方式の欠点の解消される仕組みを取り入れました。

こういった背景を知っていただいた上で、ペロトンさんの応募解答を見ていきましょう。

応募解答 と コメント

ペロトンさんの応募解答のオリジナルは、Challenge CVE-2020-13777(MITM, 0-RTT Application dataの復元)にあります。 ここでは一部抜粋しながらフォロー記事を書いていきますが、ぜひオリジナルも参照してください。また今回の課題のフォローブログも掲載予定と聞いております。楽しみにしましょう(判明後URLリンクを入れます)。すぐ公開されました。

atofaer.hatenablog.jp

フォロー記事を書くにあたり、オリジナルの応募原稿をどう損なわずにのがいいのか色々試行錯誤してしまい、このブログを公開するのに時間がかかってしまいました。結局、自分のコメントは赤字で (コメント) ... (コメント終) の中に書いています。

ペトロンさんの図は黒色実線、私が作成した図は赤色破線で囲って区別できるようにしています。少し見苦しいと思いますが、誰が書いた文章なのかを間違えないよう、注意してお読みください。

以下、ペトロンさんの応募解答+私のコメントになります。

Challenge CVE-2020-13777(MITM, 0-RTT Application dataの復元)

この解説文章・PoCの目的

(省略)

免責事項

(省略)

はじめに

(省略)

CVE-2020-13777について

(省略)

次のセクションでTLS1.3における影響について説明します。

1. TLS1.3でのMITM攻撃について

次の1つめの課題の解答をする章です。

  1. pcap中のTLS1.3 ClientHelloデータだけ使って、CVE-2020-13777によってTLS1.3のMITMが可能であることを証明してください。

(コメント)
この課題を考える時、「ClientHelloデータだけ」と書いていいかどうかすごく悩みました。実は不備があるからです。ペトロンさんから、今回の省略している部分(POCコード)の説明で、この条件の不備をちゃんと指摘されてました。

 このPoCの範囲外
 - CipherSuiteの推定
   - 簡単のため、攻撃者は1回目のCHLOのCipher Suitesのリストを見て先頭にあるTLS_AES_256_GCM_SHA384をサーバーが選択したと推定した仮定でハードコーディングしている

はい、CipherSuiteの推定が必要です。ClientHello だけでは、どのCipherSuiteを使った/使うのかわかりません。resumption PSKによる再接続では少なくとも前回の接続でのCipherSuiteのHASHがわかっていないと鍵の導出がで きないのです。本来ならServerHelloの情報も含めてと書くのが正確な課題の出し方です。

しかし、そもそもこんな「ClientHelloだけ」という不正確な条件をつけたのか。理由は、課題2を解いたことで課題1の証明するような応募を避けたかったことと、何をもって証明したとして見なせばいいのか、PSK認証の本質と私が考えていることについて述べてほしかったからです。後半の理由は後ほど詳細に書きます。
(コメント終)

PSKを使った認証

セッション再開におけるPSK(PreShared Key)はサーバーがクライアントを認証するための値です。 また、HandShakeやアプリケーションデータを暗号化/復号するための鍵のシードのようなものとしても利用されます。 PSKはexternal-PSKとresumption-PSKの二種類があり、PSKの共有方法によって分類されています。 external-PSKはTLSの帯域以外(つまり他の手段で)共有されるPSKです。 一方で、resumption-PSKは前回のセッションで用いたPSKです。セッション再開の認証に使うPSK候補が複数ある場合はサーバーからのNewSessionTicketの通知の際に次回のセッション再開と鍵の導出で使用するPSKと対応するPSK-IDが通知されます。クライアントはセッション再開時はPSK-IDに従って鍵の導出に使用するPSKを選択します。 今回はresumption-PSKを使った場合を例に説明します。以後は特に断りがない場合はPSKと書いてあるものはresumption-PSKと読み替えてください。

(コメント)
PSKについての説明です。内容は全く問題ありません。もう少し視野を上げてTLS1.3のハンドシェイクの種類で接続を分類すると下図のようになります。 f:id:jovi0608:20200703054834p:plain

TLS1.3には、大きくフルハンドシェイクとPSK接続の2種類のハンドシェイクが使われていると考えていいでしょう。正確には最初のハンドシェイクで鍵交換に失敗して2-RTTの接続してしまうハンドシェイク(Incorrect DHE Share)があります。しかし今回はわかりにくくなるので除きます。

フルハンドシェイクは、サーバ認証が必須です。通常はX.509証明書と秘密鍵を用いて認証を行います。クライアント認証も行えますがオプション扱いです(今回の図からは除いています)。

一方PSK接続の方は、応募解答に書いてある通り external PSK と resumption PSKの2つに別れます。external の方は「帯域外(out of bound)」での共有です。こういう意味での out of bound の和訳って困ってしまいますよね。私はそのままにしています。なお external PSKについては、既にプロトコル上の脆弱性が見つかっております。利用する場合には注意が必要であることをお忘れなく。

datatracker.ietf.org

resumption PSKの方は、応募解答に書いてあるとおり前回のハンドシェイクを基にPSKを共有する方式です。実はresumption PSKにも stateful と stateless の2種類あります。

statefull resumption PSKは、セッションIDをTicketに含め、セションデータはサーバ側で保持する方式です。クラスターを組んでいる場合は、複数サーバ間でセッション状態を共有しておく必要があります。TLS1.3ではセッションIDフィールドが廃止されましたが、こんなところに引っ越していたんです。

stateless resumption PSKは、セションデータを暗号化してTicketに含める方法です。今回のやり方になります。一旦クライアントにセッションデータを預けてしまうため、サーバクラスター間ではチケット内のセッションデータを復号するチケ ット鍵だけを共有しておけばいいわけです。これはTLS1.2の時代と変わりません。

少し前に知ったのですが、OpenSSL-1.1.1のTLSサーバでTLS1.3利用時にTicket無効化のオプションを設定しても、NewSessionTicketが送られてPSK接続になってしまいます。実はこの時、OpenSSLは stateful resumption PSK を使っていて、OpenSSLのメモリ内でセッシ ョンデータを保持して再接続を処理しています。本当にTicket利用を無効化したい場合は、送信するTicket数を明示的に0にするという設定をしないといけないです。

考えられるTLS1.3ハンドシェイクの形態を全部まとめると下表のように7種類になります。

f:id:jovi0608:20200703054829p:plain

ここでは細かく項目を説明しませんが、それぞれのハンドシェイク形態でやれること、セキュリティ状態が異なります。今回は3番目の statefull (* 1) stateless resumpition PSK が課題の対象となります。 (* 1 鹿野さん、間違いの指摘ありがとうございます。)
(コメント終)

セッションチケットとPSK

PSKはセッションチケットから導出可能です。これはセッションチケットの暗号化鍵が攻撃者に知られるとセッション再開の認証に必要な情報であるPSKを攻撃者が入手できることを意味します。 そして、これは攻撃者が正規のクライアントになりすますことができることを意味します。

順を追って説明します。CVE-2020-13777はサーバーが持つセッションチケットの暗号化鍵がall-0になる脆弱性です。 つまり、この脆弱性によって攻撃者はセッションチケットの暗号化鍵が0だと推測して、その内容を不正に復号することができます。 では、攻撃者がセッションチケットの内容を入手できると、どういった手順でPSKの導出をし認証のバイパスが可能になるのでしょうか。 それを明らかにするためにはまず、TLS1.3でセッションチケットの内容や認証の手順を理解する必要があります。 それでは正常時のセッションチケットを利用した認証を例に順を追って説明します。 次の図を見てください。 図中の登場人物は次のとおりです。

  • Alice: 正規のTLS1.3クライアント
  • Bob: 正規のTLS1.3サーバー

f:id:jovi0608:20200703054930p:plain f:id:jovi0608:20200703171211p:plain f:id:jovi0608:20200703054926p:plain

[図:セッション再開時のPSKによる認証]

図中の<>はセッションチケットの暗号化鍵で暗号化されたメッセージです。 図中の{}は[sender]handshake_traffic_secretで暗号化されたメッセージです。 図中の[]は[sender]application_traffic_secret_Nで暗号化されたメッセージです。

1つめの図はGnuTLSにおけるセッションチケットの書式を表しています。 そして、2つ目の図フルハンドシェイクの時の認証はフルハンドシェイクのシーケンスと認証部分の図です。図の通り、セッションチケットはサーバーからクライアントへNewSessionTicketによって通知されます。また、認証は証明書によって行われます。

(コメント)
惜しい、TLS1.3のフルハンドシェイク時の認証部分が違います。これを理解するため、サーバの秘密鍵を知らない攻撃者がTLS1.3でどこまで中間者攻撃ができるか思考実験をしてみましょう(下図)。

中間攻撃者は、平文でやり取りされるClientHello/ServerHelloを自由に書き換えることができます。それぞれの key_share拡張に入っている公開鍵情報を、中間攻撃者が作成した公開鍵に差し替えてやれば、攻撃者が生成する共有鍵をクライアント・サーバそれぞれに確立することが可能です。そのため、暗号化された EncryptedExtension や Certificate は中間攻撃者に筒抜けです。暗号化されているから認証されているわけではありません。

しかしCertificateVerifyまで来たら事情が異なります。中間攻撃者はサーバの秘密鍵を持っていないので、サーバ証明書の公開鍵に応じたハンドシェイク署名(CertificateVerify)を作成できないのです。勝手な秘密鍵でCertificateVerifyを偽造したとしても、クライアントは証明書の公開鍵を使って署名検証するため検証エラーになります。中間攻撃者はここでジエンドです。

f:id:jovi0608:20200703054824p:plain

中間攻撃者は暗号化された証明書を見れるのだから、中間攻撃者が証明書自体を改ざんしたらどうなるでしょうか? なりすまし用に作成した公開鍵に入れ替えてやれば、CertificateVerifyにあるハンドシェイク署名データの偽造できるかもしれません。

残念ながら、サーバ証明書がトラストアンカー(ルート証明書)を起点として電子署名がされているので、クライアント側で証明書の改ざんがチェックされエラーになります。認証局システムを破って不正なサーバ証明書を発行しない限り無理な話です。

f:id:jovi0608:20200703055009p:plain

なのでTLS1.3のフルハンドシェイク時の認証は、上図の範囲(Certificate, CertificateVerify, Finished)になります。
(コメント終)

そして3つ目の図セッション再開時のPSKによる認証はセッション再開時のシーケンスと認証部分の図です。クライアントはセッションを再開するタイミングでセッションチケットを送信します。そして、サーバーが受け取ったセッションチケットはセッシ ョンの再開の認証に使われます。 サーバーがセッションチケットの情報を受け取るとチケットの情報とサーバーが保持している情報を基にクライアントを認証し、認証が成功するとセッションを再開します。なお、PSKによるセッション再開時の認証が成功した場合、証明書による認証は行われません

より具体的な説明をします。まず、図GnuTLSのチケット書式の右側の"GnuTLSのチケットデータの中身の書式"を見てください。 サーバーは上から5つ目の項目のresumption_master_secretと7つ目の項目のnonceがあります。サーバーはこれらをHKDF-Expand-Label関数(※1)に入力してPSK(PreShared Key)を生成します。

psk=HKDF-Expand-Label(resumption_master_secret,
                        "resumption", nonce, Hash.length)

参考:RFC8446 4.6.1 https://tools.ietf.org/html/rfc8446#page-75 を基に作成

サーバーは上記の式でセッションチケットからpskを導出した後にbinder値(※2)を計算して、PSKを検証した後に自身が手元に持っているpskのリストと照らし合わせてpskがリストに存在するか、最初にこのpskが生成されてから規定以上の時間が経っていないかをチェックすることで認証を行います。

※1 HKDF-Expand-Labelは一方向性の性質を持つ鍵導出関数です。詳細については割愛しますが、出力から入力を導出できないことを覚えておいてください。 一方向性については後に登場するHKDF-Extractなど今回の説明で登場するHKDF-xxxという関数やderive_secretについても同様です。HKDFの仕様についてはRFC5869を参照してください https://tools.ietf.org/html/rfc5869

※2 簡単のため、binder値の計算については省略します。PoCに実装してありますので、興味のあるかたはそちらを参照してください。

(コメント)
「簡単のため、binder値の計算については省略します。」

くぅぅ、惜しい。自分としては、ここでbinder値を絶対に計算して欲しかったです。binder値こそがPSK認証の要の一つなんです。
まず、ここで実際にPSKの中身はどうなっているのか wireshark で見てみましょう。

f:id:jovi0608:20200703054951p:plain

四角で囲った部分がPSK拡張です。これはClientHelloの一番最後に配置されてないといけません(理由は後述)。 PSK拡張の中は、PSK Identity と PSK Binders で構成されています。PSK Identity は Identity と Obfuscated Ticket Age(難読化されたチケット経過時間) の2つが含まれています。Identityには、resumption PSKでは ticket そのものが入ります。

チケット経過時間がなぜ難読化されているのか? ClientHelloは平文であるため、チケットの経過時間が外部からわかるとその情報を使って色々いたずらされる可能性が出てきます。そのためクライアントは、チケットの経過時間にサーバから送られた難読化のための数値(ticket_age_add)を追加して、外から実際の経過時間がわからないように対策します。

PSK binders は、ClientHelloのハンドシェイクデータの先頭からPSK Binderの直前までのデータ(Truncated ClientHello)のハッシュ値を使います。このデータを resumption_master_secret から導き出した binder_key を使って Finished と同じような形でHMACした値です。

bindersの役割は2つあります。まずクライアントとサーバで同じ resumption_master_secret を持っていることを確認することです。もう一つは、Truncated ClientHelloが改ざんされていないことを保証することです。PSK拡張がClientHelloの一番最後に配置されないといけないのは、binderによるClientHelloの改ざん検知を最大限の範囲にするために必要な条件だからです。

新規接続のフルハンドシェイクからの流れを図示したのが下図です。

f:id:jovi0608:20200703054819p:plain

サーバは、PSKを持つClientHelloを受け取ったら、

1. まずPSK identityを復号し、ticket データからセッションデータや resumption_master_secret を取得します。
2. Obfuscated Ticket Ageの難読化を解き、チケットの経過時間を取得します。そして、NewSessionTicket中で規定したチケットの有効期限内かどうかチェックします。
3. クライアントと同じ方法で resumption_master_secret から binder_key を導出します。
4. サーバ側で Truncated ClientHello のデータと binder_secret からbinder値を計算します。
5. クライアントから送られてきた binder値とサーバが計算したbinder値が一致していれば、双方同じ resumption_master_secret を事前に共有していた相手であると確認したこととなり、認証が終わります。

このように binder によるMAC検証は、PSK認証を行う大きな要素の一つだということがわかるでしょう。
(コメント終)

MITM攻撃の手順を考える

この節では攻撃者がTLS1.3を使っている架空のシステムに対してCVE-2020-13777を使い、MITM攻撃(中間者攻撃)をする場合の手順を考えます。 前の節でPSKを用いたセッション再開時の認証のおおまかな流れについて説明しました。 MITM攻撃行うためには暗号化された通信の内容を復号して再度暗号化する必要があります。 つまり、攻撃者が攻撃を成功させるには鍵の導出や合意に必要な情報を持っている必要があります。 次の図を見てください。

f:id:jovi0608:20200703054934p:plain 図:TLS1.3のKey Scheduling

図の引用元: RFC8446 7.1 https://tools.ietf.org/html/rfc8446#page-93

この図はTLS1.3のKey Schedulingを表しています。 図の左上に着目してください。PSKと書いてあります。つまり、PSKを持っているとTLSのearly dataの暗号化/復号に必要なkey, ivを導出するための秘密情報(Secret)が手に入ります。

(コメント)
この HKDF をベースとした TLS1.3 の鍵スケジュールは、TLS1.2から一番進化したところです。この部分もいろいろコメントしたい部分がありますが、これ以上分量を増やすのもなんなんで、やめておきます。またの機会に。
(コメント終)

ハンドシェイクやアプリケーションデータを暗号化/復号するkey, ivの導出はどうすればよいでしょうか。 key, ivを導出する式を次に示します。

   [sender]_write_key = HKDF-Expand-Label(Secret, "key", "", key_length)
   [sender]_write_iv  = HKDF-Expand-Label(Secret, "iv", "", iv_length)

式:暗号化/復号 key, ivの導出

引用: RFC8446 7.3 https://tools.ietf.org/html/rfc8446#section-7.3

[sender]はデータを暗号化して送信する側を示していて、clientかserverが入ります。Secretには暗号化対象のデータ種別に対応するtraffic secretを入れます。 具体的なSecretの例を3つ紹介します。

  • 送信側がクライアント, 受信側がサーバーでearly data(0-RTT Application Data)の暗号化, 復号を行う場合
    • client_early_traffic_secret から導出したkey, ivを選択
  • 送信側がクライアント, 受信側がサーバーでハンドシェイクの暗号化, 復号を行う場合
    • client_handshake_traffic_secret から導出したkey, ivを選択
  • 送信側がサーバー, 受信側がクライアントでアプリケーションの暗号化, 復号を行う場合
    • server_application_traffic_secret_N から導出したkey, ivを選択

なお、KeySchedulingの図について、[sender]handshake_traffic_secret, [sender]application_traffic_secretの導出に着目すると、 これらの導出には(EC)DHEの共有鍵が必要なことがわかります。攻撃者は(EC)DHEへの中間者攻撃を行うことで(EC)DHEの共有鍵を手に入れることができます。

ここまでの流れをまとめると次の図のように攻撃できます。 図中の登場人物は次のとおりです。 なお、前提としてAliceはセッション再開以前にCVE-2020-13777の脆弱性を持っているBobをやりとりをしていて、PSKを用いてセッションの再開を試みているものとします。

  • Alice: 正規のTLS1.3クライアント
  • Bob: 正規のTLS1.3サーバー(ただし、CVE-2020-13777の脆弱性を持っている)
  • Mallory: MITM攻撃を行う攻撃者

f:id:jovi0608:20200703054919p:plain

RFC 8446 2.3節より、 PSKでの認証時はサーバーは証明書による認証を行ないません。 そのため、認証のバイパスに成功するとそれ以後、Malloryは[sender/receiver]handshake_traffic_secretや [sender/receiver]application_traffic_secret_N を用いて鍵やivを導出し、ハンドシェイクやApplication Dataの暗号化/復号ができるため、MITM攻撃が成立します。また、PSKの導出にはセッション再開時のCHLOのみを使っているので設問の要件「ClientHelloデータだけ使って」という要件を満たします。

(コメント)
MITM攻撃の手順は上記の通りで問題ありません。resumption_master_secretから導出したPSKを攻撃者が作成できれば、芋づる式にTLS1.3で利用する各種暗号鍵を生成できることとなり、PSK接続のハンドシェイクを自由に盗聴・ 改ざんし、成りすましもできるようになります。

ただ惜しむらくは、binderのチェックを省略せず入れて欲しかったぁ。

今回Ticketデータの書式を課題で提示しましたが、もし間違ったオフセットで resumption_master_secret を抽出していたり、(今回の課題の仮定の範囲外ですが)書式自体の間違いや更新などがあったら、MITMが成功しません。中間攻撃者がMITMを確実に成功させるには、ClientHelloを受信した時点で中間攻撃者自身がbinderの計算を行い一致するかどうかを確認するのが一番確実です。

中間攻撃者は、binder値の一致をもって、PSK Identityから解読したクライアント・サーバ間で事前共有されている resumption_master_secret が入手できたと初めて客観的に証明できるわけです。それが「ClientHelloデー タだけ」 という指定を入れた意図でしたが、やっぱり意図通りに課題を作るのは難しいと実感しました。これは出題者の力不足です。

ただ binder値のチェックはちゃんとPOCにも入っていますし、PSK生成からのMITMの手順も間違っていないのでこの解答を正解とみなしたいと思います。
(コメント終)

2. 0-RTT Application dataの復号

次の2つめの課題の解答をする章です。

  1. pcap中の暗号化されたTLS1.3 の 0-RTTアプリケーションデータをCVE-2020-13777によって復号し、アプリケーションデータの平文を取得してください。

PSKを導出するまでは1と同じです。説明とPoCを次に示します。なお、PoCの実装に際して

  • セッションチケットの復号時のMACのチェックの成功可否
  • 導出したPSKのbinderのチェックの可否
  • 0-RTT Application Dataの復号時のMACのチェックの成功可否

これらをコードの実装のヒントとして利用しました。 PoCの方針は次の図0-RTT Application dataの復元の通りです。 図中の登場人物は次のとおりです。 前提として、これまでと同様にAliceはセッション再開以前にCVE-2020-13777の脆弱性を持っているBobをやりとりをしていて、PSKを用いてセッションの再開を試みているものとします。

  • Alice: 正規のTLS1.3クライアント
  • Bob: 正規のTLS1.3クライアント(ただし、CVE-2020-13777の脆弱性を持っている)
  • Eve: Aliceのパケットを盗聴し、CHLOのApplication dataを不正に復元しようとする攻撃者

PSK導出後、Key SchedulingにしたがってPSKからclient_early_traffic_secureを導出します。 次にclient_early_traffic_secureからkeyとivを導出(※3)し、nonce=iv xor packet_number(=0, ApplicationDataはCHLOパケットに含まれているため)(※4)を算出します。

(コメント)
IVの生成もTLS1.2が大きく変わった点です。TLS1.2まではIVは明示的に生成してTLSレコードにつけていましたが、TLS1.3ではそれが必要なくなりました。これによってIVの再利用のリスクを無くすこともでき、IV分のデータ長も節約できることになりました。
(コメント終)

最後に復号をします。 暗号アルゴリズムは初回のセッションのCHLOのCipherSuitesのリストの最上位がCipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)であることからAESのGCMモードで鍵長32byte, nonce12byte, MACの長さが16byteであると推定します。 AEADのassociateは(Early dataが入っているTLSレコードレイヤーのヘッダ+暗号文の長さ)であるのがGnuTLSや他のTLS実装のソースコードにより判明したためそれを利用します。

(コメント)
AEADで改ざん検知を行うAssociate Data部分にはTLSレコードレイヤが入りますが、TLS1.3ではこの部分は改ざんされても問題ないので特に入れ込む必要がないデータです。ただ形式証明を行う場合に入っていないと正確な分析ができないということで入れ込むことになりました。透過性の問題さえなければレコードレイヤー自体必要のない無駄なデータなんです。
(コメント終)

最後に末尾16byteをMACとして切り出し、残りを暗号文として復号関数に入力します。

f:id:jovi0608:20200703054913p:plain

ここまでの説明した方針に沿って実装したPoCを実行した結果を示します。 復元したApplication dataの平文をASCIIと16進数文字列で表示した結果は次の通りです。 また、PoCのコードはこの記事の末尾にあります。

$python3 main.py
b"Let's study TLS with Professional SSL/TLS!\n\n\x17"
4c6574277320737475647920544c5320776974682050726f66657373696f6e616c2053534c2f544c53210a0a17

図:PoCの実行結果

(コメント)
お見事! 正解は、「Let's study TLS with Professional SSL/TLS!\n\n」でした。最後の0x17は、Application の Content-Type (23) が入ったものなのでちょっと余分ですが、問題ないです。

このように、TLS1.3では 0-RTT で送信されるデータの Forward Secrecy は確保できません。Ticket鍵が漏洩すれば、後日保管しておいたpcapファイル中の暗号データから平文データを解読できます。

しかしハンドシェイク後に送受信されたアプリケーションデータのForward Secrecyは守れます。これは、PSK接続では PSKと(EC)DHEを組み合わせたハイブリットモードを用意しています。psk_key_exchange_mode拡張で psk_dhe_ke が指定されている場合にPSK/(EC)DHEのハイブリッドで鍵交換されます。

下図の通り、0-RTTのデータは(EC)DHEとハイブリッド鍵交換する前の鍵で暗号化されるので Forward Securityはないですが、ハンドシェイク後のアプリケーションデータは、PSKと(EC)DHEのハイブリッド鍵交換後に導出された暗号鍵で守られています。

f:id:jovi0608:20200703054813p:plain

(EC)DHEは再接続時に一時的に生成されるものなのですぐ消去することができます。攻撃者がresumption_master_secretを入手してPSKを導出しようと、再接続時の一時鍵(EC)DHEがわからなければアプリケーションデータの暗号鍵を導出することは不可能だからです。
(コメント終)

自身でPoCを実行して結果を確認するには下記のリンクからChallenge CVE-2020-13777のpcapファイルをダウンロードし、次に示すディレクトリの構成を参考にPoCの実行ファイル(ファイル名:main.py)をpcapファイルと同じ階層に配置してください。

(コメント)
以下PoCコードの説明が続きますが、省略します。続きは、オリジナルの応募解答か、ペロトンさんのブログを参照してください。
(コメント終)

(コメントまとめ)

いろいろ難癖つけてる感じに読めますが、短期間で見事な解答だと思います。ちょうど解答が「Let's study TLS with Professional SSL/TLS!」でペロトンさんもプロフェッショナルSSL/TLSをお持ちでないということでVさんからの副賞はプロフェッショナルSSL/TLSになりました。 十分本の内容を理解できる実力をお持ちだと思うので、しっかり読み込んでいただきたいと思います。
課題に取り組んでいただいた皆さん、ありがとうございました。
(コメントまとめ終)

求む!TLS1.3の再接続を完全に理解した方(Challenge CVE-2020-13777)

1. GnuTLSの深刻な脆弱性(CVE-2020-13777)

先日、GnuTLSで深刻な脆弱性が見つかりました。

GNUTLS-SA-2020-06-03: CVE-2020-13777

It was found that GnuTLS 3.6.4 introduced a regression in the TLS protocol implementation.
This caused the TLS server to not securely construct a session ticket encryption key considering the application supplied secret, allowing a MitM attacker to bypass authentication in TLS 1.3 and recover previous conversations in TLS 1.2.
See #1011 for more discussion on the topic.
Recommendation: To address the issue found upgrade to GnuTLS 3.6.14 or later versions.
.
GnuTLS 3.6.4では、TLSプロトコルの実装にリグレッションが入っていることが判明しました。
これによりTLS サーバは、アプリケーションから渡された秘密鍵を使ってセッションチケットを暗号化する鍵が安全に生成されませんでした。
TLS 1.3では中間攻撃者が認証をバイパスすることができます。TLS 1.2では過去の通信を復号することができます。
このトピックに関する詳細な議論は、#1011を参照してください。
推奨: 発見された問題に対処するには、GnuTLS 3.6.14 以降のバージョンにアップグレードしてください。

issueを見ると、なんと stateless なTLS再接続を実現するTLSのセッションチケット情報が、TLSサーバ立ち上がりの数時間はオール0の暗号鍵で暗号化されていたというバグでした。

2年近く前、チケットの暗号鍵にTOTP(Time-Based One-Time Password Algorithm)のような機能を導入し、暗号鍵が定期的に変わるように変更したようなのですが、その際初期化のところでバグが入っていたようです。

これはなかなか衝撃的で痺れる脆弱性です。脆弱性に該当するバージョンを利用している方は直ちにアップデートしてください。

TLSセッションチケットの中身を守ることは、TLS通信の安全性を確保する要の一つです。脆弱性対象のGnuTLSを利用している全てのTLSサーバで、このチケットを暗号化する鍵が危殆化してしまったのですからそりゃ大変です。

ネット上では、このTOTPは意味はないのに入れちゃって、かえって致命的なバグを混入させたとして、GnuTLSに対する信頼性について批判するような意見も見受けられています。

issue見ると、日本人の名前の方がGnuTLSのメンテをされているようです。大変なご苦労かと思います。 ここではGnuTLSがどうだこうだと言うつもりはなく、純粋に技術的な観点からこの脆弱性の影響について考えてみたいと思っています。

2.ものは試し Challenge CVE-2020-13777

この脆弱性、実際にPoCを書いてみると非常に奥が深いです。TLS 1.3をちゃんと理解していないと無理です。

TLS 1.2 と TLS 1.3 でなぜ影響に違いがあるのか、そこはTLS 1.3の進化が見えるところです。またアナウンスには記述されていませんが、TLS 1.3の0-RTTを利用していた場合、この脆弱性によって大きな影響が新しく出てきます。

さっそく「GnuTLSの脆弱性(CVE-2020-13777)でTLS1.3の再接続を完全に理解する」というタイトルでブログを書こうと思いましたが、いつもの通りでは面白くない。なんか良からぬ考えが頭をよぎりました。

これ、どのぐらいの方が理解されるのでしょうか? 一度問題を作って、回答を公募してみようと思います。

3. 問題

以下のレポジトリにある pcap データを使って次の問題1,2に回答してください。
pcap データにはGnuTLS-3.6.13のサーバ(192.168.100.23:5556)に対するTLS1.3の接続データが2つ含まれています。1回目は新規TLS1.3接続、続く2回目は 0-RTT のTLS1.3再接続です。

https://github.com/shigeki/challenge_CVE-2020-13777

  1. pcap中のTLS1.3 ClientHelloデータだけ使って、CVE-2020-13777によってTLS1.3のMITMが可能であることを証明してください。
  2. pcap中の暗号化されたTLS1.3 の 0-RTTアプリケーションデータをCVE-2020-13777によって復号し、アプリケーションデータの平文を取得してください。

できるだけ純粋にTLS1.3仕様の理解を実証していただくため、GnuTLS固有で必要な情報(暗号化されたチケットの書式、チケットの中身の書式)は以下にヒントとして記しておきます。 f:id:jovi0608:20200613055805p:plain

不明な点は、直接GnuTLSのソースコードを参照してください。

4. 応募方法、期限

回答の説明と回答を得るために使ったソースコード を secret gist にあげて、そのリンクを私(twitter:@jovi0608)までDMで送ってください。

2問回答された方で、私の判断で一番見事と思う回答者1名にアマゾンギフト券(1万円)を贈呈したいと思います。

対象者が複数いた場合は、応募時間が先の方に贈呈します。

4.1 Vさん賞贈呈の追記 (6/13 13:40)

急遽Vさん賞を別途設けます。Vさんが好みそうな方を独自に選んでギフト贈呈します。

応募期限は、2020年6月18日(木) 23時59分59秒(JST) です。

5. 応募条件、免責事項

応募条件は、細かいですが以下の方でお願いします。

  • 「TLS1.3やQUICの実装していて、他者に提供している人」でない方:(実装されてる方は完全に理解されてますよねw 自己申告に任せます)。(追記6/13 11:24, ちょっと書き方が紛らわしかったので少し明確にしました。)
  • 正解の有無に関わらず、応募された twitter/githubアカウントや応募内容について、このブログで公開を許可していただける方(内容によっては文意を変えない程度に編集をさせていただきます)。
  • なにかリーガル上の理由やその他考慮不足など止む得ない理由で、予告なく Challenge の条件変更や取りやめを行う可能性がありますが、ご了承いただける方。
  • 私の一存の判断で応募内容の評価させていただくことに異論のない方。

TLS 1.2のPoCに関しては、全てのアプリケーションデータの forward secrecy を破ることになり影響が大きいので対象としません。

TLS 1.3については、現在 0-RTT を利用していると公表している中で netflix が一番の大規模サービスと思いますが、今回の脆弱性に該当するGnuTLSを利用していないことを確認済です。TLS1.3 0-RTTの機能はセキュリティ上の理由で安全に利用するのが難しく現在利用が普及していません。そのため今回のPoC公開・解説はあまり世間に影響ないものと判断しています。もし影響が大きいとわかれば、PoCの公表を取りやめます。

こういうことやるの初めてなので、いろんな不備・不測の自体が発生した場合にはお許しください。なにか不明な点があればこのブログにコメントください(moderateされています)。

応募が全然なかったら寂しいですね。贈呈するギフト券は完全に自分のポケットマネーです。これを機にTLS1.3仕様の完全理解に取り組んでくれる方がいらっしゃれば嬉しいです。

TLS1.3の再接続を完全に理解している方、応募をお待ちしています!

Let's EncryptがはまったGolangの落とし穴

0. 短いまとめ

300万以上の証明書の失効を迫られたLet's Encryptのインシデントは「Golangでよくある間違い」と書かれているようなバグが原因でした。

1. はじめに、

Let's Encryptは、無料でサーバ証明書を自動化して発行するサービスを行う非営利団体として2014年に設立されました。

2015年にサービス開始されると証明書の発行数はぐんぐん伸び、先月末のプレスリリースでは累計10億枚のサーバ証明書を発行したことがアナウンスされました「Let's Encrypt Has Issued a Billion Certificates」。CTLogの調査から、2020年2月末の時点では有効な全証明書の38.4%がLet's Encryptの証明書であるとみられています「Certificate Validity Dates」

無料の証明書を提供してもらえるのは非常に嬉しいのですが、認証局の業務やシステムの運用には当然大きなコストが掛かります。私も正式公開前のベータサービスの時から個人ドメインでLet's Encryptの証明書を利用し始めましたが、はたして寄付金だけを頼り、非営利で無料のままこのようなサービスを長期に継続して提供できるのだろうか?

正直言ってこのプロジェクトを初めて聞いた時 Let's Encrypt の先行きに少し不安を持っていました。もしくは将来、有料のEV証明書とかをきっと売り始めるだろうとも予想していました。

それからもう4年半も経ちました。Let's Encryptが主体となってドメイン認証と証明書発行の自動化を行うACME(Automatic Certificate Management Environment)プロトコルの仕様化も完了し、現在はACME v2が運用中です。 これによってDV証明書限定になりますが、従来の認証局が積極的に進めようとしなかった証明書発行システムの完全自動化に成功し、現在わずか13名のフルタイムスタッフと年間USD3.35M(約3億5千万円)の予算でLet's Encryptのシステムが運用され、大規模な証明書発行サービスを実現できています。本当に驚きです。

日本企業からは、時雨堂さん・さくらインターネットさんなどがLet's Encryptのスポンサーとして貢献されています。もうホント感謝しかありません。

Let's Encryptは間違いなく世界のWebサービスHTTPS化を大きく進めたもの思います。

しかしこういった高い貢献に対する評価の反面、Let's Encryptによって無料証明書を使ったHTTPSのフィッシングサイトが大幅に増加しているといった負の側面も指摘されています。

これはLet's Encryptだけが悪いとは思いません。結局Web PKIをめぐる歴史的経緯の中で生まれた様々な歪がLet's Encryptによって今あぶり出されたものだと私は思っています。

2. Let's Encryptのインシデント

そんな今では世界1位のシェアを持つLet's Encryptですが、先日証明書発行に関するインシデント発生のアナウンスがCommunity Supportに投稿されました「2020.02.29 CAA Rechecking Bug」Mozillaには「Let's Encrypt: CAA Rechecking bug」のチケットで報告されています。

この報告によると、Let's Encryptがgithub上で開発を続けている認証システム boulder でバグが見つかり、一部の証明書でドメインのCAA(Certification Authority Authorization)を再チェックせずに発行してしまったとのこと。

証明書の発行の際に記載ドメインすべてのCAAレコードをチェックすることは、CA/Browser Forum のBR(Baseline Requirements)を基としたLet's EncryptのCP(Certificate Policy)に規定されており、証明書発行時の必須要件です。

この規定要件に反して証明書発行が行われた場合、以下のCP規定に従い5日以内に対象の証明書を失効させなければなりません。

4.9.1.1 Reasons for revoking a subscriber certificate
The CA SHOULD revoke a certificate within 24 hours and MUST revoke a Certificate within 5 days if one or more of the following occurs:
7. The CA is made aware that the Certificate was not issued in accordance with these Requirements or the CA's Certificate Policy or Certification Practice Statement;

4.9.1.1 加入者の証明書を取り消す理由
CAは次のインシデントが1つ以上が発生した場合、24時間以内に証明書を取り消すべきであり(SHOULD)、5日以内に証明書を取り消さなければなりません(MUST)。
7. 証明書がここに記載されている要件またはCAの証明書ポリシー・認証実施規定に従って発行されていないことをCAが認識した時

証明書の失効方針とユーザへ証明書の再発行をメールで要請したことについて、「Revoking certain certificates on March 4」 のアナウンスも直ちに投稿されました。

対象となったのはおよそ305万の証明書、Let's Encryptから発行済みで有効な証明書のおよそ2.8%にあたる多さです。これをインシデント発見から5日以内、2020年3月5日3:00(UTC)までに全部失効させなければなりません。

Let's Encryptからの要請を受け、この期限日までにユーザによって170万以上の証明書が再発行されました。

しかしまだ130万証明書証明書が未更新のままで、その半数以上(約65%)が現在利用中であることがわかりました。このまま稼働中の証明書を強制的に失効させると、多数のWebサービスに重大な影響を与えることが予想されます。

結局、Let's Encryptはその影響度を考慮し、CP規定の5日以内に未更新の証明書を失効させることを止め、残り83日のExpireを待つ方針としました。引き続き証明書モニターと連絡を継続。今後この様な大規模インシデントに対応できるよう、失効通知するプロトコルの開発を進めるということです「Let's Encrypt: Incomplete revocation for CAA rechecking bug」

この方針に対して各ブラウザーベンダーが今後どう反応するのか、気になるところです。

今回のインシデントは、Go言語で開発されているboulderのバグによるものです。このバグの詳細についてIncident Reportで細かく言及されていました。

このレポートを読んでみると、驚いたことにこのバグは、GoのWikiページで「CommonMistakes/Using reference to loop iterator variable(よくある間違い/iterator変数をloopする際に参照を使う場合)」で書かれてある間違いが直接の原因でした。

Wikiではこの間違いを、

func main() {
     var out []*int
     for i := 0; i < 3; i++ {
         out = append(out, &i)
     }
     fmt.Println("Values:", *out[0], *out[1], *out[2])
     fmt.Println("Addresses:", out[0], out[1], out[2])
}

のようなコードで実例として挙げています。

これは、ループ内でループ変数iの参照を配列に入れてしまうことで、ループ終了後の出力値が

$ ./test_gomistake
Values: 3 3 3
Addresses: 0xc0000160a0 0xc0000160a0 0xc0000160a0

のように配列が全て同じ値になってしまう問題です。

for や range などで扱うループ変数が同じ参照になることを知っていないとやってしまいそうな初心者的な間違いです。

しかし実際にboulderコードを読むと、Let's Encryptのエンジニアは決してこの間違いを知らなかったわけではなく、ある変数ではきちんと対応していたのに他の変数ではうっかりこの間違いを見逃してしまったことが原因のようでした。

これはひょっとしたら自分もいつかこのようなバグを仕込んでしまうかも、と背筋が寒くなりました。

今回、300万以上の証明書を失効させるほどの要因となったCAAとはどういうものなのか? なぜ再チェックが必要なのか? このGoではよくある問題と書かれている程のバグは、なぜどのように発生したのか? について、このブログでまとめてみたいと思います。

3. CAAとはなにか?

CAA(Certification Authority Authorization)は、ドメインの管理者がDNSレコードに証明書発行を許可する認証局情報を記載し、証明書の不正発行や誤発行を防ぐ技術です。

認証局による証明書の誤発行や不正発行が問題となった2013年にRFC6844でCAAの仕様化が行われました。2019年11月に探索アルゴリズムのバグ修正をした改訂版RFC8659が発行されています。

ブラウザベンダや認証局が参加するCA/Browser Forumの規約で2017年9月よりCAAがサポートすることが必須化されました。この規定により、証明書発行する際には必ずCAAレコードのチェックが入ります。

認証局は、証明書発行時に証明書の subjectAltName フィールドに記載されている全てのドメインに対してCAAのチェックを行わなければなりません。

CAAレコードに記載されているドメインが自社指定のものであるなら証明書を発行することができますが、そうでなければ発行せずエラーを返します。

だからと言って焦ってドメインにCAAレコードを追加する必要はありません。CAAレコードの登録自体はオプション扱いです。CAAレコードが引けなかった場合でも証明書は発行されます。CAAレコードが設定されておらず、存在しなかった場合も証明書は発行されます。

2020年3月9日18:00更新 DNSエラー時のCAAチェックについて

Yasuhiro Morishita (@OrangeMorishita) | TwitterさんよりDNSエラー時の説明が間違っていることを指摘していただきました。ご指摘の通りですので記載内容を取り消しました。

この辺、私が規定を読み間違え、コードの確認も怠っていました。詳細については以下のtogetterをご確認ください。 ご指摘本当に感謝いたします。 togetter.com

次の図では、普段 example.com ドメイン認証局1(ca1.example.net)から証明書発行を受けている場合を例にします。 f:id:jovi0608:20200309042603j:plain example.comドメイン管理者は、DNSサーバに ca1.example.net のCAAレコードを登録し、認証局1から証明書の発行が可能であることを示しておきます。

攻撃者は、別の認証局2に対してなんらかの穴をついて www.example.com の証明書を発行しようとします。その際認証局2は、 www.example.com のCAAレコードをチェックにいきます。

CAAレコードでは www.example.com の証明書発行可能であるのは認証局1だけであることが記載されているため、認証局2では www.example.com の証明書発行要求を拒否してエラーとして返します。

よって攻撃者による www.eample.com の不正証明書入手は失敗に終わります。

CAAのレコードに記載するデータフォーマットは以下のような項目が入ります。 f:id:jovi0608:20200309042559j:plain 例えば実際のドメインでは以下のようなCAAレコードが登録されているのがわかります。 f:id:jovi0608:20200309042625j:plain このドメインは、CyberTrust、DigiCert、GlobalSignの認証局3社からしか証明書が発行できないよう指定されているのがわかります。

SSL Labs の統計では、2020年3月3日時点で7.1%のサイトがCAAの設定をしていると報告されています。やはりオプション扱いなので、まだそれほど高い普及率とは言えません。

CAAで証明書の不正発行や誤発行をすべて防ぐことはできません。むしろ限定されたケースでしかCAAによって守ることはできないと言っていいでしょう。

例えば、認証局システムのCAAチェックを無効化するまで完全に乗っ取られたり、(DNSSECを利用していない場合に)DNSへの攻撃などを合わせられるようなケースに対してはCAAは無力です。

実際にCAAを設定しているのにドメインレジストラへの攻撃によって証明書の不正発行防げなかった事例がありました。

Googleドメインには自社認証局 pki.goog の CAA が設定されていますが、2017年にGoogleのtg(トーゴ)ドメイン(google.tg)がLet's Encryptから不正発行が発覚しています。 https://crt.sh/?id=245397170 f:id:jovi0608:20200309042607j:plain この証明書は直ちに失効されました。

RFCではアプリケーションがCAAレコードを参照して証明書を検証することを禁止しています。そのためCAAレコードを付与することによってサービスが直接影響を受ける可能性は非常に少ないです。

サービスに与えるリスクが少なく、手軽に証明書の不正発行の対策ができることがCAAのメリットです。ただドメインにCNAMEが付与されている場合、CAAレコードの探索が少し複雑になりますので注意しましょう。

4. Let's Encryptの証明書発行を支えるboulder

boulder のアーキテクチャを下図に示します。以下 github repoのdocumentから引用した図を付けています。 f:id:jovi0608:20200309042555p:plain boulderのシステム要素を全て解説するわけにいきませんので、今回関連する部分だけを書きます。各システム要素(Authority)は protocol buffer v2 を使った gRPC で連携して動作しています。

ACME v2 ではおおよそ以下のステップで証明書発行を行います。

  1. Subscriber(ユーザ or クライアント)は、letsencrypt(旧certbot)コマンド等を通じて ACME v2プロトコルを使ってWFE(Web Front End)サーバと通信します。
  2. ユーザ認証系が整っていればクライアントは、証明書発行のための Order をWFE経由でRA(Registration Authority)に送ります。
  3. http-01とかdns-01等の指定された Challenge 形式の手順に従い、RA経由でVA(Validation Authority)がユーザへのサーバにアクセスしてドメイン認証(Challenge Response)を行います。
    この時VAは、発行ドメインCAAレコードをチェックして証明書発行が許可されているかどうかを確認します。 f:id:jovi0608:20200309042547j:plain ここで一度認証されたドメイン認証情報は、Let's Encryptの場合30日間有効です。FAQ Technical Questionには以下の通り記載されています。

    Once you successfully complete the challenges for a domain, the resulting authorization is cached for your account to use again later. Cached authorizations last for 30 days from the time of validation. If the certificate you requested has all of the necessary authorizations cached then validation will not happen again until the relevant cached authorizations expire
    ドメインに対するチャレンジが正常に完了すると、認証された結果がアカウントに対してキャッシュされ、後で再び使用できるようになります。 認証のキャッシュは検証時から30日間継続します。 発行要求した証明書が必要な認証を全てキャッシュされている場合には、その認証が期限切れになるまでvalidationは再度行われません。

  4. クライアントはWFEとRAを通じてCA(Certificate Authority)に証明書の発行要求(Order Final)を行います。この際ドメイン認証から8時間以上経っていればRAは再度CAAのチェックを行います。 f:id:jovi0608:20200309042552j:plain なぜ8時間以上経ったらRAによるCAAの再チェックが必要なのか? 実はCPの下記規定により、CAAチェック結果は最大でも8時間しか有効とみなせない規定があるからです。

3.2.2.8. CAA Records If the CA issues, they must do so within the TTL of the CAA record, or 8 hours, whichever is greater.

CAが発行する際は、CAAレコードのTTLまたは8時間のいずれか大きい方の時間内で証明書を発行しなければならない。

ドメイン認証は30日間有効ですので、ドメイン認証から8時間後を過ぎるとその時のCAAチェック結果は無効になります。ドメイン認証から8時間後かつ30日以内で証明書発行を要求されると、ドメイン認証でのCAAチェックはスキップされるので、RAは再度CAAをチェックしないといけないわけです。

5. boulderのバグ

今回バグは、当初ユーザからエラーが99個の同一のメッセージを出しているとのレポートによって発覚しました「Rechecking caa fails with 99 identical subproblems」。100のドメインを含んだ証明書発行を要求した際に、同一ドメインのCAAのrecheckエラーが99個含まれたメッセージが返ってきたのです。

このissueに反応した別のコミュニティユーザが現象を確認し、Let's Encryptスタッフに対して以下の問いかけます。

any confirmation? (I’m wary that this might actually be possible to apply as a CAA re-checking bypass … maybe I should delete and send to security@ …)

確認できます? (これが実際にCAAの再チェックをバイパスできるかもしれないと心配しています… おそらくこれを削除して security@ に連絡すべきかもしれませんが…)

なんと聡明なユーザでしょう。ですが残念なことにLet's Encryptのスタッフは当初これをエラー表示の問題と捉えていました。しかし数日後パッチを作って確認している際に間違いであることに気づきました。

問題はRAがOrder Finalの際にCAAの再チェックを行うところです。そこではRAとSA(Storage Authority)の間のgRPCで以下のやり取りがされていました。 f:id:jovi0608:20200309042612j:plain

  1. RAは、クライアントからの証明書発行要求(Order Final)を受け認証情報を確認します(checkAUthorizations)。その際RAは、CSRのsubjectAltNameに記載されている各ドメインの認証情報をSAに対してgRPCを通じて入手します(GetValidAuthorizations2)。
  2. SAは、自身が管理している認証情報(AuthzModel)をProtocol Buffer v2形式にして、ドメインと認証情報のMapを配列に入れRAに返します(authzModelMapToPB, modelToAuthzPB)。
  3. RAは、その認証情報に記載されているドメイン情報を見てCAAレコードをチェックします(checkAuthorizationCAA/recheckCAA)。

バグに関連する該当部分のコード(authzModelMapToPB, modelToAuthzPB)を示します。説明がわかりやすくなるよう便宜的に行番号をふっています。また一部説明に関係ない部分のコードを省略しています。

1.func modelToAuthzPB(am *authzModel) (*corepb.Authorization, error) {
2.      expires := am.Expires.UTC().UnixNano()
3.      id := fmt.Sprintf("%d", am.ID)
4.      status := uintToStatus[am.Status]
5       pb := &corepb.Authorization{
6.              Id:             &id,
7.              Status:         &status,
8.              Identifier:     &am.IdentifierValue,
9.              RegistrationID: &am.RegistrationID,
10.             Expires:        &expires,
11.     }
12.    (snip)
13.     return pb, nil
14.}
15.
16. // authzModelMapToPB converts a mapping of domain name to authzModels into a
17. // protobuf authorizations map
18. func authzModelMapToPB(m map[string]authzModel) (*sapb.Authorizations, error) {
19.     resp := &sapb.Authorizations{}
20.     for k, v := range m {
21.             // Make a copy of k because it will be reassigned with each loop.
22.             kCopy := k
23.             authzPB, err := modelToAuthzPB(&v)
24.             if err != nil {
25.                     return nil, err
26.             }
27.             resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
28.     }
29.     return resp, nil
30.}

ありました「よくある間違い/iterator変数をloopeする際に参照を使う場合」です。20行目から28行目に渡るfor loopが該当します。

authzModelMapToPBに渡されたmap m の key k と value v を参照しています。
kに関しては、ちゃんとバグにならないよう kCopyに代入して別の参照に渡しています。
vに関しては、modelToAuthzPBに参照を渡していますが一見問題がなさそうです。でも渡されたmodelToAuthzPBにおいてIdentifierとRegistrationIDのフィールドにvの参照を渡しています。そしてその返り値を配列に代入しています。

この部分だけ切り出して動作するようにして試してみます(動作が変わらない程度に若干コードに変更をかけています)。

package main
import (
        "fmt"
        "time"
        apb "./proto"
        )

type authzModel struct {
        ID               int64
        IdentifierType   uint8
        IdentifierValue  string
        RegistrationID   int64
        Status           uint8
        Expires          time.Time
}

func modelToAuthzPB(am *authzModel) (*apb.Authorization, error) {
        expires := am.Expires.UTC().UnixNano()
        id := fmt.Sprintf("%d", am.ID)
        status := "valid"
        pb := &apb.Authorization{
                Id:             &id,
                Status:         &status,
                Identifier:     &am.IdentifierValue,
                RegistrationID: &am.RegistrationID,
                Expires:        &expires,
        }
        // snip
        return pb, nil
}

func authzModelMapToPB(m map[string]authzModel) (*apb.Authorizations, error) {
        resp := &apb.Authorizations{}
        for k, v := range m {
                // Make a copy of k because it will be reassigned with each loop.
                kCopy := k
                authzPB, err := modelToAuthzPB(&v)
                if err != nil {
                        return nil, err
                }
                resp.Authz = append(resp.Authz, &apb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
        }
        return resp, nil
}

func main() {
        authzModels := [...]authzModel{
                authzModel{1,1,"www.example1.com",1,1, time.Date(2020, time.January, 1, 1, 1, 1, 1, time.UTC)},
                authzModel{2,2,"www.example2.com",2,2, time.Date(2020, time.February, 2, 2, 2, 2, 2, time.UTC)},
                authzModel{3,3,"www.example3.com",3,3, time.Date(2020, time.March, 3, 3, 3, 3, 3, time.UTC)},
        }
        authzModelMap := make(map[string]authzModel)
        for _, am := range authzModels {
                authzModelMap[am.IdentifierValue] = am
        }
        resp, _ := authzModelMapToPB(authzModelMap)
        fmt.Printf("%+v, Identifier:%p, RegistrationID:%p\n", resp.Authz[0], resp.Authz[0].Authz.Identifier, resp.Authz[0].Authz.RegistrationID)
        fmt.Printf("%+v, Identifier:%p, RegistrationID:%p\n", resp.Authz[1], resp.Authz[1].Authz.Identifier, resp.Authz[1].Authz.RegistrationID)
        fmt.Printf("%+v, Identifier:%p, RegistrationID:%p\n", resp.Authz[2], resp.Authz[2].Authz.Identifier, resp.Authz[2].Authz.RegistrationID)
}

ここでは、テスト用のmapデータwww.example[1-3].comの3つのドメインを渡された場合を模擬しています。

実行してみます。

$ ./le_bug
domain:"www.example1.com" authz:<id:"1" identifier:"www.example3.com" registrationID:3 status:"valid" expires:1577840461000000001 > , Identifier:0xc0000c8060, RegistrationID:0xc0000c8070
domain:"www.example2.com" authz:<id:"2" identifier:"www.example3.com" registrationID:3 status:"valid" expires:1580608922000000002 > , Identifier:0xc0000c8060, RegistrationID:0xc0000c8070
domain:"www.example3.com" authz:<id:"3" identifier:"www.example3.com" registrationID:3 status:"valid" expires:1583204583000000003 > , Identifier:0xc0000c8060, RegistrationID:0xc0000c8070

あぁ、identifier と registrationIDは同じ参照になっているため同じ値(www.example3.com, 3)になっています。recheckCAAはregistrationIDを参照するため、これではwww.example3.comの1ドメインしかCAAの再チェックを行いません。バグが再現できました。

このバグは、次のPR(Pass authzModel by value, not reference)で修正されました。単純に参照渡しを値渡しに変えるだけです。ここでも同じ修正をしてみます。

diff --git a/main.go b/main.go
index e4fa2a1..828401d 100644
--- a/main.go
+++ b/main.go
@@ -15,7 +15,7 @@ type authzModel struct {
         Expires          time.Time
 }

-func modelToAuthzPB(am *authzModel) (*apb.Authorization, error) {
+func modelToAuthzPB(am authzModel) (*apb.Authorization, error) {
         expires := am.Expires.UTC().UnixNano()
         id := fmt.Sprintf("%d", am.ID)
         status := "valid"
@@ -36,7 +36,7 @@ func authzModelMapToPB(m map[string]authzModel) (*apb.Authorizations, error) {
         for k, v := range m {
                 // Make a copy of k because it will be reassigned with each loop.
                 kCopy := k
-                authzPB, err := modelToAuthzPB(&v)
+                authzPB, err := modelToAuthzPB(v)
                 if err != nil {
                         return nil, err
                 }

試してみましょう。

$ ./le_bug
domain:"www.example1.com" authz:<id:"1" identifier:"www.example1.com" registrationID:1 status:"valid" expires:1577840461000000001 > , Identifier:0xc0000b8060, RegistrationID:0xc0000b8070
domain:"www.example2.com" authz:<id:"2" identifier:"www.example2.com" registrationID:2 status:"valid" expires:1580608922000000002 > , Identifier:0xc0000b8100, RegistrationID:0xc0000b8110
domain:"www.example3.com" authz:<id:"3" identifier:"www.example3.com" registrationID:3 status:"valid" expires:1583204583000000003 > , Identifier:0xc0000b81a0, RegistrationID:0xc0000b81b0

無事、それぞれのドメインに応じた値になっています。

22行目のkCopy変数の処理コメントを見ると、iterator変数をloopする際に参照を使う問題についてちゃんと意識してコードを書いていたことがわかります。本当に惜しい。

レポートでは kCopy は問題を回避しているのに v について見逃したのは、protocol buffer ver2 におけるフィールド値の代入が全て参照渡しになっていることも一因にあると分析しています。そのためつい参照渡しにしてしまったのでしょう。

再発防止策として、テストやログの充実、静的解析やレビューの実施、protocol buffer ver3のアップグレードなどが挙げられています。

ハマるところをちゃんと理解して回避したつもりがホントちょっとの思い込みで他の手当を忘れてしまう、胸に手をあてて見ても過去そんなことがあった覚えがありますし、これからも絶対に自分に起こらないとは言えません。怖いことです。

今回のインシデントは対岸の火事とはとても思えません。自分もこういうインシデントを将来起こさないよう本当に気をつけたいとレポートを読んでしみじみ思うのでした。

Windows CryptoAPIの脆弱性によるECC証明書の偽造(CVE-2020-0601)

1. はじめに

つい先日のWindowsのセキュリティアップデートでWindowsのCryptoAPIの楕円曲線暗号処理に関連した脆弱性の修正が行われました。
「CVE-2020-0601 | Windows CryptoAPI Spoofing Vulnerability」
これがまぁ世界の暗号専門家を中心にセキュリティ業界を驚かせ、いろいろ騒がしています。

その驚きの一つは、この脆弱性の報告者がNSA(米国家安全保障局)だったことです。NSAMicrosoftのアナウンスとは別により詳しい内容でこの脆弱性を警告するアナウンスを出しています。
「Patch Critical Cryptographic Vulnerability in Microsoft Windows Clients and Servers」

これまで数々の諜報活動をインターネット上で行ってきたNSAが、この脆弱性を自分たちの諜報活動に利用しないというのだろうか?
楕円曲線暗号バックドアを仕込んだのではないかと疑われているNSAが、自ら発見した楕円曲線暗号処理の脆弱性をわざわざ開発元に報告し、修正を求めるなんてことが本当にありうるのだろうか?

他にも、NSAは既にもっと巧妙な攻撃方法を使っているのでもうこの脆弱性はいらなくなったとか、他国がこの脆弱性を狙った攻撃を使い始めたからとか、いろんなことを想像しちゃいます。

まぁこれが本当なら世の中かなり変わったものです。

こういった騒ぎの中で、Hacker Newsではこの脆弱性の原因について解説をするコメントが寄せられていました。
「CVE-2020-0601の攻撃方法について述べたHacker Newsのコメント」

これを読んでみると、この脆弱性なかなかヤバそうです。早速世界中でこの脆弱性をついて exploit するPoCが作成され、既にいくつか公表されています。

その中で Kudelski Security が公表した、
「CVE-2020-0601: THE CHAINOFFOOLS/CURVEBALL ATTACK EXPLAINED WITH POC」
の内容は非常に具体的です。私も試してみると本当にHTTPSサーバの証明書を偽造することができました。このデモは、時雨堂さんのサイトを偽造してします。Vさん、ごめんなさい。

f:id:jovi0608:20200118022252p:plain

スクリーンショットにあるよう、IE11のURL表示バーには何もエラーが出ていません。ただし証明書のパスのチェックしてみると、ルート証明書にバッテンがついています。なんか不思議な状態です。通常のユーザはこれが異常であることに気づかないでしょう。

ちなみにWindows Update脆弱性修正のパッチをあててみると、まずDefenderがブロックしてサイトにアクセスできません。警告を受け入れてアクセスしてみると、見事URLバーが赤色になり証明書エラーの表示が出ています。

f:id:jovi0608:20200118022238p:plain

これだとひと目で攻撃サイトにアクセスしていることがわかります。まだWindows Updateしていない皆さん、直ちにパッチをあてましょう。

今回は、この脆弱性のしくみと上記の試験で検証した Kudelski Security が公表したHTTPSサーバを偽造する攻撃手法に限定にして解説したいと思います。コード署名やVPNなどでは他の攻撃ベクターが存在している可能性があります。ご注意ください。

2. 超ざっくりしたECDSAの解説

今回の脆弱性は、ECDSA(Elliptic Curve Digital Signature Algorithm)の処理に関連したものです。 ここでECDSAのしっかりした解説をするのはかなり荷が重いです。なので、この記事が分かる程度に超ざっくりとECDSAがどういうものか書いてみます。

これでなんとなく雰囲気を掴んでいただければと思います。ただかなり雑に書いているので、正確に理解したいならちゃんとした本や資料で勉強してください。

楕円曲線暗号の特徴は、ECパラーメータという数値セットで決定されます。そのECパラメータには、曲線の係数やベースポイント(G)と呼ばれる計算の開始点、剰余算を行う素数の値、その他いくつかの数値が含まれます。もちろん楕円曲線暗号の種類によってその中身も変わります。

この楕円曲線上の点で、数学的に決まった形で剰余演算を行います。楕円曲線暗号方式のキモは、ある楕円曲線上の点Gを何倍かして新しい点の位置を計算することはできるが、逆にある点を見てGを何倍してたどり着いた点なのか、その倍数を見つけるのが非常に難しいという性質です。さらに楕円曲線上の点Gを2倍3倍…と進んでいくと、同じ点Gに戻ってくるという性質もあります。

f:id:jovi0608:20200118134906p:plain

ECDSAは、この楕円曲線暗号を使った署名方式です。

上図の様にG、P(公開鍵)を署名者、署名検証者の間で交換しておきます。署名者は、署名作成時に乱数rから生成される点Rとメッセージのハッシュ値(Hash(M))と合わせて署名値SとRのx座標(Rx)を計算し、メッセージと共に署名検証者に渡します。
署名検証者は、受け取った署名値(Rx, S)とメッセージM、あらかじめ知っているGとPを使って別にRを計算します。この計算したRのX座標が、受け取ったRxと一致していれば署名検証成功です。

Pを生成する秘密鍵dを知っている者でないと、この計算を一致させる署名値を作ることができないからです。

こういった特徴を持つECDSA署名のしくみによってメッセージの認証や完全性が保証され、なりすましや改ざんといった脅威から守ることが可能となります。

3. CVE-2020-0601の脆弱性

今回のWindows脆弱性 CVE-2020-0601 は、CryptoAPI のバグをついてECパラメータを偽造し、署名検証をバイパスできてしまう脆弱性です。

ベースポイントGを含むECパラメータは、通常セットで名前付けられており、P-256, P-384などの名前で呼んで識別します(namedCurve)。ただし同じECパラメータでも名付け団体によって名前が異なるのでややこしいし、混乱します。

仕様的には名前付けされたパラメータセットではなく、ベタでECパラメータを書いている方式も認められています(specifiedCurve)。

修正される以前のWindows CryptoAPIでは、このECパラメータの処理する際にベースポイントGをチェックする処理が見落とされるバグを持っていました。

通常署名で利用する公開鍵は、証明書に記載されています。公開鍵を変えてしまうと別の証明書であると見なされます。そこで攻撃者は、ベースポイントGのチェックが甘いCryptoAPIのバグをついて、Gを秘密鍵がわかる位置まで移動させてしまえば、公開鍵Pに対応した秘密鍵を攻撃者が自由に設定できることを見つけました。

秘密鍵がわかる位置、そうですG'を公開鍵Pの倍数の位置まで動かせばいいのです。

公開鍵Pの座標を変えられないから計算の起点であるベースポイントGを動かす、逆転の発想です。

f:id:jovi0608:20200118134901p:plain

署名検証者が利用するベースポイントGをなんらかの方法で変更することが可能であるならば、公開鍵の値はそのままで攻撃者が自由に秘密鍵の値を決めることができてしまうのです。なんてことでしょう。

署名検証者が利用するCryptoAPIではベースポイントのチェックが漏れています。何も知らずに偽造されたベースポイントG'を利用して署名検証をしてしまうと偽造署名の検証が通ってしまうことになります。

もう少し細かくこれを数式で示したのが以下の図です。

f:id:jovi0608:20200118134857p:plain

自分も実際に紙上で手計算するまでは信じられませんでしたが、お見事です。これ見つけた人はすごい。

当初 twitter で公開した図の計算式に関しては、 xagawa さんsuper duper blooper さん から貴重なコメントを頂きました。深く感謝いたします。また頂いたコメントに従い一部表現を修正しました。

4. ECC証明書の形式

ではどうやって偽造したベースポイントG'を署名検証者に伝えるのでしょうか? 証明書を使えばいけそうです。

ECDSA署名で利用するECC証明書には、namedCurve形式とspecifiedCurve形式の2通りが存在します。仕様的には3つ目のimplictCurve形式も存在しますが、今回は対象外とします。

各形式の証明書を実際に見てみましょう。

4.1 namedCurve形式のECC証明書

namedCurve形式の証明書は以下の形です。証明書のなかほどに、ASN1 OID: prime256v1, NIST CURVE: P-256 と書いてあります。これがECパラメータを表す名前です。

f:id:jovi0608:20200118022217p:plain

この名前(prime256v1)で規定されているECパラメータの中身は、opensslのコマンドで以下の様に簡単に確認することができます。

$ openssl ecparam -name prime256v1 -text -param_enc explicit -noout
Field Type: prime-field
Prime:
    00:ff:ff:ff:ff:00:00:00:01:00:00:00:00:00:00:
    00:00:00:00:00:00:ff:ff:ff:ff:ff:ff:ff:ff:ff:
    ff:ff:ff
A:
    00:ff:ff:ff:ff:00:00:00:01:00:00:00:00:00:00:
    00:00:00:00:00:00:ff:ff:ff:ff:ff:ff:ff:ff:ff:
    ff:ff:fc
B:
    5a:c6:35:d8:aa:3a:93:e7:b3:eb:bd:55:76:98:86:
    bc:65:1d:06:b0:cc:53:b0:f6:3b:ce:3c:3e:27:d2:
    60:4b
Generator (uncompressed):
    04:6b:17:d1:f2:e1:2c:42:47:f8:bc:e6:e5:63:a4:
    40:f2:77:03:7d:81:2d:eb:33:a0:f4:a1:39:45:d8:
    98:c2:96:4f:e3:42:e2:fe:1a:7f:9b:8e:e7:eb:4a:
    7c:0f:9e:16:2b:ce:33:57:6b:31:5e:ce:cb:b6:40:
    68:37:bf:51:f5
Order:
    00:ff:ff:ff:ff:00:00:00:00:ff:ff:ff:ff:ff:ff:
    ff:ff:bc:e6:fa:ad:a7:17:9e:84:f3:b9:ca:c2:fc:
    63:25:51
Cofactor:  1 (0x1)
Seed:
    c4:9d:36:08:86:e7:04:93:6a:66:78:e1:13:9d:26:
    b7:81:9f:7e:90

今回注目のベースポイントGは、Generator (uncompressed) の項目に該当します。uncompressedは、ベースポイントの座標がX,Yの座標でエンコードされていることを示しています。

namedCurve形式のECC証明書は、opensslのデフォルトでECC証明書の秘密鍵を生成すると使えます。

$ openssl ecparam -out namedCurvePrivate.key -name prime256v1 -genkey

後述しますが、インターネットのPKIシステムで使われる証明書はこのnamedCurve形式になります。

4.2 specifiedCurve形式のECC証明書

specifiedCurve形式のECC証明書は、ECパラメータを証明書内にベタに書き込む形式です。

以下の証明書は、先のnamedCurve形式と全く同じECパラメータ(P-256)の証明書です。しかし名前ではなくECパラーメータの値がベタに記載されています。

先程のopensslで出力したECパラメータと全く同じ値が証明書内に明示的に記載されていることがわかるでしょう。

f:id:jovi0608:20200118022224p:plain

今回のCVE-2020-0601は、ここに書かれているベースポイントを偽造した値に入れ替えた証明書を使い攻撃を行います。

specifiedCurve形式のECC証明書は、opensslでECC証明書の秘密鍵を生成する際に、-param_enc explicit を付けると使えます。

$ openssl ecparam -out specifiedCurvePrivate.key -name prime256v1 -genkey -param_enc explicit

5. Kudelski Security が実証した攻撃手法

では実際にKudelski Security が実証した攻撃手法を試してみます。

時雨堂ドメインサーバ証明書を偽装し、IE11のブラウザーからエラーを出さずに偽のWebサーバにアクセスさせる攻撃です。

具体的な攻撃コードは、

github.com

にのっています。

5.1 偽造ベースポイントのルート証明書を作る

まずは適当な本物のルートECC証明書を探します。Kudelski Securityでは、現Sectigo社が管理するUSERTRUST NetworkのルートECC証明書を利用しました。

f:id:jovi0608:20200118022231p:plain

このルートECC証明書は、namedCurve形式でP-384を利用していることがわかります。

次に攻撃用のルート証明書を作成します。本物のルート証明書から、シリアルと公開鍵データをパクります。

次に、P-384のECパラメータを記載したspecifiedCurve形式の証明書から偽造ベースポイントを公開鍵を2倍にした座標に変更します。

f:id:jovi0608:20200118022209p:plain

G'=2Pなので秘密鍵は1/2となります。1/2といっても小数ではなく整数で表します。2倍して1になる数なので、1周して元に戻るちょうど中間折返し地点までの数を示します*1

5.2 時雨堂の偽造サーバ証明書を作る

時雨堂になりすましを行うHTTPSサーバ用の証明書を作り、この偽造ルートECC証明書を使って署名します。subjectAltName に shiguredo.jp を入れ込んでいます。 f:id:jovi0608:20200118022244p:plain

5.2 Windows CryptoAPIのキャッシュを上書き

脆弱性をついて攻撃できるルート証明書を作成できたとしても、どうやってこれをターゲットの端末に認識させられるのでしょうか? ルート証明書は通常端末の中で管理されており、外部の攻撃者がルート証明書を入れ替えたりすることは容易ではありません。

ここでもう一つの Windows CryptoAPI のバグ(仕様?)をつきます。

どうもCryptoAPIは、署名検証で利用した証明書をキャッシュしているようです。

ここからはWindows内部の挙動になるので想定になりますが、どうもシリアル番号・公開鍵・Subjectの情報など証明書の一部の情報が一致していると、ルート証明書といえどもキャッシュ更新を行う感じです。

2020年1月22日追記: McAfeeから調査ブログが出てました。思ったよりひどそう。 blogs.mcafee.jp

まず最初に攻撃対象となっている本物のルート証明書から発行されたサイトにアクセスしてルート証明書のキャッシュを作成させます。

次に攻撃者は、偽造ルート証明書と偽造サーバ証明書をクライアントに送り込みます。通常はルート証明書をサーバからクライアントに送ることはありませんが、しったこっちゃありません。

これを受け取ったクライアントのCrypto APIでは、なんと偽造ルート証明書にキャッシュを更新します。

本来ならECパラメータなど完全に同一のものか厳密にチェックしないといけないのですが、なんとベースポイントのチェックを忘れるバグがありました。楕円曲線の係数とかの情報はちゃんとチェックしているようで完全な見落としだと思います。

偽造ルート証明書は、CryptoAPI内では正常なルート証明書として扱われ偽造サーバ証明書の署名検証が成功することになります。これで最初に示した攻撃が完了です。 f:id:jovi0608:20200118022300p:plain 最初に示した攻撃に成功した画面では証明書パスのチェックでルート証明書にバッテンがついていました。これはキャッシュ上のルート証明書で署名検証は成功しているけどクライアントで管理している本当のルート証明書でないのでバッテンをつけているのだと思われます。

証明書パスがエラーならURLバーの表示でもエラーを出してもらいたいものです。

6. WindowsはRFC5480違反?

実はインターネットのPKIシステムでspecifiedCurve形式の証明書を利用することはRFC違反なんです。 https://tools.ietf.org/html/rfc5480#section-2.1.1 では明確に、

 implicitCurve and specifiedCurve MUST NOT be used in PKIX.

(implicitCurveとspecifiedCurveはPKIXでは利用しては**いけない**。)

規定されています。おそらく企業ユーザなどで過去TLS1.0/1.1の古い時代にspecifiedCurve形式を使った証明書を扱うようなケースがあり、互換性を維持するためにこうなってしまったのではないかと思われますが、本当のことはわかりません。

OpenSSLでも以下の issue でspecifiedCurve形式のクライアント証明書が使えなくなったというissueがあがってました。

Connection error when using EC client certificate with explicit parameters and TLS1.2

結局RFC5480に従うということでWon't Fix扱いでクローズされました。実際にspecifiedCurve形式のサーバ証明書を受け取るとopenssl-1.1.1のクライアントはエラーで接続できないです。

どういう理由かはわかりませんが、RFC違反で残っていた機能がこんなところで大きな脆弱性として出てきてしまったことは皮肉なもんです。 歴史の長いIEだからこそ起きてしまっことなのかも知れません。新しいMicrosoft EDGEでは、ChromiumのネットワークスタックやTLSライブラリ(BoringSSL)を利用しているようなので同じようなことを心配しなくても良くなるでしょう。

Google Chromeについて(2020年1月20日追記)

IE11は脆弱性の影響を受け、Google Chromeや新しいMicrosoft EDGEでは問題ないように捉えられる記述をしていましたが、間違いでした。
ChromeWindows CryptoAPIの CertGetCertificateChain を利用しており、下記コードで修正が入っています(修正版のChrome 79がリリース済み)

41853ce2057201bdd225aee96be4e6cd51b2458b - chromium/src.git - Git at Google

ただし、Google Chromeの場合はサーバ証明書に Certificate Transparency (SCT)が入っていないといけないので、SCT必須となる以前の2018年5月1日以前に発行された証明書に偽造する必要があります。

*1:実際には P-384 の order を法として2の逆数を求めます

なぜChromeはURLを殺そうとするのか? (Chrome Dev Summit 2019)

今年もChrome開発者の集まりChrome Dev Summit 2019 (CDS) がサンフランシスコで開催されました。 今回、私が Chrome Customer Advisory Board (CAB) に選出していただいたこともあり、CDSに初めて参加しました。

これは、CDS終了後のCAB meetingで頂いたChrome Dinosaurフィギュアです。ちなみにゲームはできません。 f:id:jovi0608:20191117195129j:plain タイトルの「なぜChromeはURLを殺そうとするのか?」は、2日目Chrome Leadsのパネルセッションで司会のGooglerが、Chrome UX担当のProduct Managerに対して一番最初に投げかけた問いです。

PMは直ちに「そんなことはしない」と即答しました。しかしChromeは、URLの表示領域からHTTPSの緑色表示の廃止・EV表示場所の移動・wwwサブドメイン表示の削除、といったセキュリティに関連するUI変更を立て続けに行ってきています。

Googleは、URLをどう考えていて、ChromeのUIを将来どうしていくのか?

その方向性は1日目のセッション「Protecting users on a thriving web (繁栄しているウェブの上でユーザを守る)」で話されています。


Protecting users on a thriving web (Chrome Dev Summit 2019)

世界的にHTTPS Everywhereが普及し、HTTPS化されたフィッシングサイトも増大している中、今後のChromeブラウザにおけるURL表示に対する考え方は、従来と大きく変わることになりそうです。 今回そのセッションの内容について紹介したいと思います。

このセッションは、2つに別れています。前半は、Chromeのセキュリテイ担当エンジニア Emily Stark さんによるChromeのURL表示に関するセッションです。以下は、おおよその講演内容を文字に起こしたものです。

1. あなたはウェブのどこにいるのか? サイトのアイデンティティをわかりやすくする。

ウェブブラウザの上部に表示されるURLは、ウェブサイトのアイデンティティに対する曖昧な手がかりでしかありません。

ドメイン名からサイトのアイデンティティを特定することは、簡単なことではありません。セキュリティ専門家でも見分けがつかないこともあるし、非技術者のユーザとってなおさら無理な話です。

このことは憶測に基づくものではなく、多くの実験や調査で示されています。セキュリティに対してちゃんとした決定をしなければならない時に、多くのユーザはURLに気づかなかったり、URLを理解できていないのです。

Googleは、1000人以上を対象とした調査を行いました。対象ユーザにGoogleのログインページにみえるブラウザウィンドウを見せると、アドレスが明確に tinyurl.com になっていても、85%の人がそのウェブサイトをGoogleだと言うことがわかりました。 f:id:jovi0608:20191119050458p:plain これは本当に非常に難しい問題です。

多くの人々は、本当に便利なものとしてURLを利用しています。しかし、URLが人々を騙したり、害を及ぼすことに使われたりすることもよくあります。

そのためGoogleは、いくつかの方向からこの問題にアプローチしています。それは、

  • URLを悪用する巧妙なスプーフィング技術に対して、積極的に対策をします。
  • セキュリティを決定づける最も重要なURL情報に、人々の注意を向けるようにします。
  • 専門家をサポートする特別なツールを作っています。それをURLをより良くするセキュリテイツールとして役立たせます。

というアプローチです。 f:id:jovi0608:20191119050533p:plain

1.1. スプーフィング対策

IDNを使った spoofing は見分けがつかないです。example.comのaがキリル文字だったとしてもわかりません。 f:id:jovi0608:20191119050608p:plain Chrome75から「Look-alike Warning」という新しい警告を入れるようにしました。これはスプーフされたIDNを検知し、攻撃されていると思われる場合ユーザを正式なサイトに導くページを表示します。 f:id:jovi0608:20191119050639p:plain Chromeがナビゲーション中のドメインにIDNを見つけると、同じように見える文字種を一つにまとめるアルゴリズムを使い、ドメイン名をスケルトンに変換します。 そしてそれを有名なサイトや過去にユーザが訪れたサイトのスケルトンと比較します。もし合致すれば、ユーザを正しいサイトに導くよう、この警告を表示します。 f:id:jovi0608:20191119050711p:plain

1.2. セキュリティに関連したものを強調する

1.2.1. EV表示の廃止

Chrome77より、EV証明書の表示をPage Infoに移す変更をしました。これまでの調査・研究結果から、EV証明書が人々にサイトのアイデンティティを理解させるのに役立っていないことがわかったからです。

(以下省略: これについては直前のエントリー「Google Chrome EV表示の終焉」に詳しく書きましたので、そちらを参照してください。 )

1.2.2. OmniboxにおけるURL表示の簡略化

HTTPS以外では警告を出すようにしたため、Onmiboxで scheme (https://) を非表示にしました。

大多数の人にとって、wwwサブドメインの表示をしてもしなくても違いはありません。多くのユーザには、それは visual noise となるし、セキュリティに関連した部分をURLで見つけるのが難しくなります。ユーザに対しては、多くの情報を与えないようにします。

UIをより簡単にして、ユーザの注意をURLのセキュリティに関連した部分(ドメイン名)にフォーカスさせます。そのため、これらのURLコンポーネントを隠しました。これを「steady state Omnibox」と呼んでいます。 f:id:jovi0608:20191119050740p:plain

1.2.3. 将来的な変更について (鍵アイコンの削除など)

将来的には、もっとドメイン名に注意を引きつけるUI変更も考えています。

  • OmniboxでURLを編集しようとクリックするまでパスを隠すべきか?
  • ドメイン名を太字にしたり大きく見せて、もっと際立たせるべきか?
  • ユーザーが自分でドメインを検査することになった時に、パスをアニメーション化すべきか?

まだどれが良いのかわかっていません。

この考えに沿って、最終的にHTTPS接続を示す鍵アイコンも削除する予定です。

HTTPS接続を中立な状態として、デフォルトにします。そしてセキュリティがなくなった場合にのみ、警告を出すようにします。

鍵アイコンのような positive なインディケータは、かえってユーザを混乱させ、誤ったメッセージを送ることもあります。 (これはHTTPSのメンタルモデルを調査した論文を引用し、「鍵アイコンは、自分の認証が必要なことを指していると思っている」というユーザがいたことを指しています。) f:id:jovi0608:20191119050808p:plain 何も出なかったら安全なHTTPS接続である、と思ってもらえるようにしたいです。 Chromeは安全でなかったら、警告を出すようにします。

調査・準備にもっと時間が必要なので、鍵アイコンをいつ削除するのかは未定です。

1.3. 専門家向けのユーザケース

全員がURLを簡略化してほしいと望んでいません。ここへの最初の取り組みは、Suspicious Site Reporter というChrome拡張です。

この拡張を使うとOmniboxのURLが編集されず、常に完全に見えます。デフォルトで有効になっている非技術ユーザを助ける目的のURL簡略化を回避します。 f:id:jovi0608:20191119050844p:plain この拡張を使うと、悪意のあるサイトをGoogle Safe Browsing サービスに簡単に報告できます。Google Safe Browsingサービスは、 malicious活動をスキャンしているサービスです。

Safe Browsingサービスが、報告されたサイトがフィッシングやマルウェア配布・ソーシャルエンジニアリングなど、Googleポリシー違反をしていると判定すれば、全てのChromeユーザに対してそのサイトの訪問をブロックします。

2. ウェブはあなたの何を知るのか? 反映するウェブエコシステムのためのプライバシー確保API

後半は、Michael Kleberさんによる最近のウェブプライバシー関連の状況とChromeの取り組みについてです。本エントリーの内容から少し外れてしまうので解説は省略しますが、1つ重要なことだけ挙げておきます。

2020年2月リリース予定の Chrome 80 から、クッキーのデフォルト挙動が変わります。3rd party cookieは、今のままでは動作しなくなる可能性があります。影響を受けそうなサイトは、Googleからのアナウンス「新しい Cookie 設定 SameSite=None; Secure の準備を始めましょう」をよく読んで準備しましょう。 f:id:jovi0608:20191119051248p:plain

3. まとめ

以上セッションの内容から、Googleがスプーフィング対策・非技術者向けにURLの簡略化と効果的なUIの模索・専門家向けにURLを生で見せるChrome拡張の提供、の3つの柱でChromeのセキュリティ対策を進めているのがわかりました。

「これは実質的にURLを殺しにきているのではないか?」と言われれば、まぁそうなのかもしれません。

URL表示の簡略化は、Omniboxを使うとカーソルが微妙にずれてしまったりして個人的にも違和感があります。慣れの問題かなと半分諦めていました。今回 Suspicious Site Reporter を知って速攻で入れました。まぁホント快適です(フィッシングサイトの報告はまだしたことないです)。簡略URL表示に違和感のある方は、慣れる前に試してみることをお勧めします。これを使えば、今のところURLが殺されていない世界をGoogleから提供してもらえそうです。

ただHTTPS接続が当たり前となり、フィッシングが巧妙になり、非技術者ユーザが圧倒的多数を占める状況では、URLを確認しろ・証明書を確認しろ・リンクやサイトの記述に注意せよ、といった対応はそろそろ限界に来ているのかなとも思ったりします。

今回のセッションで紹介されたHTTPSメンタルモデルの論文「“If HTTPS Were Secure, I Wouldn’t Need 2FA”- End User and Administrator Mental Models of HTTPS」で報告されているユーザ調査には、少しショックを受けました。

“I think the lock symbol means that I have to authenticate myself. 
As I frequently forget my passwords, I usually try to click around to get rid of this symbol.”
「鍵アイコンは、自分の認証が必要なことを指していると思っている。
私はよくパスワードを忘れるので、この鍵アイコンを消すためにいつもいろんなところをクリックしてます。」
“HTTPS is a bad protocol. If HTTPS were secure, I wouldn’t need 2FA.”
「HTTPSは悪いプロトコルだ。もしHTTPSが安全なら私に2FAは必要ないだろう。」

まぁこういう状況なら、ChromeのURL表示が簡略化され、セキュリティアクション選択の多くがGoogleのお任せになるのも仕方ないかもしれません。

ところで、もらった Chrome Dinosaurフィギュアの箱を見てみると見慣れないURLが、 f:id:jovi0608:20191118015129j:plain 試してみると、 f:id:jovi0608:20191119050945p:plain おぉ、 ERR_INTERNET_DISCONNECTED (-106) だったのか!

Google Chrome EV表示の終焉

1. Chrome でEV証明書の組織名表示がなくなる

ついにGoogleからChromeのURLバーからEV表示を削除する正式なアナウンスが出ました。

現在(2019年8月) StableのChrome76では、以下の様にURLバー左側にEV証明書を利用していることを示す「組織名+国名」表示が付いています。

f:id:jovi0608:20190812012752p:plain
Chrome76のEV表示

2019年9月10日Stableリリース予定のChrome77からはEV表示がURLバーから削除され、鍵アイコンをクリックして表示されるPage Infoに「組織名+国名」が表示されるようになります。

Googleのアナウンスでは、 "on certain websites" と書いてあることから一気にではなく一部のサイトからEV表示を外すのかもしれません。下図は Chrome78 Canary のスクリーンショットです。

f:id:jovi0608:20190812012733p:plain
Chrome78のEV表示

以前よりGoogleChromeでEV表示を外すフィールド実験を行っていることは知られていました。

今回、Googleはこの大規模なChromeのフィールド実験結果を解析し、EV表示の効果はないということを統計的に示しました。 結果、ChromeのURLバーからEV表示を外すという方針を決めました。

以下はGoogleのアナウンス文からの引用翻訳です。

Chrome Security UXチームは、独自の調査と以前の学術研究の調査を通じて、EV UIがユーザーを意図したとおりに保護していないと判断しました(Chromiumドキュメントの詳細資料を参照)。

EV UIが変更されたり削除されたりしても、ユーザーはパスワードやクレジットカード情報の入力を避ける、といった安全な選択を行わないようです。EV UIが提供している防止策に意味があると言えるでしょうか。

さらにEVバッジは貴重な画面領域を占有し、派手なUIで紛らわしい会社名を積極的に提示したりします。 これは安全な接続に対して有益になるわけではなく、むしろ中立的な表示を目指しているChrome製品の方向性を妨げます。

これらの問題とその限られた有用性のために、Page InfoでEV UIを表示する方が良いと考えています。

2. EV証明書の問題

ブラウザの表示については、これまで様々な問題提起がされ、大きな議論が行われてきました。 これまでの議論は、NTTセキュアプラットフォーム研究所の奥田さんによる「TLSとWebブラウザの表示のいまとこれから ~URLバーの表示はどうなるのか~」 にとても詳しくまとまっています。 興味のある方はこの資料をぜひ見てください。

奥田さんの資料に少し追加しますと、EV証明書の表示に関して少し前に2つ大きなインシデントがありました。

2017年に英国のセキュリティ研究者 James Burton氏が、"Identity Verified"という会社を設立し、正式なEV 証明書を取得しました。 当時AppleSafariではEV証明書のサイトではURLを表示せず組織名だけを表示していたため、 Identity Verified会社のEV証明書を使って以下のようにGoogleアカウントやPayPalのログインページなどを表示することができ、ユ ーザを惑わすことが可能であることを実証しました。

f:id:jovi0608:20190812013221p:plain
Identity VerifiedのEV表示

2018年AppleはiOS12でSafariでのEVの組織表示をやめました。 CABForumのF2F会議の議事録によると、Appleの話では「この変更は、調査と顧客の意見を基に行ったものである。組織名が、ユーザの意図した接続先とドメイン名と同じように結びついているわけではない。」と書いてあります。

2つ目は、米国のセキュリティ研究者 Ian Carroll氏が、米国では州が異なれば同じ会社名の法人が設立できることを使い、デラウェア州にある決済のStripe社と同じ社名のStripe社をケンタッキー州で設立しました。 この会社を使って正式なEV証明書を取得し、同じStripe社を表示する正当なEV証明書によるフィッシングサイトが立ち上げられられることを公表しました。2つを比べるとURLは異なりますが、EV表示だけを見ていると全く同じです。

f:id:jovi0608:20190812013238p:plain
Stripe IncのEV表示

このような事案を受けて、いくつかの認証局が組織するCA Security CouncilでEV/OV証明書によるフィッシングを対策する The London Protocolの策定を2018年に開始しました。しかし現在の進捗状況は不明です。

EV証明書を取得するには、登記簿の提出など組織の実在を証明する厳しい手続きと高い費用が必要です。しかしEV証明書を取得する組織が、法的に問題ない業務を行っているかどうかは認証局チェックの範囲外です。各認証局はEV証明書発行時にフィッシングドメインでないかのチェックを行っています。しかしそれは十分ではなく、コスト面さえ折り合えば、このような攻撃がEV証明書を使って行われる可能性があることが示されたわけです。

このような問題を持つEV証明書に対して、ブラウザのEV組織表示は本当に必要なのでしょうか?

ブラウザのEV組織表示は、本当にユーザのセキュリティに効果があるのでしょうか?

10年以上前にIE7やFirefox3を使った数十名を対象とした小規模な調査で、EV表示は有効でないという結果が公表されていました。現在ではブラウザの環境もUIも当時より大きく変わっています。

今回Googleは、7割近くのトップシェアを持つ Chrome を用いて大規模にフィールド実験を行いました。現在のブラウザUIでEV表示が本当にユーザのセキュリティ行動に対して有効なのか判断するためです。

3. Googleが行ったEV表示のフィールド実験

Googleは、どのようなフィールド実験結果からURLバーからEV表示の削除を結論づけたのでしょうか? 詳細は 8/14 から始まる USENIX Security 19 の「The Web's Identity Crisis: Understanding the Effectiveness of Website Identity Indicators」 で発表される予定です。

すでに公開されている論文から中身を見てみましょう。

Chromeのフィールド実験で、Googleは以下の3つの試験を行いました。

  1. Chromeユーザに対するEV表示/非表示のフィールド実験
  2. 米国・英国ユーザ(1000名程度)に対するEV表示に関するオンラインサーベイ
  3. 米国ユーザ(1000名強)に対するURL表示に関するサーベイ

ここでは、1.のChromeのEV表示/非表示のフィールド実験について主に書きます。

EV表示のフィールド実験は、2019年1月15日から28日にかけてStableチャネル(Chrome 71)で実施されました。

統制群、実験群としてそれぞれ1%のユーザをランダムで割り当て、実験群ではEV表示をしないようにしました。 Chromeのユーザ規模になると1%サンプルでも数百万ユーザのデータになっています。すごい規模です。

Googleは、それぞれのグループに対して以下のメトリックスを取得しました。

  • EV Navigationと時間
  • EV Pageの遷移(タブクローズ、Back/Forward/Reload)
  • ファイルダウンロード
  • Formの送信
  • Autofillによるクレジットカード情報の送信
  • Page Infoの表示とその後の操作
  • サイトエンゲージメント

取得したデータに対してウェルチt検定を行い、結果は以下の表になりました。統制群(Control)はEV表示しているグループ、実験群(Experiment)はEV表示をしないグループです。

f:id:jovi0608:20190812013256p:plain
Chromeのフィールド実験結果1

EV Navigationと時間、EV Pageの遷移、ファイルダウンロード、Formの送信、Autofillによるクレジットカード情報の送信に関しては、統制群・実験群でその割合に大きな違いは見られませんでした。

統制群に対して実験群が統計的有意差(p値 < 0.05)であるものは、「Page Infoを表示する」だけです。 少し意外ですが、EV表示がある方がユーザが頻繁に鍵アイコンをクリックしてPage Infoのポップアップを開けているのです(EVページナビゲーションに対して0.25%対0.02%)。

Page Infoを開けた後の操作にどのような違いがあるのか? EVページナビゲーションで正規化して解析し直したのが次の結果です。

f:id:jovi0608:20190812013318p:plain
Chromeのフィールド実験結果2

Page Info後の操作に対して、それぞれの効果量(Cohen's d)が小さいです。ユーザのPage Info後の操作は、EV表示/非表示で大きく変わらないことがわかりました。

それではどうしてEV表示があるとユーザは、Page Infoをよく開けるのでしょうか?

論文では以下の2つの仮説が挙げられています。

  • EV表示の領域が大きいためユーザが間違ってPage Infoを開けてしまう。
  • ユーザは、EV表示の場合ちゃんと認識してPage Infoを開けている。しかしEV非表示の場合でもセキュリティ行動を変える判断を行うまでに至っていない。

ですが結局よく理由はわからないと締めています。

Googleは、TOP 20のEVサイトに対するデータの解析も行っています。その結果6サイトで統計的有意差が出ましたが、効果量が少ないため自然変異だろうと結論づけています。

以上のフィールド実験の結果から、Page Infoを開ける行動には違いがあるものの、EV表示の有無がナビゲーションやページ遷移、ファイルダウンロード、フォーム送信、クレジットカード情報のAutoFillといったユーザのセキュリティ行動に影響しないことが統計的に示されました。 これは過去の小規模調査結果と合致しています。

他にもGoogleは、1000名を対象としたEV表示のサーベイを実施しています。 組織名が同じで国名が異なるEV証明書が表示された場合やApple SafariのEV表示変更でユーザがどのように判断するかサーベイしました。 どちらもWebページにログインする際、EV表示がユーザに心理的影響を与えないことがわかりました。

ChromeにおけるEV表示の大規模フィールド実験と1000名を対象としたEV表示のサーベイ結果、いずれもEV表示の有効性を示すものではありませんでした。 以上の結果から、ChromeのURLバーからEV表示を外し、Page Infoへ移す方針を決めたわけです。

4. URL表示の限界、その先にあるもの

ChromeのURLバーからEV表示がなくなります。ではブラウザは、このままのURL表示で大丈夫なのでしょうか?

今回の論文では、1000名強のユーザーを対象としてURL表示のバリエーションを変えたサーベイも行っています。しかし、どれもユーザをフィッシングサイトへの アクセスを防止させるような効果はなかったと結論づけています。

2019年1月のUSENIX ENIGMAで、GoogleのEmily Stark氏が The URLephant in the Room というプレゼンを行っています。 そこでは、

URLは、効果的なセキュリティ・インディケータではない。
- 人々はURLに気づいていない。
- 人々はURLを理解していない。
- (国際化ドメイン名など)人々がURL(の違い)を理解することが不可能な場合もある。

と述べています。一方で、直ちにURLに替わる画期的で有効な方法といった銀の弾丸は存在せず、まずURL表示を正しくするようにして長期間かけてUIを改善していく必要がある、アイデアやフィードバックが欲しいと訴えています。

Googleは、URLに替わる代替案を本当に考えていないのでしょうか?

あるChromeのバグチケットにこんなコメントがありました。

f:id:jovi0608:20190812015057p:plain

 Our long-term hope is to eventually de-emphasize URLs (Google-internal link: ...)
 我々の長期的な展望は、実質的にURLを強調させないことである(Google内部リンク: ...)

資料がGoogle内部リンクになっているため「実質的にURLを強調させないこと」が具体的に何を指しているのかわかりません。

GoogleがURL表示に替わってChromeの表示をどう変えていくのか、今後も注視していきたいと思います。