WordPressにWordfence脆弱性照合+LFIスキャナをMUプラグインで実装した手順(実運用設定つき)

パソコン

2025-10-06 / 作業メモ+公開用


スポンサーリンク

TL;DR

  • Wordfence Vulnerability API(scannerエンドポイント)でプラグイン/テーマと自動照合を実装。
  • LFIスキャナを MU プラグイン内に実装。token_get_all() を用いてコメントや文字列を除外し、include/requireのみを正確に抽出。
  • 深い静的解析=OFFでは、$_GET/$_POST/... などユーザー入力が直接含まれるケースのみ検出 → ノイズ激減。
  • 深い静的解析=ONでは、簡易データフローで変数経由の取り込みも検出(MEDIUM扱い)。
  • 除外ディレクトリ/最低深刻度フィルタで実運用に耐える件数へ。日次の自動照合+ダッシュボード通知も追加。
  • カナリアテストで (HIGH) を正しく検出 → 削除後は 0 件に復帰。
  • 併せて uploads での PHP 実行禁止 を設定。

環境

  • WordPress 6.8.3
  • MU プラグインによる管理ツール(設定画面:APIベースURL/ヘッダー、LFIスキャン、除外指定 等)

目的

  1. Wordfence の公開フィードとインストール済み資産(Plugins/Themes)を自動照合して、既知の脆弱性を早期検知。
  2. コードベースに対しLFI(Local File Inclusion)疑いを静的に検出。ただし誤検知を最小化し、危険寄りだけに集中。

実装①:Wordfence Vulnerability API 連携

設定

  • ベースURLhttps://www.wordfence.com/api/intelligence/v2/vulnerabilities/scanner
  • ヘッダー:空欄(不要)
  • スラッグ差し込み{slug}(プラグイン/テーマのスラッグ)

スラッグ解決の工夫(テーマ)

配布フォルダ名と公開スラッグが異なる場合に備え、テーマは下記から最初に得られた値をスラッグ推定に使用:

  • stylesheet / template(ディレクトリ名)
  • TextDomain
  • Name(sanitize_titleしたもの)

結果

今回の環境では、一覧照合の結果は検出なし(= 現時点の既知脆弱性には非該当)。


実装②:LFI スキャナ

課題

単純な正規表現ベースの検出だと、コメント内の「include」や PSR/Tracy 等のライブラリにより誤検知が大量発生

方式

  • PHPのトークナイザ token_get_all() でコメント/文字列をスキップしつつ、
    T_INCLUDE/T_INCLUDE_ONCE/T_REQUIRE/T_REQUIRE_ONCE を抽出。
  • 各 include/require の(セミコロンまで)を収集し、以下で評価:
    • ユーザー入力の直接使用$_GET|$_POST|$_REQUEST|$_COOKIE|$_FILES|$_SERVER
    • (深い=ON のみ)簡易データフローで 1〜2段の代入連鎖を後方探索
    • 安全寄りパターン(固定ディレクトリ・管理画面テンプレ等)は LOW に降格 or スキップ

除外(実運用向け)

設定 → 除外ディレクトリ(一例)

vendor
vendor_prefixed
includes/libraries
third-party
psr
tracy
guzzlehttp
react/Promise
languages
views
templates
assets
perfopsone

最低深刻度フィルタ

  • 返却直前に LOW/MEDIUM/HIGH を判定し、
  • 既定は MEDIUM 以上のみ残す(UIに該当項目があれば連動)。

深い静的解析の挙動

  • OFF(既定)
    • 直接ユーザー入力が式に含まれる include/require だけを残す。
    • 固定パスや管理画面テンプレはスキップ
    • ノイズ最小、重さ軽め
  • ON
    • 簡易データフローで変数経由も拾う(MEDIUM中心)。
    • 対象パスを plugins or themes に狭めてスポット診断が現実的。

実装コード(wvls_run_scan())

MU プラグイン内の既存 wvls_run_scan() を下記で置換。関数内でトークン解析/深さ制御/ポストフィルタまで完結します。

/** * LFIスキャナ本体:コメント/文字列を無視して include/require を収集し、 * 深い解析OFFでは user_input 由来のみを残す。最後に最低深刻度で絞り込む。 * * 期待オプション($opts): * - scan_base : スキャン開始ディレクトリ(例: WP_CONTENT_DIR) * - exclude_dirs : 改行区切りの除外ディレクトリ(任意) * - exclude_exts : 改行区切りの除外ファイル名/サフィックス(任意) * - max_filesize_kb : 解析する最大ファイルサイズKB(任意、デフォ16KB) * - deep_static : 深い静的解析(true/false) * - min_severity : 'LOW'|'MEDIUM'|'HIGH'(任意、デフォ 'MEDIUM') */
function wvls_run_scan($opts) { $linesToArray = function($text) { if (!is_string($text) || $text === '') return []; $arr = preg_split('/\r\n|\r|\n/u', $text); $out = []; foreach ($arr as $v) { $v = trim($v); if ($v !== '') $out[] = trim($v, "/\\ \t"); } return $out; }; $getExcerptByPos = function($code, $line, $radius = 1) { $lines = preg_split("/\r\n|\r|\n/u", $code); $i = max(1, (int)$line); $from = max(1, $i - $radius); $to = min(count($lines), $i + $radius); $snip = []; for ($n=$from; $n<=$to; $n++) { $prefix = ($n === $i) ? '>> ' : ' '; $snip[] = $prefix . $n . ': ' . (isset($lines[$n-1]) ? mb_substr($lines[$n-1], 0, 300) : ''); } return implode("\n", $snip); }; $extractVars = function($expr) { preg_match_all('/\$[a-zA-Z_][a-zA-Z0-9_]*/u', $expr, $m); return array_values(array_unique($m[0] ?? [])); }; $tokenizeIncludes = function(array $tokens) { $incs = []; $len = count($tokens); for ($i=0; $i<$len; $i++) { $t = $tokens[$i]; if (!is_array($t)) continue; $id = $t[0]; if (in_array($id, [T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE], true)) { $line = (int)$t[2]; $expr = ''; for ($j=$i+1; $j<$len; $j++) { $tj = $tokens[$j]; $chunk = is_array($tj) ? $tj[1] : $tj; $expr .= $chunk; if ($chunk === ';') { $i = $j; break; } } $incs[] = ['line'=>$line, 'expr'=>$expr]; } } return $incs; }; $exprMayFromSuperglobal = function($code, $expr) use ($extractVars) { $pos = strpos($code, $expr); $start = max(0, ($pos === false ? 0 : $pos) - 4000); $segment = substr($code, $start, 4000); $vars = $extractVars($expr); if (!$vars) return false; foreach (array_reverse($vars) as $v) { if (preg_match_all('/'.preg_quote($v,'/').'\s*=\s*([^;]+);/iu', $segment, $mm, PREG_SET_ORDER)) { $rhs = end($mm)[1] ?? ''; if (preg_match('/\$\_(GET|POST|REQUEST|COOKIE|FILES|SERVER)\b/iu', $rhs)) return true; if (preg_match('/^\s*(\$[a-zA-Z_][a-zA-Z0-9_]*)\s*$/', trim($rhs), $m2)) { $next = $m2[1]; if ($next !== $v && preg_match('/'.preg_quote($next,'/').'\s*=\s*([^;]+);/iu', $segment, $m3)) { $rhs2 = $m3[1] ?? ''; if (preg_match('/\$\_(GET|POST|REQUEST|COOKIE|FILES|SERVER)\b/iu', $rhs2)) return true; } } } } return false; }; $postfilterIssues = function(array $issues, $minSeverity='MEDIUM') { $sevRank = ['LOW'=>0,'MEDIUM'=>1,'HIGH'=>2]; $min = $sevRank[$minSeverity] ?? 1; $out = []; $dedup = []; foreach ($issues as $it) { $reason = (string)($it['reason'] ?? ''); $level = 'MEDIUM'; if (stripos($reason,'(HIGH)') !== false) $level = 'HIGH'; if (stripos($reason,'(LOW)') !== false) $level = 'LOW'; if (($sevRank[$level] ?? 1) < $min) continue; $key = ($it['file'] ?? '') . ':' . ($it['line'] ?? 0) . ':' . $reason; if (isset($dedup[$key])) continue; $dedup[$key] = true; $out[] = $it; } return $out; }; // 入力 $base = isset($opts['scan_base']) ? realpath(trailingslashit($opts['scan_base'])) : null; if (!$base || !is_dir($base)) { return ['error'=>"無効なスキャンベース: ".($opts['scan_base'] ?? '(未設定)'), 'issues'=>[]]; } $excludeDirs = $linesToArray($opts['exclude_dirs'] ?? ''); $excludeExts = $linesToArray($opts['exclude_exts'] ?? ''); $maxBytes = max(16, (int)($opts['max_filesize_kb'] ?? 16)) * 1024; // 既定16KB $deep = !empty($opts['deep_static']); $minSeverity = strtoupper(trim($opts['min_severity'] ?? 'MEDIUM')); if (!in_array($minSeverity, ['LOW','MEDIUM','HIGH'], true)) $minSeverity = 'MEDIUM'; // 走査 $issues = []; $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS)); foreach ($it as $fileinfo) { if (!$fileinfo->isFile()) continue; $path = $fileinfo->getPathname(); // 除外ディレクトリ $skip = false; foreach ($excludeDirs as $d) { if ($d && strpos($path, DIRECTORY_SEPARATOR.$d.DIRECTORY_SEPARATOR) !== false) { $skip = true; break; } } if ($skip) continue; // PHPのみ if (strtolower($fileinfo->getExtension()) !== 'php') continue; // 除外拡張/末尾一致 foreach ($excludeExts as $e) { if ($e && str_ends_with($path, $e)) { $skip = true; break; } } if ($skip) continue; // サイズ if ($fileinfo->getSize() > $maxBytes) continue; $code = @file_get_contents($path); if ($code === false) continue; $tokens = @token_get_all($code); if (!is_array($tokens)) continue; $incs = $tokenizeIncludes($tokens); if (!$incs) continue; foreach ($incs as $inc) { $line = (int)$inc['line']; $expr = (string)$inc['expr']; $isUserDirect = (bool) preg_match('/\$\_(GET|POST|REQUEST|COOKIE|FILES|SERVER)\b/i', $expr); $isConstPath = (bool) preg_match('/\b(__DIR__|ABSPATH|WP_CONTENT_DIR|WP_PLUGIN_DIR|WPMU_PLUGIN_DIR|TEMPLATEPATH|STYLESHEETPATH|XMLSF_DIR|MWAI_PATH)\b/i', $expr); $isAdminTpl = (bool) preg_match('/views?\/admin|templates?\/|\/admin\/partials\//i', $expr); $isCssTpl = (bool) preg_match('/assets\/admin\.css/i', $expr); $isSafeHint = $isConstPath || $isAdminTpl || $isCssTpl; $isUserFlow = false; if ($deep && !$isUserDirect) { $isUserFlow = $exprMayFromSuperglobal($code, $expr); } // 深い=OFF → 大胆に間引き if (!$deep) { if (!$isUserDirect) continue; // 直接でない → スキップ if ($isSafeHint) continue; // 固定/管理テンプレ → スキップ } if ($isUserDirect) { $reason = $isSafeHint ? 'user_input_include (LOW)' : 'user_input_include (HIGH)'; } elseif ($isUserFlow) { $reason = $isSafeHint ? 'variable_include_from_user_input (LOW)' : 'variable_include_from_user_input (MEDIUM)'; } else { $reason = $isSafeHint ? 'variable_include (LOW)' : 'variable_include'; } $issues[] = [ 'file' => $path, 'line' => $line, 'reason' => $reason, 'excerpt' => $getExcerptByPos($code, $line), ]; } } // 返却直前:最低深刻度でポストフィルタ $issues = $postfilterIssues($issues, $minSeverity); return [ 'base' => $base, 'generated_at' => current_time('mysql'), 'issues' => $issues, ];
}

日次ジョブ&管理画面通知(任意)

目的:毎日1回の自動照合でヒット件数を保存し、ある場合はダッシュボードに赤通知を出す。

// 1) 初期化時に1日1回のイベントを登録
add_action('init', function () { if (!wp_next_scheduled('wvls_daily_vuln_check')) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'wvls_daily_vuln_check'); }
}); // 2) 実行ハンドラ:フィード→照合→ヒット件数を保存
add_action('wvls_daily_vuln_check', function () { $opts = wvls_get_opts(); $result = wvls_run_vuln_match($opts); $hitCount = 0; foreach (['plugins','themes'] as $k) { foreach ($result[$k] as $row) { if (!empty($row['vulns'])) $hitCount += count($row['vulns']); } } update_option('wvls_last_hit_count', $hitCount); update_option('wvls_last_hit_checked_at', current_time('mysql'));
}); // 3) 管理画面に警告表示(ヒットがあれば)
add_action('admin_notices', function () { if (!current_user_can('manage_options')) return; $n = intval(get_option('wvls_last_hit_count', 0)); if ($n > 0) { $checkedAt = esc_html(get_option('wvls_last_hit_checked_at', '')); echo '<div class="notice notice-error"><p><strong>Vuln & LFI Scanner:</strong> 既知の脆弱性が ' . $n . ' 件見つかりました(最終チェック: ' . $checkedAt . ')。ツール → Vuln & LFI Scanner を開いて詳細を確認してください。</p></div>'; }
});

動作確認(カナリアテスト)

  1. wp-content/uploads/wvls-canary/lfi-test.php を作成: <?php // テスト用:公開されるが通常は読み込まれない場所に置く if (isset($_GET['p'])) { include $_GET['p']; // わざと危険 }
  2. LFI スキャン実行(深い=OFF)→ (HIGH) で1件ヒットすることを確認。
  3. ファイル削除 → 再スキャンで0件に戻ることを確認。

本番ハードニング(uploads での PHP 実行禁止)

Apache (.htaccess)wp-content/uploads/.htaccess を作成:

<FilesMatch "\.php$"> Require all denied
</FilesMatch>

旧式Apache

<FilesMatch "\.php$"> Order deny,allow Deny from all
</FilesMatch>

Nginx — サーバ設定に追加:

location ~* ^/wp-content/uploads/.*\.php$ { return 403; }

週次のスポット深掘り(任意)

  • 週1回程度、対象を wp-content/themes など狭め深い=ON で実行。
  • 終わったら 深い=OFF に戻しておくと、日常運用は軽いまま。

トラブルシュート

  • API照合でエラー
    • ベースURL:https://www.wordfence.com/api/intelligence/v2/vulnerabilities/scanner
    • ヘッダー:空欄
    • スラッグ:{slug} 差し込み(プラグイン/テーマ)
  • 検出件数が爆増
    • 除外ディレクトリに vendor / libraries / views / templates / assets / psr / tracy ... を追加
    • 深い静的解析を OFF
    • 最低深刻度を MEDIUM 以上

まとめ

  • 「既知の脆弱性(API照合)」と「コード由来のLFI疑い(静的解析)」を分けて考えることで、
    実運用で見逃さない/騒がないバランスに調整できた。
  • カナリアテストで検知の確からしさを担保し、uploads の実行禁止など恒久対策も同時に実施。
  • 以後は 日次の自動照合週次のスポット深掘りで、低コスト監視を継続。

付録:今回の主な設定値

  • Wordfence Vulnerability API ベースURL:
    https://www.wordfence.com/api/intelligence/v2/vulnerabilities/scanner
  • ヘッダー:空(未設定)
  • スラッグ差し込み:{slug}
  • 除外ディレクトリ(推奨):vendor, vendor_prefixed, includes/libraries, third-party, psr, tracy, guzzlehttp, react/Promise, languages, views, templates, assets, perfopsone
  • 最低深刻度:MEDIUM
  • 深い静的解析:通常は OFF(スポット時のみ ON