WordPressに無料のランタイム保護 Aikido Zen を本番導入した記録。deb 1つで入るSQLi/RCE対策、mu-plugin 1枚で効かせるwp-login.phpのレートリミット、PATH_INFOの落とし穴、そして「Attacks 0」の正しい読み方まで、すべて実測ベースで解説する。

つい先日も、BdThemes系プラグイン(Element Pack Lite、Prime Slider Lite等の7種)を狙ったサプライチェーン攻撃が報告された。プラグイン本体のコードは無改変のまま、プラグインが読み込むバナー配信APIの応答が汚染され、管理者がwp-adminを開くだけで攻撃が発火。不正な管理者アカウントの作成、偽プラグインとWebシェルの設置まで進む。
WordPressはコア・テーマ・プラグインという構造上、自分が書いていないコードを大量に動かすことになる。プラグインの脆弱性(SQLインジェクション、パストラバーサル、任意ファイルアップロード等)は毎週のように公表されており、さらにwp-login.phpへのパスワード総当たり、xmlrpc.phpの悪用、/wp-json/経由のユーザー列挙と、WordPressであるというだけで受ける攻撃は多い。「全部を追いかけて塞ぐ」のは現実的でない。
そこで、無料でできる防御の一枚として、Aikido Zenを紹介したい。筆者は自身のWordPress(本サイト含む)で実運用している。
Zen(Aikido Security)は、アプリケーションの内側で動くランタイム保護(in-appファイアウォール)だ。PHP版はPHP拡張としてサーバーに入るため、WordPress本体・テーマ・プラグインのコードには一切手を入れない。オープンソースで、導入自体は無料。Aikidoの無料プランに接続すれば、ダッシュボードでの攻撃検知の可視化まで使える(無料プランは月25万リクエストまで保護対象)。
WordPressの文脈で効くのは:
宣伝記事だからこそ、ここは正直に書いておく。
BdThemes攻撃の初動は、管理者ブラウザ内で実行されるXSS(汚染されたAPI応答由来)だ。これはクライアント側で起きるため、サーバー側ランタイム保護であるZenの守備範囲ではない。不正管理者の作成も「認証済み管理者のセッション」による正規のAPI呼び出しとして行われるため、これ単体をZenが弾くわけでもない。
Zenが効くのは、その前後だ。WordPressを日常的に叩いている攻撃の大半——プラグイン脆弱性を突くSQLi、パストラバーサル経由のファイル窃取、設置されたWebシェルからのコマンド実行——はZenの検知・ブロック対象になる。境界(CDN/WAF)、露出削減(ポート・アクセス制御)、そしてアプリ内側(Zen)。どれか一枚で全部を止める銀の弾丸はなく、Zenは「アプリの内側」という、無料では他に埋めにくい層を埋めるものだと理解してほしい。
筆者の環境は公式wordpressイメージベースのDocker構成。手順は4ステップ。
Aikidoにログイン → Zen → Add app → アプリ名を付けて Generate token。AIK_RUNTIME_... 形式のトークンをコピーする。Aikidoアカウントは無料で作成できる。


PHP版はdebパッケージ1つで入る。Composerも、WordPress側のコード変更も不要。PHP 7.2〜8.5対応なので、公式wordpressイメージならどのPHPタグでも入る。
既存構成が「imageを直接指定」の場合、Zenを焼き込むために image → build に切り替える。ディレクトリ構成はこうなる:
~/homepages/
├── docker-compose.yml
├── .env
└── zen/
└── Dockerfile
zen/Dockerfile の全文:
# 今使っているwordpressイメージのタグに合わせる
FROM wordpress:php8.1-apache
# Zen(PHP版)をdebで導入
RUN curl -L -O https://github.com/AikidoSec/firewall-php/releases/latest/download/aikido-php-firewall.x86_64.deb \
&& dpkg -i -E ./aikido-php-firewall.x86_64.deb \
&& rm ./aikido-php-firewall.x86_64.deb
(ARM/Graviton環境なら aikido-php-firewall.aarch64.deb に読み替え)
ベースイメージのタグは、現行composeの image: wordpress:xxx と同じものを指定する。ここを変えるとPHPバージョンごと変わってしまうので注意。
既存のwordpressサービスを次のように変更する。image: 行を build: に置き換え、環境変数を2つ足すだけで、他(volumes、DB接続等)は現行のまま:
services:
wordpress:
# image: wordpress:php8.1-apache ← これを削除して
build: ./zen # ← buildに置き換え
environment:
# (既存のWORDPRESS_DB_*等はそのまま残す)
AIKIDO_TOKEN: ${AIKIDO_TOKEN}
AIKIDO_BLOCK: "false" # まずはdetect(検知のみ)
# volumes等、既存の定義はそのまま
トークンはcompose直書きせず.envへ:
# .env(gitに入れない)
AIKIDO_TOKEN=AIK_RUNTIME_xxxxxxxxxxxxxxxx
ビルドと反映:
cd ~/homepages
docker compose build --no-cache wordpress
docker compose up -d --force-recreate wordpress
--no-cache を付けるのは、releases/latest を取る書き方のため。キャッシュが効くと古いdebのまま再ビルドされてしまう。
導入確認:
docker compose exec wordpress dpkg -l | grep aikido
# ii aikido-php-firewall 1.5.x-1 ... と出ればOK
最初は AIKIDO_BLOCK: "false"(detectモード)で運用し、ダッシュボードで検知内容と誤検知の有無を確認する。公式は誤検知回避のため2週間程度のdryモード運用を推奨している。問題なければ "true" に切り替えてブロッキング有効化。筆者のWordPressはBlockingで運用中で、体感できる遅延はない。
Aikido側での稼働確認は、ダッシュボードのEventsに「Application started」イベントが上がっていること、Routesにサイトのルートが自動発見されてくること、Instancesにインスタンス数が出ていることを見る。
ここはWordPressならではの一手間があるので、詳しく書く。
SQLi等のコア保護はdebを入れるだけで動くが、レートリミットとユーザーブロックだけは仕組みが違う。アプリ側からZenに「このリクエストをブロックすべきか」を問い合わせ、その結果に応じてアプリ自身が429を返す、という作りになっている。PHP版では \aikido\should_block_request() という関数がその窓口だ。
つまりWordPressの場合、functions.phpかmu-pluginへのフック実装が必要になり、ここだけ一手間かかる。実装先は次の理由でmu-pluginを推奨する:
wp-content/mu-plugins/)は自動読み込みで、管理画面から無効化できない。攻撃者が管理画面を触れてもレートリミットを外せないなお制限値そのものはコードに書かないので、実装は一度置けば終わり。以降の運用はすべてダッシュボード側で完結する。
制限値そのものはコードに書かず、Aikidoダッシュボードで管理する。ルート単位(ワイルドカード可)でリクエスト数と時間枠を指定でき、判定はスライディングウィンドウ方式(直近60秒の移動集計。固定窓のように窓の境目で瞬間的に倍のリクエストが通る、という穴がない)。制限はグループ→ユーザー→IPの優先順で1つだけ適用される。
置き場所はWordPressルート直下の wp-content/mu-plugins/(コンテナ内では通常 /var/www/html/wp-content/mu-plugins/)。Docker環境の場合、ホスト側のどこに置くかはvolumeマウントに依存するので、先に確認する:
docker inspect $(docker compose ps -q wordpress) \
--format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'
-> /var/www/html のマウントがあれば、そのホスト側パスの wp-content/mu-plugins/ 配下-> /var/www/html/wp-content なら、そのホスト側パスの mu-plugins/ 配下ディレクトリがなければ mkdir -p で作ってよい。ホスト側のwp-contentがwww-data所有になっている環境では、sudoで作成し、置いたファイルも chown www-data:www-data で所有者を揃えておく。以下を aikido-zen.php として置く。mu-pluginは置くだけで自動読み込みされ(有効化操作なし)、管理画面から無効化できないプラグインなので、この用途に向いている。攻撃者に管理画面を取られてもレートリミットを外されない、という意味でもある。
<?php
/**
* Plugin Name: Aikido Zen Middleware
* Description: Zenのレートリミット/ユーザーブロック判定をWordPressに接続する
*/
if (!extension_loaded('aikido')) {
return; // Zen未導入環境では何もしない
}
add_action('init', function () {
// ログイン済みならユーザーをZenに通知(ユーザー単位のrate limitに使われる)
if (is_user_logged_in()) {
$user = wp_get_current_user();
\aikido\set_user((string) $user->ID, $user->user_login);
}
// ダッシュボード設定に基づくブロック判定
$decision = \aikido\should_block_request();
if ($decision->block) {
if ($decision->type === 'ratelimited') {
status_header(429);
header('Content-Type: text/plain; charset=utf-8');
echo 'リクエストが多すぎます。しばらく待ってから再試行してください。';
} else {
status_header(403);
header('Content-Type: text/plain; charset=utf-8');
echo 'アクセスが拒否されました。';
}
exit;
}
}, 0); // 優先度0で他の処理より先に判定
?>
設置後、コンテナ内から見えているかを確認する:
docker compose exec wordpress ls -la /var/www/html/wp-content/mu-plugins/
wp-login.phpもWordPress本体を読み込んで init を発火させるため、このフックでログイン画面への総当たりも捕捉できる。
※このコードは公式middleware例のWordPress移植で、筆者の本番サイトで実測済み(後述のテストで設定どおり200×5→429×2を確認)。設置したらサイトを開く前に構文チェックを。mu-pluginは構文エラー1つでサイト全体が白画面になる:
docker compose exec wordpress php -l /var/www/html/wp-content/mu-plugins/aikido-zen.php
# "No syntax errors detected" を確認してからサイトにアクセス
POST /wp-login.php が現れたら、行のアクションメニューから Setup rate limitingPOST /wp-json/*)を追加して同様に設定できる。完全一致のルートが優先され、ワイルドカードは最も低い制限値が適用される

設定候補の例:
| ルート | 制限例 | ねらい |
|---|---|---|
POST /wp-login.php | 5回/分 | パスワード総当たり対策 |
POST /wp-login.php/* | 5回/分 | PATH_INFO亜種の抜け道封じ(次節参照) |
POST /xmlrpc.php | 5回/分 | xmlrpc悪用(system.multicall等)対策 |
POST /wp-json/* | 30回/分 | REST API乱用の抑制 |
制限を設定したルートに連続アクセスしてみる。
for i in $(seq 1 7); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://example.com/wp-login.php
done
6回目あたりから 429 が返れば成功。ダッシュボード側にもrate limitedイベントが記録される。
しばらく運用すると、Routes一覧に POST /wp-login.php のほかに POST /wp-login.php/xmlrpc.php のような合成パスが現れることがある。これはPATH_INFO形式(存在するPHPファイルの後ろにパスを付け足す記法)で、攻撃ツールが境界型WAFのパスマッチをすり抜ける目的でよく使う。
問題は、Zenのルートは自動発見されたパスごとに別エントリ=別カウントだという点だ。完全一致の POST /wp-login.php にだけ制限を掛けても、攻撃者が /wp-login.php/a、/wp-login.php/b とパスを回転させれば、毎回新しいルート=新しいカウントになり、実質すり抜けられる。
対策は、完全一致の設定に加えて Add Routeからワイルドカード POST /wp-login.php/* を1本追加しておくこと。これでPATH_INFO亜種の全体が1つの制限下にまとまる(完全一致が優先され、ワイルドカードがそれ以外を受ける)。筆者のサイトでも実際に POST /wp-login.php/xmlrpc.php が自動発見されており、机上の話ではない。

CDN(CloudFront/Cloudflare等)やリバースプロキシの背後にいる場合、ZenはX-Forwarded-Forヘッダーからクライアント IPを判定する。逆にプロキシなしで直接公開しているサーバーでは AIKIDO_TRUST_PROXY=false を設定すること。これを怠ると、攻撃者が偽のX-Forwarded-Forを付けてIPを詐称し、IPベースのレートリミットをすり抜けられる。
導入して数日経つと、たぶんこう思う。「Requestsは数千あるのに、Attacksが0。効いてるのか?」筆者も思った。結論から言うと、0は「攻撃されていない」ではない。Zenの画面は攻撃を複数の層で別々に見せているので、読み方を知っておく必要がある。
Routes一覧を眺めると、/nmaplowercheck17824...(nmapのプローブ)、/includesh1.php や /kuj8rl.php(Webシェル探し)、ルート / へのUNLOCK・PURGE・PROPFIND・LOCK(WebDAVプローブ)といった、自分では作った覚えのないルートが並んでいるはずだ。これは実際にサイトへ飛んできた偵察スキャンの痕跡が、ルートとして自動記録されたもの。WordPressは公開した瞬間からこれだけ叩かれている、という事実がそのまま見える。

Zenは、1つのIPが短時間に不審なリクエストを連発する(目安:1分に15回)、.envやwp-config.phpのような機微ファイルを取りに来る、といった振る舞いパターンを「Attack Wave」として検知し、BlockingモードならそのIPを遮断する。個々のリクエストが404で終わる無害なGETでも、連打の仕方が怪しければWaveになる。筆者のサイトでは米・欧の複数IPからの波が数日おきに検知・ブロックされている。
筆者のサイトに来た波の実例を1つ。User-AgentはCCBot——Common Crawlの行儀の良いクローラを名乗っているが、中身は .git/config、.aws/credentials、.env(無印・.example・.production・.old・api/・admin/・config/ 配下まで)を順に漁っていく認証情報狩りだった。UAは自己申告でしかなく信用ならない、ということも実データで同時にわかる。


なお、名前が紛らわしいが、Attack WaveはAttacksグラフとは別の集計だ。「Detected Attack Wave」のイベントが並んでいてもAttacksが0、というのは矛盾ではない。
統計ページのAttacksグラフが数えるのは、SQLインジェクションやコマンドインジェクションのような悪性ペイロードが、アプリ内部の監視ポイント(SQL実行・シェル実行・ファイルアクセス等)まで実際に届いて検知されたものだ。存在しないファイルへの探査は404で終わり、ペイロードがどこにも届かないので、ここには数字が付かない。
つまり「Requests数千、Attack Wave多数、Attacks 0」は、偵察は大量に来て波としてブロックされているが、ペイロード型はまだシンクに到達していないという、健全な状態の読み方になる。逆にAttacksに数字が付き始めたら、それは実エンドポイントに向けて中身のある攻撃が届いているということだ。

※補足:本稿執筆時点で、アプリ概要一覧の「Attacks」列の集計についてはAttack Wave件数が混ざるケースが無いか開発チームが確認中。数字に違和感があるときは、統計ページのAttacksグラフを正として読むのがよい。
導入自体は30分もかからない。「問題が尽きない」ことを嘆く前に、無料で足せる一枚から。
安藤 典久
公開日: 2026/8/12
最終更新: 2026/8/12
セキュリティの最新情報をお届けします。