Webセキュリティ

SQLインジェクション対策 — データベースを不正操作から守る

セキュリティ SQL データベース

SQLインジェクション対策

SQLインジェクションはWebアプリケーションで最も危険な脆弱性の1つ。仕組みを理解し、プリペアドステートメントとORMで確実に防御する方法を解説する。

この記事の対象読者

  • SQLインジェクションの仕組みを正しく理解したいエンジニア
  • データベースを使用するWebアプリケーションの開発者
  • Python / PHP / Node.js でSQLインジェクション対策を実装したい方
  • 既存アプリケーションのセキュリティを見直したい方

Step 1SQLインジェクションとは

SQLインジェクションは、ユーザーの入力値がSQL文に直接組み込まれることで、攻撃者がSQL文の構造を改ざんし、データベースを不正に操作する攻撃です。

OWASP Top 10でも常に上位にランクされており、データ漏洩、データ改ざん、認証バイパスなど深刻な被害を引き起こします。

脆弱なコードの例(Python)
# 絶対にやってはいけないパターン
def get_user(username):
    query = f"SELECT * FROM users WHERE username = '{username}'"
    cursor.execute(query)
    return cursor.fetchone()

上記のコードでは、username' OR '1'='1 と入力されると、SQL文が以下のように変化します。

改ざんされた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ビットずつ推測する攻撃です。

時間ベースのブラインドSQLインジェクション
入力値: ' 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を使う場合は必ずパラメータバインディングを使用してください。

Django: 生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インジェクションが成功しても被害を最小限に抑えられます。

PostgreSQL: アプリケーション用ユーザーの権限設定
-- 読み取り専用ユーザー(参照系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(SQLインジェクション自動検出ツール)
# 基本的なテスト
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)を徹底するだけで大部分の攻撃を防げます。