flawopen.com/SQLインジェクション/Python

Python における SQLインジェクション

致命的 CWE-89 ドラフト —— レビュー待ち
Language: English Deutsch Español Français हिन्दी Bahasa Indonesia 日本語 한국어 Português (Brasil) Русский 简体中文
やさしい説明

482のようなチケット番号だけを想定しているフォームを思い浮かべてください。SQLインジェクションとは、誰かがそこに数字の代わりに細工した文字列を入力し、システムが「チケット482だけ」ではなく「すべてのチケットを見せて」と答えてしまう現象です。システムが受け取った値が本当に数字だけかどうかを、一度も確認していなかったために起こります。

このページの重要な用語
ユーザー制御入力
アプリケーションを使う人——あるいは攻撃する人——に由来するあらゆる値のこと。フォームの入力欄、URLパラメータ、アップロードされたファイル名、HTTPヘッダーなど。アプリケーション側は、それが正しい形式で安全だと決して前提にできません。
SQLクエリ
データベースに送られるコマンド。「この行を取得する」「このテーブルを削除する」など。その意味は正確なテキストだけで決まるため、そこに余分なテキストを注入することが危険になります。

何が起きているか

SQLインジェクションは、ユーザー制御入力が独立した値として渡されるのではなく、データベースクエリのテキストに直接組み込まれることで発生します。文字列をつなぎ合わせてクエリを組み立てるコードでは、攻撃者はクエリの実際の構造を変えてしまうような入力を送り込めます——「1行だけ取得する」クエリを、全行を返すクエリや、テーブルを削除するクエリに変えてしまうのです。

Pythonでは、ほぼ決まって同じ形で現れます。データベース呼び出しを、データベースドライバ組み込みのパラメータプレースホルダーではなく、f文字列や % フォーマット、あるいは単純な + 連結で組み立ててしまうケースです。

実際に起きた影響

2015年、英国の通信事業者TalkTalkは、買収によって引き継いだ古いWebページのSQLインジェクション脆弱性を攻撃者に悪用され、15万人以上の顧客に影響が及ぶ情報漏えいを起こしました。英国のデータ保護当局はTalkTalkに40万ポンドの制裁金を科し、この不備を「防止可能で基本的な失敗」と評しています。

出典:英国情報コミッショナー事務局(ICO)の制裁通知、2016年 —— 詳細は下記「参考資料」を参照。

脆弱な実装 vs. 修正済みの実装

脆弱
# user_id はリクエストから直接渡される
def get_user(cursor, user_id):
    query = f"SELECT * FROM users WHERE id = {user_id}"
    cursor.execute(query)
    return cursor.fetchone()
修正済み
# 値は別途渡され、テキストに埋め込まれない
def get_user(cursor, user_id):
    query = "SELECT * FROM users WHERE id = %s"
    cursor.execute(query, (user_id,))
    return cursor.fetchone()

なぜこの修正で解決するのか

修正版では、クエリのテキストと値を、execute() に対して2つの別々の引数として渡しています。データベースドライバもこれらを分けてデータベースに送信するため、値が付加される前にクエリの構造が確定しており、値に何の文字が含まれていようとSQL構文の一部として解釈されることは決してありません。f文字列ではこれを実現できません。execute() がクエリを受け取る時点で、値はすでにコマンドの一部であったかのようにテキストへ焼き込まれてしまっているからです。

Python特有の注意点

execute() 内での % フォーマットは、パラメータ化されているように見えて実はそうではない

cursor.execute("... WHERE id = %s" % user_id) はf文字列と同じくらい脆弱です。プレースホルダーが安全になるのは、値が execute() 自体の第2引数として渡された場合のみです —— cursor.execute("...WHERE id = %s", (user_id,)) のように書くことで、置換を行うのがPythonの文字列フォーマットではなくドライバになります。

ORMはデフォルトでパラメータ化するが、そのエスケープハッチはしない

DjangoのORMやSQLAlchemyのクエリビルダーは、通常のクエリでは自動的にパラメータ化を行います。しかし Model.objects.raw() やSQLAlchemyの text() を使い、その生のSQLをf文字列で組み立てた瞬間に、リスクが戻ってきます。

プレースホルダーの構文はドライバごとに一貫していない

psycopg2(PostgreSQL)はカラムの型に関わらず %s を使い、sqlite3? を使います。あるドライバのドキュメントのプレースホルダー形式を別のドライバにそのまま持ち込むと、静かに失敗します——思い込みで進めず、実際に使っているドライバのパラメータ形式を確認してください。

よくある誤解

「ORMを使っているから自動的に安全」

ORMの通常のクエリAPIを使う限りは正しいですが、raw()text() を使って自分で文字列を組み立てた瞬間、それは成り立たなくなります。

「このIDは常に数値だから、埋め込んでも安全」

リスクは実行時の値の型にあるのではなく、クエリがそもそも文字列補間によって組み立てられていること自体にあります。コードのライフサイクルのどこかでその前提が崩れた瞬間、脆弱性はすでにそこで待ち構えています。

「自分でクォートをエスケープしているから、パラメータ化クエリは不要」

手動エスケープはドライバ依存であり、気づきにくい形で間違えやすいものです。パラメータ化クエリは「より厳密なエスケープ」ではなく、値がクエリのテキストの一部に決してならないことで、問題そのものを完全に回避します。

自分のコードが影響を受けていないか確認する方法

grep -rn "execute(f\"" --include="*.py" . grep -rn "execute(.*%\s*(" --include="*.py" . grep -rn "\.raw(\|text(" --include="*.py" .
grepだけに頼るより、CIで Bandit(ルールB608、hardcoded_sql_expressions)を実行するほうが確実です。このパターンを自動的に検出し、新たに発生した場合はビルドを失敗させます。

予防チェックリスト

よくある質問

ORMを使えばSQLインジェクションから守られますか?

通常のクエリメソッドについてはそうです。しかし生クエリ用のエスケープハッチは違います——それらは手書きのSQLとまったく同じ安全性しかなく、それ以上ではありません。

このリスクは検索フォームのようなものに限られますか?

いいえ。外部の第三者が実質的に制御できるものはすべて対象です。HTTPヘッダー、アップロードされたファイル名、さらにはアプリケーションが信頼しているサードパーティAPIからの値も含まれます。

自分でクォートをエスケープするだけではだめですか?

できなくはありませんが、脆く、ドライバに依存します。パラメータ化クエリこそが本当の修正であり、より厳密なエスケープではありません。

参考資料

言語を切り替える: Python JavaScriptGoJava PHPC#Ruby C/C++RustKotlin Swift Solidity(対象外)
関連項目: コマンドインジェクションパストラバーサル XSS安全でないデシリアライゼーション