2026年5月20日

2026年5月20日

WordPressのSQLインジェクション対策をする方法

はじめに

SQLインジェクションは、攻撃者がデータベースクエリに悪意のあるSQL文を挿入する攻撃です。WordPressでは$wpdb->prepare()を使ったプレースホルダーによるパラメータバインディングが基本的な対策です。適切に実装することでデータ漏洩・改ざん・削除を防げます。

症状・原因

  • セキュリティスキャンでSQLインジェクション脆弱性が検出された
  • プラグインやテーマで$wpdb->query("SELECT ... WHERE id = " . $_GET['id'])のような直接埋め込みがある
  • ユーザー入力を直接SQLクエリに連結している
  • sqlmap等のツールでデータベースが抽出できた

解決手順

ステップ1:脆弱なコードパターンを検出する

# SQL クエリに変数を直接連結しているパターンを検索
grep -rn "\$wpdb->query.*\$_GET\|\$wpdb->query.*\$_POST" /var/www/html/wp-content/
grep -rn "\$wpdb->get_results.*\$_GET\|\$wpdb->get_results.*\$_POST" /var/www/html/wp-content/

# prepare を使っていないクエリを検索
grep -rn "->query(\"SELECT\|->query('SELECT\|->get_results(\"SELECT" /var/www/html/wp-content/themes/
grep -rn "->query(\"SELECT\|->query('SELECT\|->get_results(\"SELECT" /var/www/html/wp-content/plugins/

# 文字列連結でSQLを組み立てているパターン
grep -rn '"\s*\.\s*\$' /var/www/html/wp-content/ | grep -i "select\|insert\|update\|delete\|where"

ステップ2:$wpdb->prepare() でプレースホルダーを使う

// functions.php / プラグイン: 安全なDBクエリの書き方

global $wpdb;

// ① 整数値のプレースホルダー(%d)
// 悪い例
$user_id = $_GET['user_id'];
$results = $wpdb->get_results("SELECT * FROM {$wpdb->users} WHERE ID = $user_id");

// 良い例
$user_id = absint($_GET['user_id']); // 整数に変換
$results = $wpdb->get_results(
    $wpdb->prepare("SELECT * FROM {$wpdb->users} WHERE ID = %d", $user_id)
);

// ② 文字列値のプレースホルダー(%s)
// 悪い例
$name = $_POST['name'];
$wpdb->query("UPDATE {$wpdb->users} SET display_name = '$name' WHERE ID = 1");

// 良い例
$name = sanitize_text_field($_POST['name']);
$wpdb->update(
    $wpdb->users,
    ['display_name' => $name],  // データ(自動エスケープ)
    ['ID' => get_current_user_id()],  // 条件(自動エスケープ)
    ['%s'],  // データのフォーマット
    ['%d']   // 条件のフォーマット
);

// ③ 浮動小数点のプレースホルダー(%f)
$price = (float) $_POST['price'];
$wpdb->get_results(
    $wpdb->prepare("SELECT * FROM {$wpdb->prefix}products WHERE price <= %f", $price)
);

// ④ 複数の値を IN 句に渡す場合
$ids = array_map('absint', $_POST['ids']); // 全て整数に変換
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE ID IN ($placeholders)",
        ...$ids
    )
);

ステップ3:$wpdb の安全なメソッドを使う

// functions.php: $wpdb の組み込みメソッドで安全にCRUD操作

global $wpdb;
$table = $wpdb->prefix . 'my_table';

// ① INSERT: $wpdb->insert(自動エスケープ)
$wpdb->insert(
    $table,
    [
        'user_id' => get_current_user_id(),
        'content' => sanitize_textarea_field($_POST['content']),
        'created_at' => current_time('mysql'),
    ],
    ['%d', '%s', '%s']
);

// ② UPDATE: $wpdb->update(自動エスケープ)
$wpdb->update(
    $table,
    ['status' => 'published'],          // 更新データ
    ['id' => absint($_POST['id'])],     // WHERE条件
    ['%s'],                             // データのフォーマット
    ['%d']                              // 条件のフォーマット
);

// ③ DELETE: $wpdb->delete(自動エスケープ)
$wpdb->delete(
    $table,
    ['id' => absint($_GET['id'])],
    ['%d']
);

// ④ get_var / get_row / get_col / get_results + prepare
$count = $wpdb->get_var(
    $wpdb->prepare("SELECT COUNT(*) FROM $table WHERE user_id = %d", get_current_user_id())
);

ステップ4:入力値を事前にサニタイズ・バリデーションする

// functions.php: prepare の前に入力値を検証・サニタイズ

// ① 整数値の検証
function validate_id(mixed $value): int {
    $id = absint($value);
    if ($id <= 0) {
        wp_die('無効なIDです。', 400);
    }
    return $id;
}

// ② 文字列の検証(長さ・許可文字)
function validate_search_term(string $term): string {
    $term = sanitize_text_field($term);
    if (strlen($term) > 100) {
        wp_die('検索キーワードが長すぎます。', 400);
    }
    return $term;
}

// ③ REST API エンドポイントでの検証
add_action('rest_api_init', function(): void {
    register_rest_route('myapi/v1', '/items/(?P<id>\d+)', [
        'methods'             => 'GET',
        'callback'            => function(\WP_REST_Request $request): mixed {
            global $wpdb;
            $id = absint($request->get_param('id'));
            return $wpdb->get_row(
                $wpdb->prepare(
                    "SELECT * FROM {$wpdb->prefix}items WHERE id = %d",
                    $id
                )
            );
        },
        'permission_callback' => '__return_true',
        'args'                => [
            'id' => [
                'validate_callback' => fn($v) => is_numeric($v) && $v > 0,
                'sanitize_callback' => 'absint',
            ],
        ],
    ]);
});

ステップ5:WAF と エラー表示の無効化で防御を強化する

# ① WordPress デバッグ表示を無効化(エラーからDB情報が漏れるのを防ぐ)
# wp-config.php
# define('WP_DEBUG', false);
# define('WP_DEBUG_DISPLAY', false);
# define('DISPLAY_ERRORS', 0);

wp config set WP_DEBUG false --raw
wp config set WP_DEBUG_DISPLAY false --raw

# ② Nginx の WAF モジュールで SQL インジェクションを検出
# ModSecurity または NAXSI でリクエストをフィルタリング
grep -r "UNION\|SELECT.*FROM\|DROP TABLE\|INSERT INTO" /var/log/nginx/access.log | tail -20

# ③ Fail2ban で SQLi を検出してIPをBAN
cat > /etc/fail2ban/filter.d/wordpress-sqli.conf << 'EOF'
[Definition]
failregex = ^<HOST>.*"(GET|POST).*(UNION|SELECT.*FROM|DROP|INSERT.*INTO|1=1|OR 1=1)
ignoreregex =
EOF

注意事項

  • $wpdb->prepare()の第1引数にはプレースホルダー(%d%s%f)を使用し、値は第2引数以降に渡してください。%sは文字列をクォートで囲んで自動エスケープします
  • LIKE句を使用する場合は$wpdb->esc_like()を使って%_をエスケープした上で%sプレースホルダーを組み合わせてください:$wpdb->prepare("... LIKE %s", '%' . $wpdb->esc_like($term) . '%')
  • テーブル名やカラム名にはプレースホルダーが使えません。これらはユーザー入力から生成する場合は許可リスト(allowlist)でバリデーションしてください

まとめ

SQLインジェクション対策は①grep -rn "->query.*\$_GET"で脆弱パターンを検出、②$wpdb->prepare("... WHERE id = %d", $id)でプレースホルダーを使用、③$wpdb->insert()update()delete()で自動エスケープ、④absint()sanitize_text_field()でDB処理前にサニタイズ、⑤WP_DEBUG=falseでエラー表示を非公開にします。

お気軽にご相談ください

お見積りへ お問い合わせへ