データベースが流出したとき、まっさきに問われるのは管理者パスワードです。投稿や設定だけでなく、wp_users テーブルまでそっくり相手の手に渡ります。あのパスワードは、復元されてしまうのか。今日はその問いに、保存されているハッシュを openssl で開いて答えます。

OpenSSL で手を動かして学ぶ暗号の基礎、番外編の3本目です。本編を読んでいなくても進められますが、ハッシュの一方向性は第1回、ソルトは第2回が下敷きになっています。

パスワードは戻せないが、当てられる

パスワードそのものを元に戻す(復号する)ことは、原理的にできません。パスワードはハッシュにして保存されるからです。暗号化と違い、ハッシュは一方向で、戻す計算式がそもそも存在しません(第1回でやったとおりです)。鍵を握られたら復号される、という類の話ではないのです。

ただし、質問を「当てられるか」に変えると、答えが変わります。攻撃者は、戻す代わりに、当てにきます。候補のパスワードを片っ端からハッシュして、漏れたハッシュと突き合わせる。一致すれば、それが答えです。弱いパスワードは、この総当たりで現実的な時間に判明します。

攻撃者は、当てにくる。
攻撃者 123456 ハッシュ 8d969eef…6c92 ちがう qwerty ハッシュ 65e84be3…37c5 ちがう hunter2 ハッシュ f52fbd32…f6c7 一致=これが答え 突き合わせ = f52fbd32…f6c7 データベースから漏れたハッシュ 攻撃者 候補を次々ハッシュ 123456 hunter2 ハッシュ ハッシュ 8d969eef…6c92 f52fbd32…f6c7 ちがう 一致=これが答え 突き合わせ = f52fbd32…f6c7 データベースから 漏れたハッシュ

候補を次々ハッシュして、漏れたハッシュと突き合わせる。弱いパスワードは、ここで一致する。

つまり「復元できるか」の答えは、パスワードの強さと、保存に使われたハッシュ方式の2つで決まります。残りは、その2つを実際に開いて確かめる話です。

user_pass に入っているもの

ユーザーのパスワードは、wp_users テーブルの user_pass 列に保存されます。入っているのはハッシュです。平文ではありません。第1回の最後に「データベースが流出しても、そこに並んでいるのは戻せないハッシュ値だけ」と書きました。その現物が、この列です。

値の先頭を見ると、どの方式で保存されたかが分かります。$方式$ソルト$ハッシュ という形をしていて、先頭の記号が方式の名札になっています。出てくるのは2種類です。

  • $P$ で始まる。6.7 以前の phpass 方式
  • $wp$ で始まる。6.8 以降の bcrypt 方式

2025年4月の 6.8 で、既定のハッシュ方式が切り替わりました。あなたのサイトの user_pass がどちらで始まっているかで、漏れたときの危険度が変わります。順に開いていきます。

openssl で、同じ形のハッシュを作る

本編は openssl の連載なので、まずは openssl で「$方式$ソルト$ハッシュ」の形を自分の手で作ってみます。openssl passwd は、パスワードをこの形式のハッシュにするコマンドです。

openssl passwd -6 -salt abcdefgh secret
# $6$abcdefgh$ltjgWl6579NluT/Vi1nwEvcil.G5Nbc4NiXZaNGStk8PSwGfQv72N2CKPPrVACtLtip/cZ/1GM/O6IND4WQhG.

-6 は SHA-512 系という方式の指定で、-salt はソルトの指定です。出てきた値は、$6$(方式)、abcdefgh(ソルト)、そのあとの長い塊(ハッシュ本体)に分かれています。これは WordPress の user_pass と同じ組み立てです。

ソルトを変えると、同じパスワードでもハッシュが総取っ替えになります。

openssl passwd -6 -salt zzzzzzzz secret
# $6$zzzzzzzz$049Gy1kKpDnrdlw.t.UKelLZ.2pbq5K9tFrtnXbOx5YImyoUusH3YQn3oDJLkYeKgqmjwOKp9VnPylO9CIxBy0

secret という同じパスワードなのに、ltjg… と 049G… でまるで違います。ソルトは、保存のたびに変える短いランダム値です。第2回で AES の暗号文の先頭に出てきた Salted__ と、同じ「ソルト」です。これがあるおかげで、同じパスワードを使う2人のユーザーが並んでも、保存された値は別物になります。「よくあるパスワードのハッシュ一覧」をあらかじめ作って一括で突き合わせる攻撃(レインボーテーブル)も、ソルトのぶんだけ無力になります。

同じパスワードでも、ソルトが違えば別のハッシュ。
パスワード ソルト 出てきた値 secret + abcdefgh ハッシュ ltjgWl65…QhG. 同じ ちがう まるで別物 secret + zzzzzzzz ハッシュ 049Gy1kK…xBy0 secret secret 同じ + + abcdefgh zzzzzzzz ちがう ハッシュ ハッシュ ltjgWl65…QhG. 049Gy1kK…xBy0 出てきた値は、まるで別物

secret は同じ。ソルト abcdefgh と zzzzzzzz で、出てくる値はまるで違う。

ここまでが、方式によらない土台です。これから見る2方式も、その上に立っています。

6.7 以前は、phpass の $P$

6.7 以前の WordPress が使っていたのは、phpass というライブラリのハッシュです。user_pass はこういう値でした(ソルトは毎回ランダムなので、手元では別の値になります)。

$P$BC.V9sHHwg/O0We9awGYXU5nGPakDR1

分解すると、$P$(phpass の名札)、次の1文字 B(ストレッチ回数を表す)、続く8文字 C.V9sHHw(ソルト)、残り22文字(ハッシュ本体)です。中身は、ソルトとパスワードをつないで MD5 にかけ、その結果をまた MD5 にかけ、と繰り返す仕組みです。B が表す回数は 8192 回。1回では速すぎて総当たりが楽になるので、8192回まわして1回の照合をわざと重くしています。これがストレッチ(鍵伸長)です。

ソルトもストレッチもあるのに、なぜ $P$ は要注意なのか。核にあるのが MD5 だからです。MD5 は1回の計算がとても軽く、GPU なら1秒あたり数十億回を回せます。8192回のストレッチを掛けても、専用ツール(hashcat の phpass モード)にかければ、辞書に載っているような弱いパスワードは現実的な時間に当たります。第1回の注意点で「MD5 と SHA-1 は改ざん検出に使わない」と書きましたが、パスワード保存でも、MD5 を土台にした方式は分が悪いのです。

6.8 以降は、bcrypt の $wp$2y$

2025年4月の 6.8 で、既定が bcrypt に変わりました。user_pass はこう始まります。

$wp$2y$10$M3hK7AY8m/bkYZxvKpbrhuhX5QYCeyLnaLkRdsbOJkJT.3MLD7qwi

$wp$ が WordPress の名札、続く $2y$ が bcrypt の名札、10 がコスト(後述します)です。ここで面白いのは、WordPress が bcrypt にかける前に、ひと手間を挟んでいることです。ソースを開くと、こうなっています。

// wp-includes/pluggable.php の wp_hash_password より
$password_to_hash = base64_encode( hash_hmac( 'sha384', trim( $password ), 'wp-sha384', true ) );
return '$wp' . password_hash( $password_to_hash, $algorithm, $options );

パスワードをそのまま bcrypt に渡さず、いったん HMAC-SHA384 という関数に通します。その結果を base64 にしてから bcrypt にかけ、頭に $wp を足しています。なぜこんなことをするかというと、bcrypt には「72バイトを超える入力は切り捨てる」という古い癖があるからです。長いパスフレーズを使う人のために、まず固定長へ畳んで、長さの情報を失わないようにしているわけです(wp-sha384 という文字列は、この用途専用だと区別するための鍵です)。

この前処理は、第1回で使った openssl dgst でそっくり再現できます。

printf '%s' 'secret' | openssl dgst -sha384 -mac HMAC -macopt key:wp-sha384 -binary | openssl base64 -A
# qbkKVZK1/fCOPlGm5IxC1KnGqNkFSywLf79RNfTtmzevzNwKU1eXGFovQ4UXkLyH

出てきた base64 が、WordPress が bcrypt に渡している値そのものです。連載でずっと使ってきた dgst が、WordPress のパスワード保存の一段目で、同じ形で働いています。

残る二段目、bcrypt そのものは openssl passwd では作れません。方式一覧に bcrypt はなく、-6(SHA-512 系)までです。bcrypt でハッシュするには、Apache の htpasswd -B か、PHP の password_hash($v, PASSWORD_BCRYPT) を使います。内部で使われているのは後者です。

$2y$ の次にあった 10 が、bcrypt の強さの正体です。これはコストと呼ばれ、計算の重さを表します。10 なら 2 の10乗、1024回ぶんの処理をまわす。phpass のストレッチと発想は同じですが、bcrypt は GPU で一気に並べて計算しづらいように設計されています。MD5 のように秒間数十億回とはいきません。同じ辞書攻撃をかけても、桁が何桁も違う時間がかかります。しかもコストは後から上げられるので、マシンが速くなったぶんだけ重くできます。

同じ総当たりでも、方式で速さが桁違い。
同じ辞書で総当たり phpass $P$・MD5 速い GPU で秒間数十億回 bcrypt $wp$ 遅い コストで抑える 同じ時間 同じ辞書・同じ時間で phpass $P$・MD5 速い GPU で秒間数十億回 bcrypt $wp$ 遅い コストで抑える

phpass は MD5 が軽く、GPU で一気に回せる。bcrypt はわざと重く、並べて計算しづらい。

第1回の注意点に「実際のパスワード保存に SHA-256 をそのまま使うことはありません。総当たりを遅くする仕掛けを足した専用の方式(bcrypt や Argon2 など)を使います」と書きました。6.8 で WordPress が phpass から bcrypt に移ったのは、まさにこの「遅くする」を強くした動きです。

アップグレードしても、古いハッシュは残る

気をつけたいのは、6.8 に上げただけでは、既存ユーザーの $P$ ハッシュが $wp$ に変わらないことです。ハッシュは一方向なので、保存済みの値から新方式へは作り直せません。書き換わるのは、そのユーザーが次にログインに成功した瞬間です。入力された平文をその場で bcrypt にかけ直して保存します。ソースでも、認証成功後にこう動いています。

// wp-includes/user.php より
if ( wp_password_needs_rehash( $user->user_pass, $user->ID ) ) {
    wp_set_password( $password, $user->ID );
}

裏を返すと、しばらくログインしていない管理者アカウントは、6.8 に上げたあとも古い $P$ のまま眠っています。滅多に使わない管理者ほど、弱い方式のまま残りがちです。

注意点

復元より怖いのは、パスワードの使い回し

ここまで「当てられるか」を方式の話にしてきましたが、実務でいちばん怖いのは、当てられた先です。そのパスワードを他のサービスでも使っていたら、1つ割られた瞬間に、被害はそのサイトの外へ広がります。逆に言えば、サイトごとに違うパスワードにしておけば、たとえ当てられても損害はそのサイトの中で止まる。管理者ほど、使い回さないことが効きます。

漏れたら、侵入された前提で動く

データベースが流出したということは、すでに中まで入られている可能性が高い、ということです。ハッシュが割られるかどうかとは別に、次を進めます。

  1. 全ユーザーのパスワードを強制的にリセットする
  2. wp-config.php のシークレットキーとソルト(AUTH_KEY など8つ)を作り直す。WordPress 公式が生成サービスを出していて、開くたびに define() 行が8本、そのまま貼れる形で返ってきます。キーを差し替えると、ログイン状態を保つクッキーがいっせいに無効になります。言い換えると、全ユーザーを一度に強制ログアウトさせられる。乗っ取られたセッションも、ここで切れる
  3. 不審な管理者ユーザーが増えていないか、wp_users と wp_usermeta を点検する。身に覚えのない admin は、流出後に作られたものかもしれない
  4. データベースの接続パスワードも変える。2要素認証を入れ、可能なら 6.8 以降へ上げて bcrypt に寄せる

シークレットキーとソルトが wp-config.php(データベースの外)にあるのは、この番外編の隠れた要点です。ログイン状態の証明は、このファイルの中の秘密と組み合わせて作られています。だからデータベースだけを持っていかれても、ログイン状態をそのまま偽造できません。とはいえ流出に気づいた以上、鍵は回しておく。第5回までに繰り返し出てきた「隠すものと、公開してよいもの」の区別が、ここでも効いています。

強いパスワードは、方式に先立つ

最後にもう一度、最初の問いに戻ります。$P$ でも $wp$ でも、辞書に載るような短いパスワードなら当てられます。逆に、長くてでたらめなパスワードなら、phpass の $P$ であっても現実的な時間には当たりません。方式は攻撃側の速度を左右しますが、当たるかどうかの最後の分かれ目は、いつも、選んだパスワードそのものの強さです。

本編第1回で「データベースが流出しても、そこに並んでいるのは戻せないハッシュ値だけ」と書きました。番外編は、その一文に隠れた但し書きを開いた回です。戻せはしない。けれど、弱ければ当てられる。6.8 の bcrypt 化は、その「当てられる」を格段に遠ざけました。あとは、あなたが選ぶ一語しだいです。

本編は第0回からどうぞ。