はじめに
最近話題になりましたSQLインジェクション。
いわゆる脆弱性ですが、とあるWebシステムで見つかったそうです。(すっとぼけ)
「SQLインジェクションなんて初歩的なミスを・・」とか、「名前は聞いたことあるけど・・」とか、「最近のフレームワークを使っていれば安心でしょ?」など、色々な意見があるかと思います。
今回は、敢えてSQLインジェクションの脆弱性を含んだシステムを作成してみたいと思います。
また、人気のあるLaravelというフレームワークで試します。
Laravelを使用したことがない人でも構築できるように、イチから解説しています。
対策や仕組みを知るには、実際に試してみるのが手っ取り早いですよね!?
本記事ではLaravelを使用して実際に試してみますが、SQLインジェクションはどの言語・フレームワークでも概念は同じです。
しっかりと仕組みを押さえておきましょう。
他にも私のブログで、Laravelについて解説している記事がありますのでご覧ください。
お断り
試す場合は、プライベートな環境(ローカル等)で行いましょう。
また、脆弱性のあるコードを本記事では扱いますが、使用しないように気をつけてください。
OWASP ZAPでの脆弱性診断
脆弱性診断をおこなう無料のツールがあります。
詳しくは以下の記事をご覧ください。
環境
【紹介】個人開発
私の個人開発ですがQuiphaというサービスを開発しました。(Laravel, Vue3など)
良かったら、会員登録して動作を試してみて下さい。
また、Laravel 9 実践入門という書籍を出版しました。
Kindle Unlimitedを契約している方であれば、読み放題で無料でご覧いただくことができます。
フレームワークについて
Webシステムなどはじめ、開発においては通常はフレームワークを使用します。
フレームワークとはその名の通り土台であり、予め機能が用意されていたり、ルールが決まっているなど、不具合を埋め込みにくくするメリットもあります。
その一環として、脆弱性に対する対応も考慮されているケースもあります。
今回紹介するLaravelも、普通に実装すれば、SQLインジェクションを埋め込む可能性は低いと思います。
逆に「フレームワークを使ったら脆弱性のあるコードは入り込まないよね?」というのは間違いですので、気をつけましょう。
フレームワークを使用する場合、きちんとフレームワークのマニュアルを読み、特性を知ることが大事です。
学習コストが必要ですが、結果的には工数削減に繋がります。
Laravelで実装(問題のないコード)
それでは早速、実装を行いましょう。
いきなり脆弱性のあるコードを埋め込むのではなく、まずは問題のない実装を行います。
プロジェクトの作成
まずはプロジェクトの構築です。
Laravel Sailを使った構築は以下の記事をご覧ください。
以下のコマンドで、Laravelのプロジェクトを作成します。
プロジェクト名は「sql-injection」にしました。(任意です)
$ curl -s https://laravel.build/sql-injection | bash
Sailを起動します。
$ sail up -d
Sailコマンドで、npmパッケージのインストールとビルドを行います。
$ sail npm install
$ sail npm run dev
以下のURLにアクセスし、初期画面が表示されることを確認します。
http://localhost
作成するサイト
テーブルの作成
テーブルを作成していきましょう。マイグレーションファイルを作成します。
初期状態でusersというテーブルが作成されますので、今回は別途アカウントテーブルを作成することにしました。
以下のコマンドを実行し、マイグレーションファイルを生成します。
$ sail php artisan make:migration create_accounts_table
マイグレーションファイルが作成されます。(ファイル名は現在時刻)
database/migrations/2021_05_19_140741_create_accounts_table.php
マイグレーションファイルを編集します。
今回は、ログインIDとパスワードの項目を追加します。(テーブル定義は細かく掘り下げません)
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('accounts', function (Blueprint $table) {
$table->id();
$table->string('login_id');
$table->string('password');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('accounts');
}
};
マイグレーションを実行し、テーブルを作成します。
$ sail php artisan migrate
この時点でMySQLのデータベースを確認し、テーブルが作成されていることを確認してください。
ダミーデータの作成
Laravelのシーダーという機能で、ダミーデータを作成します。
以下のコマンドを実行し、シーダーを作成します。
$ sail php artisan make:seeder AccountsSeeder
シーダークラスが作成されます。
database/seeders/AccountsSeeder.php
ログインIDとパスワードのダミーデータを挿入する処理を追加します。
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class AccountsSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
DB::table('accounts')->insert([
'login_id' => 'login-user',
'password' => 'password',
]);
}
}
シーダーを実行します。
$ sail php artisan db:seed --class=AccountsSeeder
アカウントテーブルにデータが挿入されたことを確認しましょう。
モデルの作成
先ほど作成したテーブルに対するモデルクラスを作成します。
コマンドを実行します。
$ sail php artisan make:model Account
クラスファイルが作成されますが、特に変更の必要はありません。
画面の作成
コマンドを実行し、コントローラーを作成します。
$ sail php artisan make:controller LoginController
web.phpを修正します。
ログイン入力画面と、結果画面のルーティングを追記します。
use App\Http\Controllers\LoginController;
Route::get('/login', [LoginController::class, 'index'])->name('login-index');
Route::post('/login', [LoginController::class, 'result'])->name('login-result');
続いて、画面テンプレートを作成します。(ログイン画面)
以下のファイルを作成します。
resources/views/login.blade.php
テンプレートファイルにログインフォームを記述します。
<html>
<body>
<form method="POST" action="{{ route('login-result') }}">
@csrf
<input type="text" name="login_id" placeholder="ID"><br>
<input type="text" name="password" placeholder="PASSWORD"><br>
<input type="submit" value="ログイン">
</form>
</body>
</html>
@csrfは、クロスサイトリクエストフォージェリ(CSRF)の対応です。(脆弱性の一つ)
また、actionのURLはroute経由で取得すると、web.phpの定義と連動できますので保守性が良くなるでしょう。
次に、以下のファイルを修正します。
App\Http\Controllers\LoginController.php
ログイン画面の表示と、ログイン情報の確認結果を表示する処理を実装します。
ここでは、テーブルからデータを取得するのに、LaravelのEloquentというORマッパーを使用します。
O/Rマッピングとは、オブジェクト指向プログラミング言語におけるオブジェクトとリレーショナルデータベース(RDB)の間でデータ形式の相互変換を行うこと。そのための機能やソフトウェアを「O/Rマッパー」(O/R mapper)という。
https://e-words.jp/w/O-R%E3%83%9E%E3%83%83%E3%83%94%E3%83%B3%E3%82%B0.html
フォームで入力されたIDとパスワードが、アカウントテーブルに一致するデータが存在するかどうかチェックしています。(exists)
このコードは安全です。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Models\Account;
class LoginController extends Controller
{
/**
* ログインフォーム
*/
public function index()
{
return view('login');
}
/**
* ログイン結果を表示
*
* @param Request $request
*/
public function result(Request $request)
{
$login_id = $request->input('login_id');
$password = $request->input('password');
// ログイン情報の確認
$exists = Account::where('login_id', $login_id)
->where('password', $password)
->exists();
if ($exists) {
return "Login OK!";
} else {
return "Login NG!";
}
}
}
それでは画面を開いてみましょう。
http://localhost/login
ログインフォームが表示れました。
IDに「login-user」、パスワードに「password」と入力しログインボタンをクリックすると、「Login OK!」と表示され、ログイン成功です。
それ以外のログイン情報の場合は、「Login NG!」と表示され、ログイン失敗となります。
引き続き、SQLインジェクションを含んだコードを実装します。
SQLインジェクションのコードを実装
実装
さて本題です。
先程までのコードは問題ないコードでしたが、どのようにすればSQLインジェクション(脆弱性あるコード)となるのでしょうか。
SQLインジェクションの場合は、SQL文を文字列連結で動的に組み立てると問題がある可能性があります。
Laravelでも生のSQLを実行する関数は一応用意されています。
Laravelのマニュアルに記載のある通り、取り扱いには注意です。
素のSQL文はそのまま文字列としてクエリへ挿入されるため、SQLインジェクションの脆弱性を含めぬように細心の注意を払う必要があります。
https://readouble.com/laravel/10.x/ja/queries.html
コントローラを以下のように修正します。
whereRawを使用し、SQL文を文字列連結して引数に渡します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Models\Account;
class LoginController extends Controller
{
/**
* ログインフォーム
*/
public function index()
{
return view('login');
}
/**
* ログイン結果を表示
*
* @param Request $request
*/
public function result(Request $request)
{
$login_id = $request->input('login_id');
$password = $request->input('password');
// ログイン情報の確認
// $exists = Account::where('login_id', $login_id)
// ->where('password', $password)
// ->exists();
// 注意:脆弱性のあるコード(SQLインジェクション)
$exists = DB::table('accounts')
->whereRaw('login_id = \'' . $login_id . '\'')
->whereRaw('password = \'' . $password . '\'')
->exists();
if ($exists) {
return "Login OK!";
} else {
return "Login NG!";
}
}
}
この状態でログインフォームから動作確認を行うと、問題のないコードと同じように正常に動くように見えます。
では、ログインIDはデタラメで、パスワードを以下にしてみましょう。
' or 1 = 1 or '
存在しないIDとパスワードですが、「Login OK!」と表示され、ログイン成功します。
あれれ、IDとパスワードが一致しないのに、
レコードが存在していることになっているよ・・
SQL文の確認
それでは、どのようなSQL文が実行されているか確認してみましょう。
クエリビルダの実行処理の前後に、SQL文を出力するコードを記述します。
\DB::enableQueryLog();
// 注意:脆弱性のあるコード(SQLインジェクション)
$exists = DB::table('accounts')
->whereRaw('login_id = \'' . $login_id . '\'')
->whereRaw('password = \'' . $password . '\'')
->exists();
dd(\DB::getQueryLog());
上記の状態で、リクエストを送信してみましょう。
実行されるSQL文が表示されます。
array:1 [▼
0 => array:3 [▼
"query" => "select exists(select * from `accounts` where login_id = 'login-user' and password = '' or 1 = 1 or '') as `exists`"
"bindings" => []
"time" => 6.3
]
]
SQL文のwhere句が、不正に組み立てられ、構文としては成立します。
また、「or 1 = 1」の条件により常に真となり、不正にレコードを取得することができます。
where login_id = 'login-user' and password = '' or 1 = 1 or ''
不正なリクエストではない場合は、当然、SQL文として問題なく実行されます。
array:1 [▼
0 => array:3 [▼
"query" => "select exists(select * from `accounts` where login_id = 'login-user' and password = 'password') as `exists`"
"bindings" => []
"time" => 3.02
]
]
またSQLインジェクションは、SQL文を不正に組み立て実行することができるため、シングルクオートやダブルクオートをリクエストに入れると、SQLの構文エラーでシステムエラーが発生する場合が多いです。
例えば、パスワードにシングルクオートだけ入力してログインしてみましょう。
SQL構文エラーで、システムエラーが表示されます。
SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ””) as `exists`’ at line 1 (SQL: select exists(select * from `accounts` where login_id = ‘hoge’ and password = ”’) as `exists`)
SQL文をリクエストパラメータの値と文字列連結で動的に作成すると、SQLインジェクションになる場合があります。
他にも
今回の例では、ログイン情報が存在するかどうかのチェックのみですが、例えばマスタメンテナンス画面で、データの一覧を表示する画面があったとします。
検索フォームを用意し、条件によって一覧を絞り込む実装はよくあると思います。
検索条件のWhere句にSQLインジェクションが含まれる場合、全てのデータを閲覧されてしまう可能性があります。
(「or 1 = 1」の条件によりすべてのデータを取得)
シンプルに情報流出にもなり、大変危険です。
プレースホルダーの利用
どうしてもwhereRawを使用しないといけない場合は、プレースホルダーでバインド変数を活用しましょう。
この場合は、SQLインジェクションにはなりません。
$exists = DB::table('accounts')
//->whereRaw('login_id = \'' . $login_id . '\'')
//->whereRaw('password = \'' . $password . '\'')
// プレースホルダーとバインド変数を利用する
->whereRaw('login_id = ?', [$login_id])
->whereRaw('password = ?', [$password])
->exists();
先程同様に、パスワードに不正なリクエストを含めて実行してみます。
今度は、「Login NG!」となり正常な動作になります。
' or 1 = 1 or '
SQL文を確認してみると、バインド変数が利用されているのが分かります。(SQL文自体は動的に変更されていない)
array:1 [▼
0 => array:3 [▼
"query" => "select exists(select * from `accounts` where login_id = ? and password = ?) as `exists`"
"bindings" => array:2 [▼
0 => "login-user"
1 => "' or 1 = 1 or '"
]
"time" => 4.07
]
]
とはいえ、whereRawを極力使用しないほうが良いでしょう。
他のフレームワークでも、SQL文を動的に作成して実行するのは避けたほうが良いと思います。
さいごに
SQLインジェクションを含んだコードを実装してみました。
最悪、データを不正に閲覧したり、削除される恐れもあります。
ちなみに公開されているサイトで、SQLインジェクションや脆弱性の確認は、不正アクセス禁止法に触れる場合もあるかもしれませんし、何よりもTwitterなどで公にせず、IPAに連絡をしましょう。
フレームワークを正しく利用すれば、概ね問題ないとは思いますが、脆弱性の仕組みを知ることにより、埋め込まないように気をつけましょう。
他にも私のブログで、Laravelについて解説している記事がありますのでご覧ください。
コメント