SQLインジェクション
SQLインジェクションの基本と対策、種類の整理。Python で実演しながら書いたメモ。
SQLインジェクションとは
ユーザーから受け取った入力を SQL に文字列連結で埋め込んだ結果、入力に含まれる SQL の構文が解釈されて、本来書かれていない問い合わせや更新が実行される脆弱性。
Python で実演
sqlite3 で users テーブルを用意する。
import sqlite3
conn = sqlite3.connect(":memory:")cur = conn.cursor()cur.executescript(""" CREATE TABLE users (id INTEGER, name TEXT, password TEXT); INSERT INTO users VALUES (1, 'admin', 'super-secret'); INSERT INTO users VALUES (2, 'alice', 'hunter2');""")脆弱なログイン関数。入力を f-string で SQL に埋めている。
def login(user: str, password: str): sql = f"SELECT * FROM users WHERE name = '{user}' AND password = '{password}'" return cur.execute(sql).fetchall()攻撃1:コメントで残りを消す
login("admin' --", "anything")# => [(1, 'admin', 'super-secret')]実行された SQL:
SELECT * FROM users WHERE name = 'admin' --' AND password = 'anything'-- 以降はコメントになり、パスワード条件が消える。パスワードを知らずに admin で通る。
攻撃2:常に真の条件 + コメント
login("' OR '1'='1' --", "anything")# => [(1, 'admin', 'super-secret'), (2, 'alice', 'hunter2')]実行された SQL:
SELECT * FROM users WHERE name = '' OR '1'='1' --' AND password = 'anything'-- でパスワード条件をコメント化、'1'='1' が常に真なので全行が返る。アプリ側で「最初の1行を成功扱い」していれば、通常 ID=1 のユーザーで認証が通る。
解説
文字列連結だと、入力に含まれる ' がリテラル境界として SQL パーサに解釈される。受け取りたかった admin' -- は、文字列としてではなく「admin という文字列リテラル + コメント開始」として解釈される。
防ぐには、SQL の構文と値を分離して DB に渡す。これがプレースホルダ(パラメータバインディング)。値は構文として解釈されず、データとしてだけ DB に渡る。
対策
文字列連結をやめて、プレースホルダを使う。
cur.execute( "SELECT * FROM users WHERE name = ? AND password = ?", (user, password),)第1引数の SQL に ? を置き、第2引数のタプルで値を別に渡す。
? は何をしているのか
これはサニタイズ(値の中の危険な文字をエスケープしてから連結する)ではない。SQL文と値を別の引数として DB に渡して、SQL を先にパースしてから値を当てはめている。
sqlite3 ライブラリの中では、SQLite の C API がだいたい次の順で呼ばれている:
1. sqlite3_prepare_v2("SELECT ... WHERE name = ? AND password = ?") └─ SQL を最初にパースする。? は「値を受け取る穴」として認識される
2. sqlite3_bind_text(stmt, 1, user) └─ 1番目の ? に値を結びつける
3. sqlite3_bind_text(stmt, 2, password) └─ 2番目の ? に値を結びつける
4. sqlite3_step(stmt) └─ クエリを実行値が入るのは 2 と 3 のステップ。この時点で SQL のパースは終わっている。つまり、値がどんな文字列であっても、新しい SQL 構文として解釈される経路が存在しない。
' OR '1'='1' -- を入れても、それは name 列と比較する1個の文字列値として扱われるだけで、OR も -- も SQL のキーワードとして読み直されない。
プレースホルダの記号はライブラリで違う(? / %s / :name)が、SQL と値を別経路で渡す発想は同じ。
プレースホルダで縛れない箇所
プレースホルダは「値」しか縛れない。ORDER BY の列名や IN (?, ?, ?) の要素数のように、SQL の構文構造そのものを動的にしたい場合は使えない。
# これは ? で書けない(列名は値ではない)sql = f"SELECT * FROM users ORDER BY {sort_column}"ここはホワイトリストで照合する:
ALLOWED = {"id", "name", "created_at"}if sort_column not in ALLOWED: raise ValueError("invalid sort column")sql = f"SELECT * FROM users ORDER BY {sort_column}"SQLi の種類
| 分類 | 概要 |
|---|---|
| In-band (Classic) | 通常のレスポンスに結果が混ざるタイプ。今回の ' OR '1'='1' -- はこれ |
| ┗ Error-based | DB のエラーメッセージに中身が混じる挙動を利用して情報を抜く |
| ┗ Union-based | UNION SELECT を差し込んで別テーブルの中身を結果に混ぜる |
| Blind | レスポンスに結果は出ないが、挙動の差から1ビットずつ抜く |
| ┗ Boolean-based | 真偽で表示が変わるのを利用。「先頭文字は a か?」を繰り返して読む |
| ┗ Time-based | SLEEP(5) のような構文で応答時間に差を作って読む |
| Out-of-band | DNS や HTTP で外部サーバに情報を送らせる |
Blind 系(Boolean-based・Time-based)は別記事で実演する予定。