SQLインジェクション対策
SQLインジェクションはWebアプリケーションで最も危険な脆弱性の1つ。仕組みを理解し、プリペアドステートメントとORMで確実に防御する方法を解説する。
この記事の対象読者
- SQLインジェクションの仕組みを正しく理解したいエンジニア
- データベースを使用するWebアプリケーションの開発者
- Python / PHP / Node.js でSQLインジェクション対策を実装したい方
- 既存アプリケーションのセキュリティを見直したい方
目次
Step 1SQLインジェクションとは
SQLインジェクションは、ユーザーの入力値がSQL文に直接組み込まれることで、攻撃者がSQL文の構造を改ざんし、データベースを不正に操作する攻撃です。
OWASP Top 10でも常に上位にランクされており、データ漏洩、データ改ざん、認証バイパスなど深刻な被害を引き起こします。
# 絶対にやってはいけないパターン
def get_user(username):
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)
return cursor.fetchone()上記のコードでは、username に ' OR '1'='1 と入力されると、SQL文が以下のように変化します。
SELECT * FROM users WHERE username = '' OR '1'='1''1'='1' は常に真なので、すべてのユーザーのデータが返されてしまいます。
重要: 文字列結合やフォーマット文字列でSQL文を組み立てることは、SQLインジェクションの最大の原因です。このパターンを見つけたら即座に修正してください。
Step 2攻撃の具体例
認証バイパス
ユーザー名: admin' --
パスワード: (何でもよい)
生成されるSQL:
SELECT * FROM users WHERE username = 'admin' --' AND password = '何でもよい'
-- 以降はコメントとして無視されるため、パスワードチェックがスキップされるUNION攻撃(データ抽出)
入力値: ' UNION SELECT id, username, password FROM users --
生成されるSQL:
SELECT name, price FROM products WHERE category = '' UNION SELECT id, username, password FROM users --'
別テーブル(users)のデータが結果に含まれるデータ破壊
入力値: '; DROP TABLE users; --
生成されるSQL:
SELECT * FROM products WHERE name = ''; DROP TABLE users; --'
usersテーブルが削除されるブラインドSQLインジェクション
エラーメッセージが表示されない場合でも、レスポンスの違い(応答時間、ページの内容の変化)を利用して情報を1ビットずつ推測する攻撃です。
入力値: ' OR IF(SUBSTRING(@@version,1,1)='5', SLEEP(5), 0) --
MySQL 5.x の場合、5秒遅延する → バージョン情報が推測できるStep 3対策1: プリペアドステートメント(パラメータ化クエリ)
プリペアドステートメントは、SQL文の構造とデータを分離する仕組みです。データベースエンジンがSQL文の構造を先に解析し、後からパラメータを安全にバインドするため、入力値がSQL文の構造を変更することは原理的に不可能になります。
Python(sqlite3)
# プレースホルダ(?)を使用
def get_user(username):
query = "SELECT * FROM users WHERE username = ?"
cursor.execute(query, (username,))
return cursor.fetchone()Python(psycopg2 — PostgreSQL)
def get_user(username):
query = "SELECT * FROM users WHERE username = %s"
cursor.execute(query, (username,))
return cursor.fetchone()PHP(PDO)
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();Node.js(mysql2)
const [rows] = await connection.execute(
'SELECT * FROM users WHERE username = ?',
[username]
);原則: SQL文に値を埋め込む場合は、必ずプリペアドステートメントのパラメータバインディングを使用してください。文字列結合は絶対に使わないでください。
Step 4対策2: ORMを使う
ORM(Object-Relational Mapping)を使用すると、SQL文を直接書く必要がなくなり、SQLインジェクションのリスクが大幅に低減します。ORMは内部的にプリペアドステートメントを使用しています。
Django ORM
# Django ORMは自動的にパラメータ化クエリを使用
user = User.objects.get(username=username)
# フィルタリング
products = Product.objects.filter(
category=category,
price__lte=max_price
)
# Q オブジェクトによる複雑な条件
from django.db.models import Q
results = Product.objects.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)SQLAlchemy(Python)
# SQLAlchemy ORMも自動的にパラメータ化
user = session.query(User).filter_by(username=username).first()
# 複雑なクエリ
from sqlalchemy import and_, or_
results = session.query(Product).filter(
and_(
Product.category == category,
Product.price <= max_price
)
).all()注意: ORMを使っていても、raw() や extra()、RawSQL() などで生SQLを書く場合はSQLインジェクションのリスクがあります。生SQLを使う場合は必ずパラメータバインディングを使用してください。
# 安全: パラメータバインディングを使用
User.objects.raw('SELECT * FROM users WHERE username = %s', [username])
# 危険: 文字列フォーマットで埋め込み
User.objects.raw(f'SELECT * FROM users WHERE username = \'{username}\'')Step 5対策3: 入力検証とホワイトリスト
プリペアドステートメントが使えない箇所(テーブル名、カラム名、ORDER BY句など)では、入力検証とホワイトリストで防御します。
テーブル名・カラム名のホワイトリスト
ALLOWED_SORT_COLUMNS = {'name', 'price', 'created_at', 'updated_at'}
ALLOWED_SORT_ORDERS = {'ASC', 'DESC'}
def get_products(sort_by, order):
if sort_by not in ALLOWED_SORT_COLUMNS:
sort_by = 'created_at' # デフォルト値にフォールバック
if order.upper() not in ALLOWED_SORT_ORDERS:
order = 'ASC'
query = f"SELECT * FROM products ORDER BY {sort_by} {order}"
cursor.execute(query)入力値の型チェック
def get_product_by_id(product_id):
# 整数であることを検証
try:
product_id = int(product_id)
except (ValueError, TypeError):
raise ValueError("Invalid product ID")
query = "SELECT * FROM products WHERE id = %s"
cursor.execute(query, (product_id,))多層防御: 入力検証はプリペアドステートメントの代替ではなく、追加の防御層です。プリペアドステートメント + 入力検証の両方を実装するのがベストプラクティスです。
Step 6対策4: 最小権限の原則
データベースユーザーに必要最小限の権限のみを付与することで、万が一SQLインジェクションが成功しても被害を最小限に抑えられます。
-- 読み取り専用ユーザー(参照系API用)
CREATE ROLE app_readonly LOGIN PASSWORD 'secure_password';
GRANT CONNECT ON DATABASE myapp TO app_readonly;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
-- 読み書きユーザー(通常のアプリケーション用)
CREATE ROLE app_readwrite LOGIN PASSWORD 'secure_password';
GRANT CONNECT ON DATABASE myapp TO app_readwrite;
GRANT USAGE ON SCHEMA public TO app_readwrite;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_readwrite;
-- 以下の権限はアプリケーションユーザーには付与しない
-- DROP, CREATE, ALTER, TRUNCATE, GRANT| 原則 | 内容 |
|---|---|
| 参照系と更新系の分離 | 読み取り専用のAPIには読み取り専用のDBユーザーを使う |
| DDL権限を付与しない | アプリケーションにDROP TABLE等の権限を与えない |
| スキーマ単位の制御 | 必要なスキーマのみにアクセスを許可する |
| 接続元の制限 | pg_hba.confでアプリサーバーからのみ接続を許可する |
Step 7対策5: WAFとセキュリティテスト
アプリケーションレベルの対策に加えて、WAF(Web Application Firewall)とセキュリティテストを導入しましょう。
WAF(Web Application Firewall)
WAFはHTTPリクエストを検査し、SQLインジェクションのパターンを検知してブロックします。ただし、WAFはバイパスされる可能性があるため、アプリケーションレベルの対策の代替にはなりません。
セキュリティテストツール
# 基本的なテスト
sqlmap -u "https://example.com/api/users?id=1" --batch
# POSTリクエストのテスト
sqlmap -u "https://example.com/api/login" --data="username=test&password=test" --batch
# 全データベースの列挙(検証環境のみ)
sqlmap -u "https://example.com/api/users?id=1" --dbs --batch警告: sqlmap等のセキュリティテストツールは、必ず自分が管理する検証環境でのみ使用してください。許可なく他者のシステムに対して実行することは不正アクセスに該当します。
まとめ: SQLインジェクション対策の優先順位は、(1) プリペアドステートメント / ORM の使用、(2) 入力検証とホワイトリスト、(3) 最小権限の原則、(4) WAFとセキュリティテストです。(1)を徹底するだけで大部分の攻撃を防げます。