フューチャー技術ブログ

Oktaのセキュリティの問題をGoで再現する

先日、Oktaでユーザー名が52文字を超えるとどのようなパスワードでもログインできてしまうという問題が公表されました。どういう原理なのか?というのが話題になりましたが下記のサイトに詳しく書かれています。

Okta AD/LDAP Delegated Authentication - Username Above 52 Characters Security Advisory

パスワードをサーバー側に保管するときに「プレーンテキストではなく、ハッシュ化しよう」というのは多くのソフトウェア開発者には常識になりつつあるかもしれません。ハッシュ化について強く言われ始めたのはここ15年とかだと思うので、まれに昔実装されていたまま放置されているとかはあるかもしれませんが・・・

しかし、このハッシュ化で使われるbcryptの参照実装含め、多くの実装では72バイトを超える文字列が来た場合に、72バイトに黙って切り詰めてから処理する実装が多いというのがあり、そのため、先頭に着けるソルトが長すぎて72バイトを超えてしまうと、パスワード部分が削除されてハッシュ化されてしまうため、どのようなパスワードでもログインできるようになってしまうと。

Oktaでは、ユーザーIDの20バイト以外に、ユーザー名をソルトとして使い、その後ろにパスワードを繋いだ文字列に対してbcryptをかけていたようで、ユーザー名が52文字を超えるとパスワードなしでログインできてしまうと。

userId + username + password

ソルトというのは、パスワードに付与する文字列です。偶然同じパスワードを使っているユーザーがいた場合に、そのままハッシュ化(あるいはストレッチ)すると同じハッシュ値になってしまいます。そうなると、一人のパスワードが解読されてしまうと同じパスワードだとばれてしまいます。そのため、個人ごとに異なる文字列を付与して同じパスワードでも別のハッシュの結果が得られるようにするための文字列がソルトです。ソルトは同一パスワードのハッシュが別のものになるようにするのが目的でそれそのものは秘密な情報ではありません。なので、ユーザーIDやユーザー名をソルトに使っていること自体は問題ありません。

Goでも試してみます。準標準ライブラリのbcryptをとってきます。

$ go get golang.org/x/crypto/bcrypt@v0.4.0

次に検証します。最初に作ったhashは、パスワードをハッシュ化したものです。それに対して別のパスワードを付与した入力値を与えるとエラーがnil(パスワード一致)になってしまうことがわかります。

package main

import (
"bytes"
"log"

"golang.org/x/crypto/bcrypt"
)

func main() {
password := []byte("password")
prefix := bytes.Repeat([]byte("a"), 72)

hash, _ := bcrypt.GenerateFromPassword(append(prefix, password...), bcrypt.DefaultCost)

wrongPassword := []byte("wrong-password")
err := bcrypt.CompareHashAndPassword(hash, append(prefix, wrongPassword...))
log.Println(err)
// nil
}

このコードではあえて古いバージョンを使いましたが、最新版では最初のハッシュ計算に限っては72文字を超えるとエラーになる実装が入っています。2022/11/15の修正でこのチェックが追加されました。入力値が変わっているはずなのにハッシュが変わらないのはおかしいよね、という理由で、今回の問題とは関係ない理由です。ただし比較する CompareHashAndPassword() の方は保存済みのハッシュとの互換性維持のため、チェックはされません。

現状バージョンでも、パスワードとその前の文字列の合計がぎりぎり72文字になるようにしてハッシュを生成すると、パスワードの前方が一致して後ろに余計な文字列が付いている、というケースであれば突破はできてしまいますね。だいぶ条件が厳しくはなりますが。

package main

import (
"bytes"
"log"

"golang.org/x/crypto/bcrypt"
)

func main() {
password := []byte("password")
prefix := bytes.Repeat([]byte("a"), 72-len(password))

hash, _ := bcrypt.GenerateFromPassword(append(prefix, password...), bcrypt.DefaultCost)

wrongPassword := []byte("passwordwrong")
err := bcrypt.CompareHashAndPassword(hash, append(prefix, wrongPassword...))
log.Println(err)
// nil
}

どう対策すべきか

ソルトにそれだけ長い文字列を設定できるようなロジックになっていることは稀かと思いますし、世の中はパスワードを32文字制限とかにしているサービスが多いと思うので、まあこの制限にひっかかることはないかなと思います。

万が一、検証時の入力が72文字を超える場合はエラーを返してパスワードリセットに誘導し、長すぎないソルトとパスワード全量が入るようにして再計算を行わせるとかしかないのかな、という気がします。ハッシュだけみてもパスワード長がわからないのでログイン操作をしてもらわないとわからないですからね。

あとは、別のパスワードストレッチアルゴリズムのargon2とかscryptの実装を見ると、文字列長の足切りはやってなさそうなので、新規で作る場合にはbcrypt以外のアルゴリズムを検討してみるのも良いかもしれません。