あなたは危険なほど暗号が下手だ

本記事は Claude による翻訳です。誤訳や不自然な表現が含まれている可能性があります。

能力には四つの段階がある:

  1. 無意識的無能 — 自分がどれだけ下手か、何を知らないかにすら気づいていない段階。
  2. 意識的無能 — 自分の下手さを自覚し、上達のために何をすべきかが分かっている段階。
  3. 意識的有能 — 上達していて、そのことを自覚している段階(ここは楽しい!)。
  4. 無意識的有能 — 上達しすぎて、もはやそれを意識しなくなった段階。

好むと好まざるとに関わらず、誰もが第一段階から始まる。どの分野でも、第一段階から第二段階へ進む鍵は、たくさん失敗してフィードバックを得ることだ。フィードバックがあれば、何が正しかったか、何を間違えたか、次は何を改善すべきかが少しずつ見えてくる。

暗号が危険なのは、ミスをしてもフィードバックが返ってこないからだ。普通の開発者にとって、ランダムに base64 エンコードされたバイト列など、どれも似たようなものに見える。

プログラミングは偶然でも上達できる。コードがコンパイルできない、意図通りに動かない、明らかなバグがある——そういう場合はすぐにフィードバックが返ってくる。直して、次はもっと良くする。

暗号は偶然では上達しない。エクスプロイトについて読み、自分で実装する時間と労力を費やさない限り、自作の暗号ベースのセキュリティ機構が現実の攻撃に耐えられる可能性はほとんどない。

暗号ベースのセキュリティ機構を破る方法を知っているセキュリティ専門家に金を払わない限り、自分のコードが安全でないことを知る術はない。あなたのセキュリティ機構を回避する攻撃者も、それを教えてはくれない(攻撃者にとっての最善は、あなたに気づかれずに回避することだ)。

以下に、暗号の誤用例をいくつか挙げる。この記事を読んでいなかったとして、あなた なら現実のコードでこれらの間違いに気づけただろうか、と自問してみてほしい。

写真共有サイトの API 認証

md5 + 秘密鍵によるメッセージ認証

かつて、ある写真共有サイトは以下のスキームで API を認証していた:

  • ユーザーは次の二つのクレデンシャルを持つ:
    • 自分を識別するための公開された ユーザー ID(平文で送信して構わない)
    • メッセージに署名するための 共有秘密鍵(秘密に保つ必要がある)
  • ユーザーは HTTP/HTTPS(どちらでもいい)経由で API リクエストを送る。破壊的な変更は、特定のパラメータを伴う POST/GET リクエストで行う(例: { action: create, name: 'my-new-photo' })。
  • メッセージを認証するため、ユーザーはユーザー ID をパラメータとして送り、秘密鍵でメッセージに署名する。署名は、共有秘密鍵とキーバリューペアを連結したもののmd5だ。

クライアントが本人であることを確認するため、サーバーはリクエストパラメータと、そのユーザーについて保存されている秘密鍵から署名を生成する。

コードはこのようになる:

# CLIENT SIDE

require 'openssl'

## Our user credentials
user_id = '42'
secret  = 'OKniSLvKZFkOhlo16RoTDg0D2v1QSBQvGll1hHflMeO77nWesPW+YiwUBy5a'

## The request params we want to send
params = { foo: 'bar', bar: 'baz', user_id: user_id }

## Build the MAC
message      = params.each.map { |key, value| "#{key}:#{value}" }.join('&')
params[:mac] = OpenSSL::Digest::MD5.hexdigest(secret + message)

## Then send the request via something like...
HTTP.post 'api.example.com/v3', params
# SERVER SIDE

## Grab the user credentials out of the DB
user   = User.find(params[:user_id])
secret = user.secret

## Get the MAC out of the request params
challenge_mac = params.delete(:mac)

## Calculate the MAC using the same method the client uses
message        = params.each.map { |key, value| "#{key}:#{value}" }.join('&')
calculated_mac = OpenSSL::Digest::MD5.hexdigest(secret + message)

## Compare the challenge and calculated MAC
if challenge_mac == calculated_mac
  # The user authenticates successfully, do what they ask
else
  # The user is not authenticated, fail
end

md5 の仕組みを基本的に理解していれば、これは API 認証の実装として完全に妥当に見える。安全そうに見えるだろう?本当にそうだろうか?

実はこのスキームは、長さ拡張攻撃(length extension attack)と呼ばれる攻撃に対して脆弱だ。

簡単に言うと:

  • md5('foo') の値が分かっていれば、md5 の仕組み上、プレフィックス 'foo' を知らなくても md5('foobar') を簡単に計算できる。
  • つまり、md5('secretfoo:bar') の値が分かっていれば、プレフィックス 'secret' を知らなくても md5(secretfoo:bar&bar:baz) を簡単に計算できる。
  • これはつまり、署名されたメッセージの例が一つでもあれば、そのメッセージに任意のリクエストパラメータを加えた署名を偽造でき、上記のスキームでは認証が通ってしまうということだ。

これを事前に知らない開発者なら、いとも簡単に 引っかかっていただろう。実際、Flickr、Vimeo、Remember the Milk の開発者たちはこれを 本番環境にデプロイ していた

言いたいのは、暗号関数の内部にある難解な詳細をすべて知っているべきだ、ということではない。言いたいのは、暗号には失敗のしかたが無数にある、だから手を出すな、ということだ。

納得いかない?では、この例を直して安全にできるか試してみよう……

HMAC によるメッセージ認証

近所のホワイトハッカーからこの脆弱性について教えてもらい、彼は ハッシュベースのメッセージ認証コード(HMAC)を使って API リクエストを認証することを勧めてくる。

素晴らしい!HMAC はまさにこの用途のために 設計されている。先ほどまでの署名検証コードを、そのまま置き換えられる。サーバー側の検証コードは次のようになる:

require 'openssl'

## Grab the user credentials out of the DB
user   = User.find(params[:user_id])
secret = user.secret

## Get the MAC out of the request params
challenge_mac = params.delete(:hmac)

## Calculate the HMAC
## We'll do the same thing on the client when we generate the challenge
message = params.each.map { |key, value| "#{key}:#{value}" }.join('&')
calculated_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('md5'), secret, message)

## Compare the challenge and calculated MAC
if challenge_hmac == calculated_hmac
  # The user authenticates successfully, do what they ask
else
  # The user is not authenticated, fail
end

安全そうに見えるだろう?本当にそうだろうか?

実は、上の検証コードは タイミング攻撃 に対して脆弱で、特定のメッセージに対する正しい MAC を推測できてしまう。

簡単に言うと:

  • 特定のメッセージに対し、HMAC をすべて同じ一文字で埋めて送ってみる。これを各 ASCII 文字について行う(例: 'aaaa...', 'bbbb...' など)。
  • 各リクエストの完了時間を計測する。文字列の等価比較は、先頭の文字が一致しているとほんのわずかに時間が長くなるため、最も長く返ってくるリクエストが正しい先頭文字を持っている。
  • レイテンシのノイズを抑えるには、二つの方法がある:
    • 各推測について数百回から数千回リクエストを送り、平均時間を取る。
    • 同じデータセンター内からタイミング攻撃コードを走らせる。データセンターが特定できない場合は、最悪、主要なプロバイダーそれぞれにインスタンスを立て、ターゲットサーバーへの ping が明らかに短くなるインスタンスを見つければいい。
  • 最初の文字が判明したら、二文字目以降を変えながら同じことを繰り返す。例えば最初の文字が 'x' なら、'xaaa...', 'xbbb...' などを試す。
  • HMAC 全体が判明するまで続ける。

この手法を使えば、API に送りたい任意のメッセージの HMAC を確実に特定でき、認証を通せる。

また同じ話だが、タイミング攻撃を知らなかったとしても無理はないし、知っている必要もない。言いたいのは、個別の脆弱性の詳細を知ってそれぞれに備えるべきだ、ということではない。言いたいのは、暗号には失敗のしかたが無数にある、だから手を出すな、ということだ。

それでも、もっと安全にできるか試してみよう……

タイミングに依存しない方法で HMAC を検証する

タイミング攻撃を回避するには、送られてきた MAC と計算した MAC を、時間に依存しない方法で比較する必要がある。つまり、プログラミング言語に組み込みの文字列等価演算子は使えない——一文字でも違いを見つけた時点で即座に返ってきてしまうからだ。

文字列を比較するには、任意のバイトを自分自身と XOR すると0になるという性質を利用できる。文字列 A の各バイトと文字列 B の対応するバイトを XOR し、結果のバイトを合計して、0なら true、そうでなければ false を返せばいい。Ruby ならこのようになる:

require 'openssl'

## Time insensitve string equality function
def secure_equals?(a, b)
  return false if a.length != b.length
  a.bytes.zip(b.bytes).inject(0) { |sum, (a, b)| sum = sum || (a ^ b) } == 0
end

## Grab the user credentials out of the DB
user   = User.find(params[:user_id])
secret = user.secret

## Get the MAC out of the request params
challenge_hmac = params.delete(:hmac)

## Calculate the HMAC
## We'll do the same thing on the client when we generate the challenge
message         = params.each.map { |key, value| "#{key}:#{value}" }.join('&')
calculated_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('md5'), secret, message)

## Compare the challenge and calculated MAC
if secure_equals?(challenge_hmac, calculated_hmac)
  # The user authenticates successfully, do what they ask
else
  # The user is not authenticated, fail
end

安全そうに見えるだろう?本当にそうだろうか?

怪しい。この手のスキームに対する攻撃ベクトルとしては、これが私の知識の限界だが、これを破る方法がないとは到底思えない。

手間を省け。暗号を使うな。あれはプルトニウムだ。失敗のしかたは何百万通りもあり、正しくやる方法はごくわずかしかない。

追伸: どうしても 自分で HMAC を検証する必要があり、activesupport が手元にあるなら、ActiveSupport::MessageVerifier を使えばタイミングに依存しない比較が得られる。ゼロから書くな。そして、頼むから上の私の実装をコピペするな。

追々伸: それでもまだ納得いかない?Matasano Crypto Challenges をやってみてほしい。それで考えが変わらなければ言うことはない。私はまだ半分も終わっていないが、すでに過去のクライアント二人に連絡を取って、壊れた暗号を直してもらう羽目になっている。

ロシア語版はこちら。Dmitry Cherniachenko 氏による翻訳に感謝。