WordPressが乗っ取られた・改ざんされた時の確認と対処法

実測 に検証

知らない管理者ユーザーがいる。記事に見覚えのないリンクが入っている。 検索結果に自分のサイトが変な文字列で出ている。

ファイルの改ざんチェックだけでは足りません。 wp core verify-checksums や git の差分で見られるのはファイルだけで、 データベースに書き込まれた痕跡は 1 件も見つかりません。

実際に痕跡を作って、DB 側の検知方法を確立しました。

ファイル側の検査では見つからない

前提として、ファイル側の検査には別記事で測った通り 3 つの穴があります。 → verify-checksums が Success でも改ざんは見つかっていない

そのうえで、次の 3 つはファイルを 1 バイトも変えません。

  • 管理者ユーザーの追加
  • 記事本文へのリンク・スクリプトの挿入
  • オプション(設定値)へのコード挿入

ファイルの検査が全部 Success でも、これらは残っています。

検知 1: 管理者ユーザーの棚卸し

いちばん先に見るところです。

SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;

実測した出力です(lab_intruder が仕込んだもの)。

ID  user_login     user_email            user_registered
6   lab_intruder   intruder@lab.local    2026-09-12 11:38:55
1   admin          admin@example.com     2026-09-11 14:32:45

登録日で並べるのが要点です。最近登録された管理者が心当たりのないものなら、 そこが侵入点です。

WP-CLI でも同じことができます。

wp user list --role=administrator --fields=ID,user_login,user_registered

両方で突き合わせてください。プラグインがユーザー一覧を フィルタで隠している場合、WP-CLI(WordPress 経由)では見えず、 SQL でだけ見えることがあります。逆に権限の付け方が変則的だと SQL の条件に引っかからないこともあります。

見るべき他の項目

-- 誰でも登録できる状態になっていないか
SELECT option_name, option_value FROM wp_options
WHERE option_name IN ('users_can_register', 'default_role', 'admin_email');

users_can_register = 1 かつ default_role = administrator は、 誰でも管理者になれる状態です。サイトヘルスもこれを検査しています。

admin_email が書き換えられていると、 パスワード再設定のメールが攻撃者に届きます。必ず確認します。

検知 2: 記事本文への挿入

SELECT ID, post_type, post_status, post_modified FROM wp_posts
WHERE post_content REGEXP '<script|<iframe|base64_decode|eval\\(|display:\\s*none'
LIMIT 20;

隠しリンクは display:none や画面外への移動で仕込まれるため、 スクリプトタグだけを探すのでは足りません。

特定のドメインで探すほうが確実な場合もあります。

SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%不審なドメイン%';

重要: 更新日は当てにならない

これが実測でいちばん有用だった点です。

攻撃者が SQL で直接書き換えた場合、post_modified は変わりません。

投稿本文に文字列を追加した直後の値です。

ID  post_date              post_modified          判定
1   2026-09-11 23:32:45    2026-09-11 23:32:45    同じ

本文は変わっているのに、更新日は元のままです。

WordPress の管理画面や REST API 経由で編集すれば post_modified が更新されますが、 DB を直接叩かれた場合は痕跡が残りません。

つまり:

  • 「更新日が最近の投稿を調べる」は侵害調査の手段として不完全です
  • 逆に更新日が新しい投稿が並んでいたら、WordPress 経由で操作された (盗まれた管理者アカウントが使われた)可能性が高い、という情報になります

検知 3: オプションへの挿入

SELECT option_name, LEFT(option_value, 80), autoload FROM wp_options
WHERE option_value REGEXP '<script|<iframe|base64_decode|eval\\(';

実測すると、仕込んだものと一緒に偽陽性が出ました。

option_name                              先頭
_site_transient_feed_992efac...          a:6:{s:5:"child";...   ← 偽陽性
lab_marker_injected                      <script>/*...*/</script>  ← 本物

_transient__site_transient_ で始まるものは、 外部から取得した RSS などのキャッシュです。中に <script が 含まれていても正常です。

除外して検索します。

SELECT option_name, LEFT(option_value, 80) FROM wp_options
WHERE option_value REGEXP '<script|<iframe|base64_decode|eval\\('
  AND option_name NOT LIKE '%transient%';

autoloadon のものは全ページで読み込まれます。 そこにコードが入っていると、サイト全体に影響します。 まずここを見ます。

検知 4: その他の置き場所

挿入先は本文とオプションだけではありません。

-- ウィジェット(オプションに入っている)
SELECT option_name FROM wp_options WHERE option_name LIKE 'widget_%';

-- カスタムフィールド
SELECT post_id, meta_key, LEFT(meta_value, 60) FROM wp_postmeta
WHERE meta_value REGEXP '<script|<iframe|base64_decode' LIMIT 20;

-- ユーザーのプロフィール(表示名や自己紹介にリンクを仕込む手口)
SELECT user_id, meta_key, LEFT(meta_value, 60) FROM wp_usermeta
WHERE meta_value REGEXP '<a |<script|http' AND meta_key IN ('description','first_name','last_name') LIMIT 20;

-- コメント
SELECT comment_ID, comment_author, LEFT(comment_content, 60) FROM wp_comments
WHERE comment_approved = '1' AND comment_content REGEXP '<script|<iframe' LIMIT 20;

-- 予約された処理(バックドアの再設置に使われる)
SELECT option_value FROM wp_options WHERE option_name = 'cron';

cron オプションは必ず見てください。 定期処理として「ファイルを再設置する」処理が登録されていると、 ファイルを消しても復活します。

wp cron event list

見覚えのないフック名があれば、それがバックドアです。

侵害が確認できたときの初動

ファイルを消して終わりにしないこと。 盗まれた認証情報とセッションが残っていると、すぐ再侵入されます。

1. 認証キーを再生成する

wp-config.php には 8 つの認証キーが定義されています(実測で確認)。

define( 'AUTH_KEY', '...' );
define( 'SECURE_AUTH_KEY', '...' );
define( 'LOGGED_IN_KEY', '...' );
define( 'NONCE_KEY', '...' );
define( 'AUTH_SALT', '...' );
define( 'SECURE_AUTH_SALT', '...' );
define( 'LOGGED_IN_SALT', '...' );
define( 'NONCE_SALT', '...' );

これを作り直すと、発行済みのログイン Cookie が全部無効になります。 WordPress の生成ツールの 出力で 8 行を置き換えます。

パスワードだけ変えても、盗まれた Cookie は使えたままです。 ここを飛ばすと再侵入されます。

2. セッションを明示的に破棄する

ログインセッションは DB に記録されています(実測)。

SELECT user_id, meta_key FROM wp_usermeta WHERE meta_key = 'session_tokens';
wp user session destroy --all --all-users

3. 全ユーザーのパスワードを変更する

とくに管理者と編集者。再設定メールは admin_email に届くので、 その値が正しいことを先に確認します。

4. 不正なユーザーを削除する

wp user delete <ID> --reassign=<正当なユーザーID>

--reassign を付けないと、そのユーザーの投稿も消えます。

5. ファイル側も確認する

wp core verify-checksums          # コア
wp plugin verify-checksums --all  # wp.org 配布のプラグイン
git status --short                # テーマ・自作コードを含む全体(git 管理している場合)
wp cron event list                # 再設置の仕込み

verify-checksums だけでは足りない

6. 侵入経路を塞ぐ

痕跡を消しても経路が残っていれば再発します。

確認する場所何を見るか
アクセスログ不正ログインの成功、wp-login.php への大量アクセス、見覚えのない POST
PHP / WordPress のログ侵害の前後のエラー
プラグイン・テーマ古いバージョン、公式以外から入れたもの
wp-config.php のバックアップ.bak などが公開領域に残っていないか(実測で全文露出を確認)

最後の項目は見落としやすいところです。 → 設定ファイルは漏れるのか

棚卸しをまとめて実行する

-- 管理者
SELECT u.user_login, u.user_email, u.user_registered FROM wp_users u
JOIN wp_usermeta m ON m.user_id=u.ID
WHERE m.meta_key='wp_capabilities' AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;

-- 登録設定
SELECT option_name, option_value FROM wp_options
WHERE option_name IN ('users_can_register','default_role','admin_email','siteurl','home');

-- 本文・メタ・オプションへの挿入(transient は除外)
SELECT 'posts' AS 場所, COUNT(*) FROM wp_posts
  WHERE post_content REGEXP '<script|<iframe|base64_decode|eval\\('
UNION ALL SELECT 'postmeta', COUNT(*) FROM wp_postmeta
  WHERE meta_value REGEXP '<script|<iframe|base64_decode'
UNION ALL SELECT 'options', COUNT(*) FROM wp_options
  WHERE option_value REGEXP '<script|<iframe|base64_decode|eval\\('
  AND option_name NOT LIKE '%transient%';

この 3 本を定期的に走らせて件数を記録しておくと、 「増えた」で気づけます。0 件であることを知っていれば、 1 件出た時点で異常だと分かります。

再現手順

-- 痕跡を作る(無害なマーカーのみ)
INSERT INTO wp_users (user_login, user_pass, user_nicename, user_email, user_registered, user_status, display_name)
VALUES ('lab_intruder', 'MARKER', 'lab-intruder', 'intruder@lab.local', NOW(), 0, 'lab');
SET @uid = LAST_INSERT_ID();
INSERT INTO wp_usermeta (user_id, meta_key, meta_value)
VALUES (@uid, 'wp_capabilities', 'a:1:{s:13:"administrator";b:1;}');

UPDATE wp_posts SET post_content = CONCAT(post_content, '<a href="http://marker.invalid/">X</a>')
WHERE ID = 1;

INSERT INTO wp_options (option_name, option_value, autoload)
VALUES ('marker_injected', '<script>/*X*/</script>', 'on');

-- post_modified が変わっていないことを確認する
SELECT ID, post_date, post_modified FROM wp_posts WHERE ID = 1;