本文へ移動
購読
GUIDE#security更新 2026.09.24

WordPressサイトのセキュリティ対策:nginx設定・WAF・アカウント保護のチェックリスト

ログイン総当たり・xmlrpc.php・プラグイン脆弱性という3つの入口を、nginxの拒否設定とレート制限、Cloudflare WAF、二要素認証、バックアップと監視で塞ぐ手順。侵害時の初動も整理。

level
中級
time
約25分
stack
WordPress / nginx / Cloudflare
author
@ryoma_t
// 前提条件
nginxとPHP-FPMでWordPressを動かしていること / WP-CLIが使えること / Cloudflareのアカウント
wp core verify-checksumsと、xmlrpc・ユーザー列挙・uploads内PHP・隠しファイルへの403、ログインのレート制限をcurlで確認したターミナル
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

WordPressのセキュリティ対策とは、更新・アカウント・Webサーバー設定・WAF・バックアップ・監視の6つの層で、ログイン画面への総当たり、xmlrpc.php の悪用、プラグインの脆弱性という3つの主要な侵入口を塞ぐ作業です。世界で最も使われているCMSであるため、攻撃は「あなたのサイト」を狙ったものではなく、インターネット全体を機械的に巡回するボットが既知の入口を順に試す形で来ます。

想定する構成は、Ubuntu上のnginxとPHP-FPMで動くWordPressを、Cloudflareのプロキシ(WAF)の背後に置く形です。nginxの設定は行ごとに「何を防ぐか」を添え、Cloudflare側のルールは式の形で示します。プラグインに頼らずサーバー側で塞げるものはサーバーで塞ぎ、最後に確認用の curl コマンドと、侵害されたときの初動手順、チェックリストをまとめています。

この記事でわかること

  • wp-login.php・xmlrpc.php・REST API・uploadsディレクトリが、それぞれどう悪用されるか
  • nginxでxmlrpc.php拒否・uploads内PHP実行禁止・ユーザー名列挙防止・ログインのレート制限を書く方法と、locationの順序
  • Cloudflare WAFのカスタムルールとレート制限の式、そしてオリジンIPを隠さないとWAFが無意味になる理由
  • WP-CLIによるチェックサム検証・不審な管理者・改変ファイルの発見方法と、バックアップの復元テスト
  • 侵害に気づいたときの初動(隔離・証拠保全・鍵の再発行・復元)の順序

WordPressが狙われる理由:ログイン・xmlrpc・プラグイン

攻撃者の視点で見ると、WordPressは「入口の場所が全サイトで同じ」ことが最大の弱点です。ログイン画面は必ず /wp-login.php にあり、標準では試行回数の制限がありません。ユーザー名は /?author=1 にアクセスすると /author/ユーザー名/ にリダイレクトされて分かり、REST APIの /wp-json/wp/v2/users は未ログインでも投稿者の一覧を返します。つまりユーザー名は公開情報で、残るのはパスワードだけという前提で守る必要があります。

xmlrpc.php は古いモバイルアプリや外部投稿ツールのためのインターフェースで、system.multicall メソッドを使うと1回のHTTPリクエストに数百件の認証試行を詰め込めます。nginxのレート制限がリクエスト数で数えている限り、この経路は素通りです。加えて pingback.ping は指定したURLへサーバーからアクセスさせられるため、他所へのDDoSの踏み台にも使われます。執筆時点で管理画面やモバイルアプリはREST APIを使うので、ほとんどのサイトでは丸ごと止めて問題ありません。

そして実際の侵害の大半はコア本体ではなく、プラグインとテーマの脆弱性から起きます。認証なしで任意ファイルをアップロードできる、任意のオプションを書き換えられる、といった脆弱性が公開されると、数日以内に自動化されたスキャンが始まります。アップロードされたPHPファイルは wp-content/uploads/ に置かれ、そこでPHPが実行できる設定のままだと、それがそのままWebシェルになります。

更新とプラグインの最小化

コアのマイナー更新(セキュリティ修正)は標準で自動適用されます。小規模なサイトではメジャー更新も含めて自動にし、「更新で壊れるリスク」より「更新しないで侵害されるリスク」を取る方が結果として安全です。プラグインは1つずつ自動更新を有効にできるので、有効化した全プラグインで有効にし、月に1回はWP-CLIで状態を確認します。

terminal(WP-CLI)
cd /var/www/example.com
wp core check-update
wp plugin list --update=available          # 更新が残っているプラグイン
wp plugin list --status=inactive           # 無効化されたまま残っているプラグイン → 削除対象
wp plugin delete $(wp plugin list --status=inactive --field=name)
wp theme list --status=inactive --field=name | grep -v twentytwentyfive | xargs -r wp theme delete

無効化しただけのプラグインを残してはいけません。プラグインのPHPファイルはWordPressの有効・無効に関係なくWebから直接アクセスでき、単体で動く脆弱なファイルは無効化していても悪用されます。導入時の基準は「最終更新が1年以内」「有効インストール数が十分」「同じ機能を既に別のプラグインやテーマが持っていない」の3つで、プラグインの数を減らすこと自体が最も効く対策です。標準テーマを1つだけ残すのは、有効テーマが壊れたときのフォールバック用です。

アカウント保護:ユーザー名の露出・MFA・権限

やることは4つです。(1)admin というユーザー名を使わない(既にあるなら新しい管理者を作ってから削除し、投稿は引き継ぐ)。(2)記事を書くアカウントと管理者アカウントを分け、投稿は「編集者」権限で行う。表示名とログイン名を同じにしない。(3)管理者全員に二要素認証を必須にする。TOTPとパスキー(WebAuthn)に対応した二要素認証プラグイン(WordPress.orgで公開されている「Two-Factor」など)を使い、バックアップコードも発行しておく。(4)ダッシュボードからのテーマ・プラグイン編集を禁止する。侵入後に最初にやられるのがテーマファイルへのバックドア挿入で、DISALLOW_FILE_EDIT でその経路を塞げます。

wp-config.php(追記)
define('DISALLOW_FILE_EDIT', true);     // 管理画面のテーマ・プラグインエディタを無効化
define('WP_AUTO_UPDATE_CORE', true);    // コアのメジャー更新も自動で適用
define('FORCE_SSL_ADMIN', true);        // 管理画面とログインを常にHTTPSに
define('WP_DEBUG', false);              // 本番でエラー内容をブラウザに出さない

ユーザー名の列挙は、REST APIのユーザー一覧を未ログインでは返さないようにして塞ぎます。テーマの functions.php ではなく wp-content/mu-plugins/ に置くと、テーマを切り替えても設定が消えません。外部からのREST・XML-RPC連携を使わないなら、アプリケーションパスワードも無効化します。

wp-content/mu-plugins/harden.php
<?php
// 未ログインでは /wp-json/wp/v2/users を無効化(ユーザー名列挙の防止)
add_filter('rest_endpoints', function ($endpoints) {
    if (!is_user_logged_in()) {
        unset($endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)']);
    }
    return $endpoints;
});

// XML-RPC を無効化(nginx でも遮断するが、設定漏れに備えて二重に)
add_filter('xmlrpc_enabled', '__return_false');

// アプリケーションパスワード(REST 用のパスワード)を使わないなら無効化
add_filter('wp_is_application_passwords_available', '__return_false');

nginxでの防御設定:xmlrpc拒否・uploads内PHP禁止・隠しファイル・レート制限

nginxのlocationは「= の完全一致→最長の前置一致→正規表現は書いた順」で評価されます。uploads内のPHPを拒否するブロックは、PHPをFPMへ渡す location ~ \.php$ より前に書かないと、後者が先にマッチして実行されてしまいます。以下は、この順序を守った例です。

/etc/nginx/conf.d/wp-limits.conf(httpコンテキスト)
# 送信元IPごとに wp-login.php へのリクエストを平均 30回/分(2秒に1回)に制限するゾーン
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=30r/m;
/etc/nginx/sites-available/example.com(抜粋)
server {
    listen 443 ssl http2;            # nginx 1.25.1 以降は "listen 443 ssl;" と "http2 on;" に分ける
    server_name example.com;
    root /var/www/example.com;
    index index.php;

    # --- 拒否系はすべて PHP の location より前に置く ---
    location = /xmlrpc.php { deny all; access_log off; log_not_found off; }

    # uploads / cache 配下の PHP を実行させない(アップロード脆弱性経由のWebシェル対策)
    location ~* ^/wp-content/(?:uploads|cache)/.*\.php$ { deny all; }

    # 隠しファイル(.git, .env, .htaccess など)。ACME用の .well-known だけ許可
    location ~ /\.(?!well-known/) { deny all; access_log off; log_not_found off; }

    # 設定ファイル・バージョン情報・エディタや退避のゴミ
    location ~* ^/(?:wp-config\.php|wp-config-sample\.php|readme\.html|license\.txt)$ { deny all; }
    location ~* \.(?:bak|old|orig|save|swp|sql|log)$ { deny all; }

    # ログイン画面のレート制限。超過分は 429 を返す
    location = /wp-login.php {
        limit_req zone=wplogin burst=10 nodelay;
        limit_req_status 429;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location / {
        # ?author=N によるユーザー名列挙を拒否
        if ($args ~* "^author=\d+") { return 403; }
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
}

rate=30r/m と burst=10 nodelay の組み合わせは、「平均して2秒に1回まで、瞬間的には10回まで待たせずに通し、それを超えた分は即座に429で拒否」という意味です。人間のログインはページ表示に1回、送信に1回なので、パスワードを3回間違えても引っかかりません。総当たりは秒間数十回で来るので、最初の10回以降はすべて拒否されます。$binary_remote_addr はクライアントのIPで数えるため、Cloudflareの背後では次の章のrealip設定がないと「Cloudflare全体で1つのIP」と見なされ、正規の利用者まで巻き込みます。

オフィスの固定IPからNAT越しに数十人が同じサイトを編集している場合、burst=10 では朝の一斉ログインで429が出ることがあります。geo ディレクティブで社内IPだけ制限を外すか、burst を利用者数に合わせて増やしてください。制限を緩めるときは rate ではなく burst を触る方が、総当たりへの効果を保てます。

WAFとは:Cloudflare WAFとレート制限の設定

WAF(Web Application Firewall)は、HTTPリクエストのパス・ヘッダー・本文を検査し、SQLインジェクションやXSS、既知のプラグイン脆弱性を突くパターンを、アプリケーションに届く前に遮断する仕組みです。ポート単位で判断するネットワークファイアウォールとは層が違い、「443番は許可しているが、この本文を含むリクエストは拒否する」という判断ができます。CloudflareのWAFを使うには、DNSレコードをプロキシ(オレンジ色の雲)にし、SSL/TLSの暗号化モードを「フル(厳密)」にしてオリジン側にも有効な証明書(Cloudflare Origin CAで発行したもので構いません)を置きます。「フレキシブル」はCloudflareとオリジンの間が平文になるので使いません。

Cloudflare側の機能止められるもの設定する内容の例
マネージドルール(WAF)SQLi・XSS・既知のWordPress/プラグイン脆弱性のシグネチャ「セキュリティ」→「WAF」→「マネージドルール」で有効化(無料プランでも一部のルールセットが使える。執筆時点)
カスタムルール特定パスへの国別・条件別の遮断やチャレンジ(http.request.uri.path eq "/xmlrpc.php") → ブロック
カスタムルール国外からの管理画面アクセス(http.request.uri.path eq "/wp-login.php" and ip.src.country ne "JP") → マネージドチャレンジ
レート制限ルールログインの総当たりパスが /wp-login.php かつメソッドが POST、同一IPから10秒に5回超でブロック
ボット対策(Bot Fight Mode)既知の悪性ボット有効化するだけ。正規のAPIクライアントが弾かれる場合は除外ルールを作る

管理画面全体を国内限定にする場合は、/wp-admin/admin-ajax.php を除外してください。多くのテーマやプラグインが未ログインの閲覧者からもこのファイルを呼ぶため、含めると海外からの閲覧でお問い合わせフォームや無限スクロールが動かなくなります。式で書くと (starts_with(http.request.uri.path, "/wp-admin/") and not http.request.uri.path eq "/wp-admin/admin-ajax.php" and ip.src.country ne "JP") です。海外出張中に管理する可能性があるなら、ブロックではなくマネージドチャレンジにしておきます。

WAFはオリジンサーバーのIPアドレスが隠れていて初めて機能します。Cloudflareを通さずにオリジンのIPへ直接アクセスできれば、上のルールはすべて素通りです。IPは、プロキシを有効にしていないサブドメイン(mail. や ftp.)、過去のDNS履歴、そしてサーバーから直接送ったメールの Received ヘッダーから漏れます。対策は、オリジンの443番をCloudflareの公開IPレンジからだけ受け付けるようにufwやnftablesで絞ることです。さらに徹底するなら、オリジン側に受信ポートを一切開けないCloudflare Tunnel(cloudflared)に切り替えます。

/etc/nginx/conf.d/cloudflare-realip.conf
# Cloudflare 経由の実クライアントIPをログとレート制限に使う。
# レンジは Cloudflare が公開している一覧をすべて列挙し、変更に備えて定期的に更新する(以下は一部の例)
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 172.64.0.0/13;
real_ip_header CF-Connecting-IP;

バックアップと復元テスト

バックアップ対象は、データベース、wp-content/(uploads・plugins・themes・mu-plugins)、wp-config.php の3つです。コア本体は再ダウンロードできるので含めなくて構いません。バックアッププラグインは便利ですが、保存先が wp-content/ 配下だと、侵入者がサイトを消すときに一緒に消えます。サーバー側のcronで取り、WordPressの権限では触れない場所と、別の認証情報で書き込むオフサイト(オブジェクトストレージなど)の2か所に置いてください。

/usr/local/bin/wp-backup.sh
#!/bin/bash
set -euo pipefail
SITE=/var/www/example.com
DEST=/var/backups/wordpress          # www-data が書き込めないディレクトリにする
DATE=$(date +%F)
mkdir -p "$DEST"

# DB。認証情報は root の ~/.my.cnf に置き、コマンドラインに書かない(ps で見えるため)
mysqldump --single-transaction --quick wpdb | gzip > "$DEST/db-$DATE.sql.gz"

# ファイル
tar -czf "$DEST/files-$DATE.tar.gz" -C "$SITE" wp-content wp-config.php

# 30日を超えた世代を削除
find "$DEST" -type f -mtime +30 -delete

復元テストは四半期に1回、ステージング環境で「DBを入れて、URLを置換して、ログインできて、画像が表示される」まで確認します。wp db import db-2026-09-01.sql のあと wp search-replace 'https://example.com' 'https://staging.example.com' --skip-columns=guid でURLを置き換えるのが定型です。復元したことのないバックアップは、存在しないのと同じです。オフサイトの保存先を改ざん不能(イミュータブル)にする考え方はランサムウェア対策の記事で扱っています。

監視:ログ・改ざん検知・不審な管理者

侵害の兆候で最も多いのは「知らない管理者ユーザーが増えている」「コアやプラグインのファイルが書き換わっている」「uploadsにPHPファイルがある」の3つです。WP-CLIはコアとWordPress.org配布のプラグインについて、公式のチェックサムと突き合わせる機能を持っています。週次のcronで回し、差分があればメールで通知する形にします。

terminal
cd /var/www/example.com
wp core verify-checksums
# Success: WordPress installation verifies against checksums.   ← 正常
# Warning: File doesn't verify against checksum: wp-includes/... ← 改変か、Webシェルの疑い

wp plugin verify-checksums --all
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered

# uploads 配下の PHP(本来は存在しないはず)
find wp-content/uploads -type f -name '*.php'

# 直近24時間に変更された PHP(更新を当てた日以外に出るなら要調査)
find . -type f -name '*.php' -mtime -1 -not -path './wp-content/cache/*'

# ログイン試行の量(realip 設定後は実クライアントIPで集計できる)
grep 'POST /wp-login.php' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head

Cloudflareの「セキュリティ」→「イベント」では、どのルールが何を止めたかを国・パス・ルール別に見られます。ログインへの総当たりが「レート制限」で止まっている限りは正常で、「マネージドルール」でプラグインの脆弱性を突く試行が急増したら、そのプラグインの更新を確認する合図です。

侵害されたときの初動

  1. 公開を止める:Cloudflareの「I'm Under Attack」モードにするか、nginxで自分のIP以外に503を返します。閲覧者にマルウェアを配っている可能性があるので、原因調査より先です。
  2. 証拠を保全する:掃除を始める前に、サイト全体とDBのダンプ、nginx・PHP-FPMのログを別の場所にコピーします。侵入経路を特定できないと、復旧後に同じ穴から再侵入されます。
  3. すべての秘密を入れ替える:WordPressの全ユーザーのパスワード、wp config shuffle-salts でログインCookieの鍵、DBのパスワード、サーバーのSSH鍵、連携しているAPIキー。侵入者が持ち出した認証情報を無効にするためで、順番は「公開停止の直後」です。
  4. 侵入経路と改変を特定する:verify-checksums、知らない管理者、wp cron event list に見覚えのないイベント、mu-plugins と uploads のPHP、wp option get siteurl と home の書き換え(外部サイトへの転送マルウェアの典型)を確認します。
  5. 侵害前のバックアップから復元し、すべてを更新する:掃除より復元の方が確実です。復元後に脆弱だったプラグインを更新または削除してから公開を再開します。
  6. 報告と後始末:会員情報など個人データが含まれていれば、個人情報保護法に基づく個人情報保護委員会への報告義務と本人への通知が発生します。検索エンジンから警告が出ている場合はSearch Consoleで再審査を依頼します。

確認方法:設定が効いているかをcurlで見る

terminal(外部のネットワークから実行)
curl -sI https://example.com/xmlrpc.php | head -n 1                    # HTTP/2 403
curl -s  https://example.com/wp-json/wp/v2/users | head -c 120          # rest_no_route または rest_forbidden。ユーザー名が出なければOK
curl -sI "https://example.com/?author=1" | head -n 1                    # HTTP/2 403(302 で /author/... に飛べば未対策)
curl -sI https://example.com/wp-content/uploads/x.php | head -n 1       # HTTP/2 403(404 なら PHP の location が先に効いている)
curl -sI https://example.com/readme.html | head -n 1                    # HTTP/2 403
curl -sI https://example.com/.env | head -n 1                           # HTTP/2 403

# レート制限:15回連続で POST すると途中から 429 になる
for i in $(seq 1 15); do curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/wp-login.php; done

# オリジンIPへの直接アクセスが遮断されているか(オリジンのIPを指定。タイムアウトすれば正しい)
curl -sI --max-time 5 --resolve example.com:443:203.0.113.10 https://example.com/ | head -n 1

つまずきポイント

管理画面がリダイレクトループになる・「このページは動作していません」

Cloudflareの暗号化モードが「フレキシブル」のまま FORCE_SSL_ADMIN を有効にすると、オリジンはHTTPで受けているのにWordPressがHTTPSへ転送し続けてループします。モードを「フル(厳密)」にし、オリジンに証明書を置くのが正しい解決です。$_SERVER['HTTPS'] を無理やり書き換える回避策は、Cloudflareとオリジンの間が平文のままになるので採らないでください。

正規の利用者がログイン時に429を受ける

realip設定がなく、Cloudflareの1つのIPに全利用者が集約されているケースがほとんどです。/var/log/nginx/access.log の先頭のIPがCloudflareのレンジのままなら、cloudflare-realip.conf が読まれていないか、real_ip_header の綴りが違います。sudo nginx -T | grep real_ip で実効設定を確認してください。

国別チャレンジを入れたら海外からフォームが送れなくなった

/wp-admin/admin-ajax.php か /wp-json/ をルールに含めています。前者は未ログインの閲覧者からも呼ばれ、後者はブロックエディタやコンタクトフォームのプラグインが使います。管理画面の保護は /wp-login.php と /wp-admin/(admin-ajax.phpを除外)に限定してください。

「File doesn't verify against checksum」が出たが、本当に侵害か分からない

同じバージョンのWordPressを別ディレクトリにダウンロードし、diff で該当ファイルを比較します。差分が eval(base64_decode(...)) や見慣れない $_POST の処理なら侵害で、上の初動手順に進みます。wp-config.php や独自に置いたファイルは検証対象外なので警告に出ません。過去に誰かがコアを直接編集した「行儀の悪い修正」も同じ警告になり、これは修正を mu-plugins に移してコアを上書きし直すのが正解です。

チェックリスト

層項目確認方法
更新コア・プラグインの自動更新が有効。無効化プラグイン・未使用テーマが残っていないwp plugin list --status=inactive が空
アカウントadminユーザーなし、管理者は二要素認証、投稿は編集者権限、ファイル編集禁止wp user list --role=administrator、DISALLOW_FILE_EDIT
nginxxmlrpc拒否、uploads内PHP拒否、隠しファイル拒否、author列挙拒否、ログインのレート制限確認方法の章の curl 一式が期待通り
WAFプロキシ有効、フル(厳密)、マネージドルール、wp-loginのレート制限、オリジンIPをCloudflareに限定オリジンIPへの直接アクセスがタイムアウトする
バックアップDBとwp-contentを毎日、WordPressが触れない場所とオフサイトの2か所に。30日保持四半期ごとの復元テストの記録がある
監視チェックサム検証と管理者一覧を週次で自動確認、Cloudflareのイベントを月次で確認cronのログと通知が届いている

WordPressが動いているOS側のSSH・ファイアウォール・自動更新はLinuxサーバーの基本ハードニングの手順で先に固めておいてください。nginxとPHPの脆弱性はOSの自動更新で追従できますが、コンテナで運用している場合はイメージ側の更新が別に必要で、Trivyでの定期スキャンに含めます。

よくある質問

セキュリティプラグインは必要ですか?

この記事の対策のうち、二要素認証と改ざん検知の通知はプラグインで補う価値があります。一方、xmlrpc拒否やレート制限、uploadsのPHP禁止はnginxとCloudflare側で行う方が、PHPが起動する前に遮断できるぶん負荷も回避の余地も小さくなります。多機能な総合セキュリティプラグイン1つに全部を任せると、そのプラグイン自体の脆弱性が単一障害点になる点も考慮してください。

WAFだけで十分ですか?

不十分です。WAFは既知の攻撃パターンとレート超過を止めますが、漏れたパスワードでの正規ログイン、脆弱なプラグインの未知の悪用、オリジンIPへの直接アクセスは防げません。更新・二要素認証・nginx側の拒否設定・バックアップと組み合わせて、どれか1層が抜けても次の層で止まる構成にします。

侵害されたときの初動は?

公開停止、証拠の保全、全パスワードと鍵の入れ替え、侵入経路の特定、侵害前のバックアップからの復元と全更新、必要な報告、の順です。掃除から始めると証拠が消え、鍵の入れ替えを後回しにすると復旧中に再侵入されます。個人データを扱うサイトでは、個人情報保護委員会への報告期限を最初に確認してください。

まとめ

WordPressへの攻撃は「同じ場所にある入口」を機械的に叩くものなので、入口を減らす対策が最も効きます。プラグインを減らして自動更新し、adminを消して二要素認証を入れ、nginxで xmlrpc.php・uploads内のPHP・ユーザー名列挙を拒否してログインにレート制限をかけ、Cloudflareのレート制限とWAFを前に置いてオリジンIPを隠す。そのうえで、WordPressが触れない場所へのバックアップと、チェックサム検証・管理者一覧の週次確認で「気づける」状態を作ります。設定後は必ず外部から curl で1つずつ確かめてください。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。