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スキャン、除外指定 等)
目的
- Wordfence の公開フィードとインストール済み資産(Plugins/Themes)を自動照合して、既知の脆弱性を早期検知。
- コードベースに対しLFI(Local File Inclusion)疑いを静的に検出。ただし誤検知を最小化し、危険寄りだけに集中。
実装①:Wordfence Vulnerability API 連携
設定
- ベースURL:
https://www.wordfence.com/api/intelligence/v2/vulnerabilities/scanner - ヘッダー:空欄(不要)
- スラッグ差し込み:
{slug}(プラグイン/テーマのスラッグ)
スラッグ解決の工夫(テーマ)
配布フォルダ名と公開スラッグが異なる場合に備え、テーマは下記から最初に得られた値をスラッグ推定に使用:
stylesheet/template(ディレクトリ名)TextDomainName(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>'; }
});
動作確認(カナリアテスト)
wp-content/uploads/wvls-canary/lfi-test.phpを作成:<?php // テスト用:公開されるが通常は読み込まれない場所に置く if (isset($_GET['p'])) { include $_GET['p']; // わざと危険 }- LFI スキャン実行(深い=OFF)→ (HIGH) で1件ヒットすることを確認。
- ファイル削除 → 再スキャンで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}差し込み(プラグイン/テーマ)
- ベースURL:
- 検出件数が爆増:
- 除外ディレクトリに
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)

