WordPressのセキュリティ確認 — 攻撃者から何が見えているか実測

実測 に検証

「WordPress は狙われやすい」と言われても、具体的に何が見えているのかは 分かりにくいものです。

インストールしたままの WordPress に対して、攻撃者が最初に行う情報収集を 実際にやってみました。そして対策を入れて、効いたものと効かなかったものを 計測しました。

攻撃の前提: まずユーザー名を集める

パスワードを試すには、有効なユーザー名が必要です。 既定の WordPress はこれを 4 経路で教えます。

① REST API のユーザー一覧

curl -s "https://example.com/wp-json/wp/v2/users"

実測結果です。

HTTP 200
露出したユーザー数: 2
  1  admin           / admin
  4  lab_contributor / Lab contributor

ログインせずに、ログイン名(slug)と表示名が取れます。 投稿があるユーザーが対象です。

② 投稿者アーカイブ

curl -s -o /dev/null -D - "https://example.com/?author=1"
HTTP/1.1 301 Moved Permanently
Location: https://example.com/author/admin/

ID を 1 から順に試すだけで、ユーザー名が判明します。 リダイレクト先の URL にログイン名が入るためです。

③ ログインエラーのメッセージ

これがいちばん直接的です。誤ったパスワードで試した結果です。

入力したユーザー名返ってきたメッセージ
admin(実在)「ユーザー名 admin のパスワードが間違っています。」
nosuchuser_zzz(存在しない)「ユーザー名 nosuchuser_zzz は、このサイトに登録されていません。」

メッセージが違うので、ユーザー名が実在するかどうかが分かります。 これは既定の動作です。

④ XML-RPC

curl -X POST "https://example.com/xmlrpc.php" -H "Content-Type: text/xml" \
  --data '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>'

実測で利用可能だったメソッドです。

system.multicall
wp.getUsersBlogs
pingback.ping

system.multicall があると、1 回のリクエストに大量のログイン試行を 詰め込めます。wp.getUsersBlogs は認証を試すためのメソッドです。

攻撃の前提: バージョンを特定する

脆弱性は「このバージョンに存在する」という形で公開されるため、 攻撃者はまずバージョンを見ます。実測では 3 経路で分かりました。

経路実測結果
meta generator<meta name="generator" content="WordPress 7.1" />
アセットの ?ver=ver=7.1
/readme.htmlHTTP 200(本体のバージョンが書かれている)
/license.txtHTTP 200

プラグインも同じ方法で分かります。

curl -s -o /dev/null -w '%{http_code}\n' \
  "https://example.com/wp-content/plugins/akismet/readme.txt"

200 が返れば、そのプラグインが入っていることが確定します。 実測では akismet/readme.txt が 200、存在しないプラグインは 404 でした。 readme.txt にはバージョンも書かれています。

「どのプラグインの何番が入っているか」は外から一覧できるということです。

ログイン試行の制限は既定では無い

同じユーザー名で 5 回連続でログインを試した結果です。

200 200 200 200 200

制限も遅延もありません。自動化ツールで 1 秒間に何百回も試せます。

対策を入れて効果を測った

mu-plugins に対策をまとめて、前後を比較しました。

対策結果
REST のユーザー一覧を未ログインに返さない効いた(200 → 401 rest_forbidden
ログインエラーのメッセージを統一する効いた(実在・非実在で同一文になった)
meta generator を消す効いた(0 件)
アセットの ?ver= を消す効いた(0 件)
?author=N を塞ぐ最初は効かなかった(後述)
XML-RPC を止めるPHP のフィルタだけでは不完全(後述)

効かなかった 1: ?author=N

最初はこう書きました。

add_action( 'template_redirect', function () {
	if ( is_author() ) { wp_safe_redirect( home_url( '/' ), 301 ); exit; }
} );

まだ /author/admin/ へリダイレクトされました。 WordPress の正規化リダイレクト(redirect_canonical)は template_redirect より先に走るためです。

クエリが解釈される時点で弾く必要があります。

add_action( 'parse_request', function ( $wp ) {
	if ( is_user_logged_in() ) { return; }
	if ( isset( $wp->query_vars['author'] ) || isset( $wp->query_vars['author_name'] ) ) {
		wp_safe_redirect( home_url( '/' ), 301 );
		exit;
	}
} );

これで ?author=1 はトップページへ 301 になりました(実測)。 ユーザー名は出ません。

効かなかった 2: XML-RPC

よく紹介される書き方です。

add_filter( 'xmlrpc_enabled', '__return_false' );

実測では、まだ system.multicall が使えました。

xmlrpc POST: HTTP 200
  system.getCapabilities
  system.listMethods
  system.multicall

xmlrpc_enabled認証が必要なメソッド(wp.getUsersBlogs など)を 無効にするだけで、system.* 系は残ります。 メソッド一覧を空にするフィルタを足しても、system.* は WordPress が 後から追加するため残りました。

サーバー側で拒否するしかありません。

# .htaccess (Apache)
<Files "xmlrpc.php">
  Require all denied
</Files>
# nginx
location = /xmlrpc.php { deny all; }

実測した結果です。

xmlrpc POST
Apache(.htaccess で拒否)403
nginx(.htaccess を読まない)200

.htaccess に書いても nginx では効きません。 サーバーごとに設定する必要があります。

副作用も確認しました。サイト表示・REST API・管理画面はすべて正常 (200 / 200 / 302)でした。XML-RPC を止めても通常の運用に影響はありません (アプリからの投稿や Jetpack を使っている場合は別です)。

まとめた対策

// mu-plugins/harden.php として置く

// 1. REST のユーザー一覧を未ログインには返さない
add_filter( 'rest_authentication_errors', function ( $result ) {
	if ( ! empty( $result ) ) { return $result; }
	$uri = $_SERVER['REQUEST_URI'] ?? '';
	if ( ! is_user_logged_in() && false !== strpos( $uri, '/wp/v2/users' ) ) {
		return new WP_Error( 'rest_forbidden', '', array( 'status' => 401 ) );
	}
	return $result;
} );

// 2. ?author=N を塞ぐ(parse_request でないと間に合わない)
add_action( 'parse_request', function ( $wp ) {
	if ( is_user_logged_in() ) { return; }
	if ( isset( $wp->query_vars['author'] ) || isset( $wp->query_vars['author_name'] ) ) {
		wp_safe_redirect( home_url( '/' ), 301 ); exit;
	}
} );

// 3. ログインエラーを統一する
add_filter( 'login_errors', function () {
	return 'ユーザー名またはパスワードが正しくありません。';
} );

// 4. バージョンを出さない
remove_action( 'wp_head', 'wp_generator' );
add_filter( 'style_loader_src',  fn( $src ) => remove_query_arg( 'ver', $src ) );
add_filter( 'script_loader_src', fn( $src ) => remove_query_arg( 'ver', $src ) );

サーバー側にも設定します。

<Files "xmlrpc.php">
  Require all denied
</Files>
<Files "readme.html">
  Require all denied
</Files>
<FilesMatch "\.(bak|save|old|orig|swp|inc)$|~$">
  Require all denied
</FilesMatch>

最後の 1 行は、wp-config.php.bak のようなバックアップから データベースのパスワードが全文露出するのを防ぐためです(実測済み)。 → 設定ファイルは漏れるのか

優先順位

情報を隠すのは二次的な対策です。効果の大きい順に並べるとこうなります。

優先対策理由
1本体・プラグイン・テーマを更新する実際に侵入されるのは既知の脆弱性経由。隠しても古ければ破られる(→ プラグインに多い脆弱性の型
2強いパスワードと二要素認証ユーザー名が漏れても、パスワードが破れなければ入られない
3ログイン試行の制限既定では無制限。総当たりを実際に止める
4使っていないプラグイン・テーマを削除する停止中でもファイルは残り、脆弱性の対象になる
5XML-RPC を止める(使っていなければ)マルチコール攻撃の土台を消す
6ユーザー名・バージョンを隠す手間は増やせるが、それだけでは止まらない

6 を先にやっても効果は薄いということです。 ユーザー名が分かっても、パスワードが強ければ入られません。 逆に、ユーザー名を隠しても古い脆弱性が残っていれば ログインを経由せずに侵入されます。

自分のサイトを確認する

S=https://example.com

# ユーザー名が漏れているか
curl -s "$S/wp-json/wp/v2/users" | head -c 300
curl -s -o /dev/null -D - "$S/?author=1" | grep -i location

# バージョンが分かるか
curl -s "$S/" | grep -oE '<meta name="generator"[^>]*'
curl -s -o /dev/null -w '%{http_code}\n' "$S/readme.html"

# XML-RPC が開いているか
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$S/xmlrpc.php" \
  -H "Content-Type: text/xml" \
  --data '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>'

# 設定ファイルのバックアップが残っていないか
for f in wp-config.php.bak "wp-config.php~" wp-config.txt .env; do
  printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "$S/$f")" "$f"
done

403 か 404 以外が返る行があれば、そこが対応箇所です。

侵害されたあとの確認は別記事に

すでに入られている疑いがある場合は、痕跡の探し方が別になります。 ファイルの検査だけでは、管理者の追加や本文への注入は見つかりません。WordPressが乗っ取られた・改ざんされた時の確認と対処法

参考にした情報源