背景:单台服务器托管 800+ 个 WordPress 站点(站群架构),遭遇批量挂马。特征为 wp-content 目录下充斥恶意 PHP/HPH 文件,部分站点的 wp-config.php 被直接篡改为恶意 HTML 页面。
目标:在不丢失数据库数据、保留上传图片 (uploads) 的前提下,批量重置核心文件、清理后门、重置管理员权限。
难点:
- 规模大:手动处理不现实,必须自动化。
- 环境脏:配置文件被污染,常规脚本读取会报错。
- 容错性:不能因为 1 个站点失败导致脚本中断。
本文记录从最初的“简单逻辑”到“生产级容错”的脚本演进过程。
核心原则 (Production Rules)
- 数据隔离:永远假设文件系统是不可信的,只信任数据库中的内容。
- 最小权限:Web Shell 无法执行的操作,通过 WP-CLI 在服务器端完成。
- 白名单机制:与其费力去识别哪些是病毒(黑名单),不如只保留什么是正常的(白名单)。
- 原子操作:单个站点的失败不应影响整体流程。
踩坑与演进:
在编写自动化脚本时,我们遇到了几个典型的生产环境陷阱。以下是问题的根源及解决方案。
1. 解析陷阱:当 wp-config.php 变成 HTML
问题现象:
脚本尝试读取数据库密码时,报错 –dbprefix can only contain numbers。日志显示读取到的数据库用户名竟然是 <html lang=en-US>。
根本原因:
黑客将部分站点的 wp-config.php 替换成了纯 HTML 的钓鱼页面。
早期的读取逻辑是直接 include 该文件。PHP 解释器遇到非 PHP 代码(HTML)会直接输出到标准输出 (stdout),导致脚本捕获了 HTML 源码作为变量值。
解决方案:
引入 Output Buffering (输出缓冲)。
在 eval 代码前使用 ob_start(),执行后使用 ob_end_clean()。这样可以吞掉所有非 PHP 的杂音(HTML),只保留内存中定义的常量。
2. 内核陷阱:不要加载 wp-settings.php
问题现象:
即使解决了 HTML 问题,脚本依然报错 PHP输出行数不足。
根本原因:
标准的 wp-config.php 尾部通常包含 require_once ABSPATH . ‘wp-settings.php’;。
这行代码会启动整个 WordPress 应用。在修复模式下,站点通常是损坏的,加载内核会导致 PHP 抛出致命错误或输出页面内容,干扰变量读取。
解决方案:
正则剔除。在 eval 之前,读取文件内容字符串,使用正则暴力移除加载内核的代码。
// 必须使用 s 修饰符 (PCRE_DOTALL),防止 require_once 跨行导致匹配失败
$content = preg_replace('/require_once\s*\(?.*wp-settings\.php.*?;/is', '', $content);
3. 用户管理陷阱:一刀切的风险
问题现象:
使用 SQL 直接 DELETE FROM wp_users 只保留管理员。
风险:
对于商城或会员站,这会误删数万真实用户。
解决方案:
使用 WP-CLI 进行逻辑操作。先查询 administrator 角色的列表,排除掉我们的超级管理员账号,再删除其余管理员。
经验:能用 API/CLI 解决的,绝不直接操作数据库 Raw SQL,因为你不知道有哪些关联表(UserMeta)需要清理。
4. 进程陷阱:单点故障导致全盘崩溃
问题现象:
脚本跑到第 50 个站点时,因为网络波动导致下载核心文件失败,脚本配置了 set -e,直接退出。剩余 750 个站点未处理。
解决方案:
子进程隔离。
将单个站点的所有修复逻辑放入 ( … ) 子 Shell 中执行。子 Shell 的退出状态不会杀死父进程(主循环)。
最终生产版本脚本
这是经过多次迭代后的最终版本,具备容错、强力解析和安全隔离特性。
适用环境:CentOS/Debian/Ubuntu, 已安装 PHP, WP-CLI, MySQL Client。
前置条件:准备好 failed_sites.txt (或其他站点列表文件)。
#!/usr/bin/env bash set -Eeuo pipefail # ========================= # 配置区 # ========================= WEBROOT_BASE="/www/" # 指定包含站点目录名的列表文件 SITE_LIST_FILE="/backup/failed_sites.txt" # 干净的插件/主题源路径 (用于覆盖) SOURCE_WPCONTENT="/backup/68wordpress/wp-content" # 目标管理员信息 TARGET_USER_LOGIN="username" TARGET_USER_EMAIL="[email protected]" TARGET_USER_HASH='password' # WordPress Hash string OWNER="www:www" # 依赖检查 PHP_BIN="$(command -v php || true)" WP_BIN="$(command -v wp || true)" # 辅助函数 log() { printf -- "[%s] %s\n" "$(date '+%F %T')" "$*" >&2; } doit() { eval "$@"; } # --- 核心:强力 PHP 解析函数 --- # 解决 HTML 注入和 WP 内核加载问题 php_eval_from_config() { local cfg="$1" local php_code # 使用 Heredoc 避免 Shell 转义地狱 php_code=$(cat <<'PHPEOF' ini_set('display_errors', 'stderr'); error_reporting(E_ALL); $cfg = $argv[1]; if (!file_exists($cfg)) exit(10); $content = file_get_contents($cfg); // 关键:正则移除 wp-settings.php 加载,防止启动 WP $content = preg_replace('/require_once\s*\(?.*wp-settings\.php.*?;/is', '', $content); // 关键:输出缓冲,吞掉可能的 HTML 注入 ob_start(); eval('?>' . $content); ob_end_clean(); if (!defined('DB_NAME') || !defined('DB_USER')) exit(11); $prefix = isset($table_prefix) ? $table_prefix : 'wp_'; echo constant('DB_NAME') . PHP_EOL . constant('DB_USER') . PHP_EOL . constant('DB_PASSWORD') . PHP_EOL . constant('DB_HOST') . PHP_EOL . $prefix . PHP_EOL; PHPEOF ) "$PHP_BIN" -r "$php_code" -- "$cfg" } # --- 单站点处理逻辑 --- process_single_site() { local domain=$1 local site_dir="$WEBROOT_BASE/$domain" local cfg="$site_dir/wp-config.php" if [[ ! -f "$cfg" ]]; then log "跳过:无配置文件 $domain"; return; fi # 1. 解析配置 local output # 使用 || true 捕获 PHP 错误而不退出 output=$(php_eval_from_config "$cfg" 2>/dev/null | tr -d '\r' || true) if [[ -z "$output" ]]; then log "错误:解析配置失败 $domain"; return; fi readarray -t meta <<< "$output" if (( ${#meta[@]} < 5 )); then log "错误:配置信息不全 $domain"; return; fi local DB_NAME="${meta[0]}" DB_USER="${meta[1]}" DB_PASS="${meta[2]}" \ DB_HOST="${meta[3]}" TBL_PREFIX="${meta[4]}" # 简单的安全检查 if [[ "$DB_USER" == *"<"* ]]; then log "警告:配置包含 HTML,疑似被篡改 $domain"; return; fi # 2. 执行修复 (子 Shell 隔离环境) # 任何一步失败只会终止当前站点的处理 ( set -e log "处理中: $domain" # A. 清理文件 (白名单模式) # 仅保留 wp-content 和特定文件,其余全删 find "$site_dir" -maxdepth 1 ! -name 'wp-content' ! -name '.user.ini' ! -name '.' ! -name '..' -exec rm -rf {} + # B. 清理 Uploads (删掉所有 PHP/HPH) find "$site_dir/wp-content/uploads" -type f -iregex '.*\.\(php\|phtml\|phar\|hph\)$' -delete # C. 重装核心 & 生成新配置 "$WP_BIN" core download --path="$site_dir" --force --allow-root "$WP_BIN" config create --path="$site_dir" --dbname="$DB_NAME" --dbuser="$DB_USER" \ --dbpass="$DB_PASS" --dbhost="$DB_HOST" --dbprefix="$TBL_PREFIX" --force --allow-root "$WP_BIN" config shuffle-salts --path="$site_dir" --allow-root # D. 同步插件/主题 (覆盖模式) rsync -a --delete "$SOURCE_WPCONTENT/plugins/" "$site_dir/wp-content/plugins/" rsync -a --delete "$SOURCE_WPCONTENT/themes/" "$site_dir/wp-content/themes/" "$WP_BIN" plugin activate --all --path="$site_dir" --allow-root # E. 账号收敛 (只创建/重置目标账号,删除多余管理员) if ! "$WP_BIN" user get "$TARGET_USER_LOGIN" --path="$site_dir" --field=ID --allow-root >/dev/null 2>&1; then "$WP_BIN" user create "$TARGET_USER_LOGIN" "$TARGET_USER_EMAIL" --role=administrator \ --user_pass="TEMP_PASS" --path="$site_dir" --allow-root fi "$WP_BIN" user update "$TARGET_USER_LOGIN" --user_pass="$TARGET_USER_HASH" --path="$site_dir" --allow-root # 找出其他管理员并删除 local admins admins=$("$WP_BIN" user list --role=administrator --field=user_login --exclude="$TARGET_USER_LOGIN" --path="$site_dir" --allow-root || true) for admin in $admins; do "$WP_BIN" user delete "$admin" --reassign=1 --yes --path="$site_dir" --allow-root done # F. 权限修正 chown -R "$OWNER" "$site_dir" log "成功:$domain" ) || log "失败:站点 $domain 处理中断" } # --- 主循环 --- main() { [[ ! -f "$SITE_LIST_FILE" ]] && { log "列表文件不存在"; exit 1; } while IFS= read -r domain || [[ -n "$domain" ]]; do domain=$(echo "$domain" | tr -d '\r' | xargs) [[ -z "$domain" || "$domain" == \#* ]] && continue process_single_site "$domain" done < "$SITE_LIST_FILE" } main "$@"
技术总结
- Parse, Don’t Grep: 不要尝试用 Grep 去提取代码文件中的变量,永远使用该语言原本的解释器(这里是 PHP)去解析。
- Heredoc for Code Injection: 在 Bash 中通过
php -r执行复杂 PHP 代码时,使用Heredoc(<<‘EOF’) 是最干净的方式,避免了无休止的引号转义。 - Fail Fast, But Keep Going: 对于批处理脚本,单个任务必须快速失败(Fail Fast),但主进程必须具备韧性,能够跳过错误继续执行下一个任务。
警告:此脚本为“无备份”版本,执行的是破坏性修复。在生产环境使用前,务必确认已通过快照或其他方式对底层存储进行了备份。
以上脚本在多台服务器总计约3000+站点的环境测试通过。