RubyのBCryptはバイナリセーフなのか
徳丸先生が注意喚起としてあげられていたこちらの記事に関して
“bcryptの72文字制限をSHA-512ハッシュで回避する方式の注意点 | 徳丸浩の日記” https://t.co/AA1yFVd0TH
— 徳丸 浩 (@ockeghem) 2019年2月24日
記事ではPHPの例が上がっていましたが、Rubyではどのような影響があるのかが気になります。
BCryptは非常にメジャーなアルゴリズムで、例えば Devise
や Sorcery
などの定番の認証gemを使う場合、デフォルトでBCryptを利用する設定になっています。Railsアプリを開発されている方なら、ほとんどの方が使っているのではないでしょうか。
詳しくは徳丸ブログを見ていただくとして、以下ではbcrypt-rubyについて、同じ現象が起こるのかどうか簡単に調査しています。
TL;DR
-
BCryptを使って バイナリをハッシュ化してはならない
- バイナリコード0x00にヒットすると以降のハッシュ化を止める
- それにより実際より短い部分文字列だけを照合してしまう可能性がある
- とくに冒頭に0x00を含むパスワードを指定すると、簡単に破られてしまう
-
BCryptに通す前に自前でハッシュ関数を適用したり、BCryptでバイナリをハッシュ化するというような特殊用途で使用している場合は要注意
- セキュリティ面の問題がある
- 未来のバージョンで、今まで通っていたパスワードが通らなくなる可能性がある(後述)
「ユーザーからの入力パスワードをテキストのままdeviseに渡す」というように、一般的な使い方をしている分には問題にならないと思われます。
実験してみる
徳丸ブログに指摘されている内容はいくつかのステップがあります。
それぞれRubyのBCrypt実装においても同様の現象が起こるかどうかを確認していきます。
72文字を超えるパスワード文字列は切り捨てられるか
実験のために、BCryptでハッシュ化された長いパスワードを用意します。
(ストレッチ回数などはデフォルト設定です)
>> require 'bcrypt'
# 72文字を超える長いパスワードを用意する
>> password1 = 'a_rediculously_loooooooooooooooooooooooooooooooooooooooooooooooooooooong_password'
>> password1.length
=> 81
# BCryptオブジェクトを作る
>> bcrypted_password1 = BCrypt::Password.create(password1)
=> "$2a$10$gSsEckxXdY26QjgVuibuZ.J.nAxVpAyMVW6GWjKI9ouOVBEbPiitm"
ここで作成したBCryptオブジェクトと候補文字列を ==
で比較すると、「ハッシュ化した時に同じハッシュを得られるかどうか(≒ 元のパスワードが一致するかどうか)」を判定してくれます。1
パスワード認証を通す・通さないの判定に使われている仕組みですね。
# 一致する
>> crypted_password1 == 'a_rediculously_loooooooooooooooooooooooooooooooooooooooooooooooooooooong_password'
=> true
# もちろん適当な文字列を入れても一致しない
>> crypted_password1 == 'a_totally_different_thing'
=> false
ところが・・
# パスワード後半を変更する
>> crypted_password1 == 'a_rediculously_loooooooooooooooooooooooooooooooooooooooooooooooooooooong_cucumber'
=> true
>> crypted_password1 == 'a_rediculously_loooooooooooooooooooooooooooooooooooooooooooooooooooooongSpeach'
=> true
後半(73文字以降)がハッシュ化の際に捨てられていることがわかります。
もちろん72文字以前を変更した場合は不一致となります。
>> 'a_rediculously_loooooooooooooooooooooooooooooooooooooooooooooooooooooong'.length
=> 72
>> crypted_password1 == 'a_rediculously_loooooooooooooooooooooooooooooooooooooooooooooooooooooonG_password'
=> false
前提となるパスワード文字列の制限については、ドキュメントには明記されていないようですが、RubyのBCryptでも同様に起こることがわかります。
実装はcrypt(3)ライブラリに準拠していると思われるので当然といえば当然ですね。
長い文字列をハッシュ化するときの落とし穴
ここで何らかの事情で「どうしても72文字以上ある長い文字列のハッシュをBCryptで取りてえ」となったとします。
上記の切り捨てを回避するため、「別のアルゴリズムでいったん短いハッシュを作ってから、BCryptに食わせる」という手段が思い浮かびます。ハッシュが長いテキストだと結局また切り落としが発生するのですが、バイナリハッシュを使うと72文字以下におさめることができそうです 2
比較演算子 ==
が使えるように、雑に実装してみたのが下記です。
require 'digest'
class VeryUnsafeDigest
def initialize(password)
@password = password
@digest = create_digest
end
def create_digest
temporary_digest = Digest::SHA512.digest(@password)
BCrypt::Password.create(temporary_digest)
end
def ==(other)
@digest == Digest::SHA512.digest(other)
end
end
>> digest1 = VeryUnsafeDigest.new(password1)
>> digest1 == "a_rediculously_loooooooooooooooooooooooooooooooooooooooooooooooooooooong_password"
=> true
>> digest1 == "a_rediculously_loooooooooooooooooooooooooooooooooooooooooooooooooooooong_beard"
=> false
今度は長いパスワードをうまく区別してくれました。
一見うまくいったように見えます。が・・
>> digest2 = VeryUnsafeDigest.new('good_looking_password_380')
>> digest2 == 'RandomString105'
=> true
>> digest2 == 'good_looking_password_420'
=> true
なんと、3つの完全に異なるパスワードが すべて衝突してしまいました。
これらの文字列は、バイナリハッシュの段階で冒頭に0x00が含まれてしまっています。
>> Digest::SHA512.digest 'good_looking_password_380'
=> "\x00\xB6\xE5\xE65\\\x91j\xD3\t\xB4\x98h\x81\xBD\x0E\xB1\\\xC3$\xCB\xD2\x80\x85\xB6\xEC\x18B\xCC\xE4\r\xC3\x93\xA1j*4P\x97\xF0y\x17\xD7P\x11h\x96iq\xFE\x7F\xDB\x10(\n\xDC\xB8\xAF8ESe>\xDD"
つまりBCryptは、この文字列の冒頭が終端だと判断して処理を終了してしまい、残りのハッシュ化をしません(空文字列を渡された場合と実質同じ処理になる)。
やはりRuby実装でも、BCryptは0x00を終端文字列と解釈し、残りの文字列を切り捨ててしまうことが確認できます。
今後の動き
この問題についてはbcrypt-rubyリポジトリでもイシューがいくつか出ているようで、「文字列中に0x00が入ってきた場合には例外を吐かせる」というパッチを我らがたこやきアーロン巨匠が提案してくれています。
これによって、何かの間違いでうっかりBCryptにバイナリを食わせる仕様にしてしまったとしても、上で書いたような「後ろが切り捨てられるパスワード」についてはDBに投入される前に阻止できそうです。
みんなでいいねを押して応援しましょう!
もし上記のようなオレオレ実装をしていた場合、このパッチが入るバージョン以降は一部のパスワードが通らなくなると思われるので、万が一そんなことがあった場合は注意してください。
セキュリティの分野において、ライブラリをハックしたりアルゴリズムを再発明するのは、かえって脆弱性を産むことになるので避けましょうということですね 😇
じゃあどうすりゃいいの
RubyGemsで確認すると、Argon2のリポジトリがあるようです。徳丸先生のブログによると、PHPのArgon2は72文字制限はなくバイナリセーフであるようだ、となっています(Ruby実装については不明)
私には使える・使えないの判断ができるほど実装が理解できていない上に、まだまだ利用実績が少ないため(編集時点で64,607ダウンロード。対するBCryptは51,359,582ダウンロード)紹介するのは控えます。
もっと乱暴に「SHAだけを通す」という方法が考えられますが、今度は短いパスワードがレインボーテーブルで簡単に突破される、という状態になり極めて脆弱になります。(ためしに SHA rainbow table
でググって出てきたサイトに、SHA512でハッシュ化した短いパスワードを投げてみたところ、ものの数秒で解読されました)。パスワード管理については流出リスクなどを考えて使わない方がよいと思われます。
というわけで、素直にBCryptを使い、長いパスワードを末尾部分についてはあきらめて「72文字までの一致を見る」(入力パスワードが72文字を超えた場合はユーザーにエラーを返す)ということで割り切るのが、現時点ではもっとも素直な解決策のように思いました。
追記
2/26 ご指摘を受けてタイポ修正、一部わかりにくい表現を追記・編集しました