DEV

SQLインジェクション

SQLインジェクションの基本と対策、種類の整理。Python で実演しながら書いたメモ。

SQLインジェクションとは

ユーザーから受け取った入力を SQL に文字列連結で埋め込んだ結果、入力に含まれる SQL の構文が解釈されて、本来書かれていない問い合わせや更新が実行される脆弱性。

Python で実演

sqlite3 で users テーブルを用意する。

setup.py
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 に埋めている。

vulnerable.py
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 に渡る。

対策

文字列連結をやめて、プレースホルダを使う。

safe.py
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}"

ここはホワイトリストで照合する:

whitelist.py
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-basedDB のエラーメッセージに中身が混じる挙動を利用して情報を抜く
┗ Union-basedUNION SELECT を差し込んで別テーブルの中身を結果に混ぜる
Blindレスポンスに結果は出ないが、挙動の差から1ビットずつ抜く
┗ Boolean-based真偽で表示が変わるのを利用。「先頭文字は a か?」を繰り返して読む
┗ Time-basedSLEEP(5) のような構文で応答時間に差を作って読む
Out-of-bandDNS や HTTP で外部サーバに情報を送らせる

Blind 系(Boolean-based・Time-based)は別記事で実演する予定。