「验证码装上了,日志里还是一晚上几千次 POST /wp-login.php。」这种问题很常见,毛病多半出在顺序上:把减少噪音的动作放在了防入侵的前面。合理的顺序是先把能登录的账号面收干净,再让「密码正确」不再等于「进得去后台」,然后才是尝试限制、验证码、改路径这些。
一次性人机验证加的是单次交互成本。分布式慢速爆破把这个成本摊到大量 IP 上,每个 IP 每分钟只试一两次,永远低于常见的「每 IP 每分钟 10 次」阈值,而针对同一个用户名,一小时就能试掉几百个密码。
实现上的漏洞更致命:验证码校验通过后 token 没有失效,同一个 token 可以重放;校验只挂在登录页的渲染流程里,直接 POST 就绕过去了;只对 action=login 生效,action=lostpassword、action=register、xmlrpc.php 完全不经过它。
验证码更像减速带。先做「就算密码被猜到也进不去」的事。
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
wp user application-password list <user>要处理的几类:
admin、test、和域名同名的登录名,在爆破字典第一页。改登录名有两条路:新建一个干净账号,把内容迁过去再删旧的;或者直接改 wp_users.user_login,改完要清掉所有会话。注意改「显示名」对登录毫无影响,而且旧登录名还会残留在 user_nicename 和文章作者 slug 里。限速改变的是「试多少次」,双因子改变的是「试对了之后能不能进」。后者才是决定损失的那一步。
TOTP、短信、邮件三类里,TOTP 是离线校验,不依赖运营商通道,换卡、漫游、虚拟号都不影响;短信依赖运营商,而且不宜作为唯一因子;邮件等于把后台的安全边界交给了邮箱。以 TOTP 为主,恢复码兜底。
大多数人漏掉的是下面这些运维细节:
慢速爆破的特征是:单 IP 频率极低,单账号累计次数很高。所以:
min(60 * 2^over, 3600) 秒。下面这个 mu-plugin 是可跑的骨架:
<?php
/**
* Plugin Name: Login Throttle
* Description: 账号维度指数退避 + 全局失败速率观测
*/
const AC_LT_MAX_TRIES = 5; // 同一账号窗口内允许的失败次数
const AC_LT_WINDOW = 900; // 计数窗口(秒)
const AC_LT_LOCK_BASE = 60; // 首次锁定时长(秒)
const AC_LT_GLOBAL_MAX = 60; // 每 5 分钟全站失败数告警阈值
// 优先级 5:早于核心在 20 上的密码校验,被锁的账号连哈希都不会算一次
add_filter('authenticate', 'ac_lt_check', 5, 3);
function ac_lt_check($user, $username, $password) {
if (empty($username) || empty($password)) {
return $user;
}
$state = get_transient('ac_lt_' . md5(strtolower($username)));
if (is_array($state) && !empty($state['until']) && $state['until'] > time()) {
return new WP_Error(
'ac_lt_locked',
sprintf('尝试次数过多,请 %d 秒后再试。', $state['until'] - time())
);
}
return $user;
}
add_action('wp_login_failed', 'ac_lt_record');
function ac_lt_record($username) {
$now = time();
$key = 'ac_lt_' . md5(strtolower($username));
$state = get_transient($key);
if (!is_array($state) || ($state['first'] + AC_LT_WINDOW) < $now) {
$state = ['count' => 0, 'first' => $now, 'until' => 0];
}
$state['count']++;
if ($state['count'] >= AC_LT_MAX_TRIES) {
$over = $state['count'] - AC_LT_MAX_TRIES;
$state['until'] = $now + min(AC_LT_LOCK_BASE * (2 ** $over), 3600);
}
// 过期时间必须覆盖锁定时间,否则锁会被提前清掉
set_transient($key, $state, AC_LT_WINDOW + 3600);
ac_lt_bump_global($now);
}
add_action('wp_login', function ($login) {
delete_transient('ac_lt_' . md5(strtolower($login)));
});
function ac_lt_bump_global($now) {
$bucket = 'ac_lt_g_' . floor($now / 300);
$n = (int) get_transient($bucket) + 1;
set_transient($bucket, $n, 600);
if ($n > AC_LT_GLOBAL_MAX) {
error_log(sprintf('[login-throttle] global failures in 5min: %d', $n));
}
}两点说明。authenticate 上优先级 20 是核心的密码校验,锁定判断挂在 30 就变成「先算完密码再告诉你被锁了」,既浪费哈希开销,也让日志里分不清是锁定拒绝还是密码错。另外,XML-RPC 和 REST 的密码认证同样走 authenticate 这条链,所以这段代码会一并覆盖,但 2FA 覆盖不到 application password,那部分靠权限收敛。
wp-login.php 的多个入口:action=login、action=register、action=lostpassword、action=resetpass,以及独立于它的 xmlrpc.php 和 REST 认证。收益很具体:自动扫描器打 wp-login.php 的噪音大幅下降,日志干净,WAF 告警变少。
代价也很具体:团队书签、密码管理器里存的 URL 全部失效;监控和合成测试如果探的是 wp-login.php 会开始误报;内部脚本、CI、SSO 回调可能硬编码了登录路径;忘了新地址又没有备用通道,就是自己进不去。
最关键的一点:它不改变任何认证逻辑。xmlrpc.php、REST、application password 都还在,wp-admin/ 未登录时照样跳到登录页。它挡的是扫目录的脚本,挡不住真在撞库的人。
所以判断很简单:目标是减少日志噪音,改;目标是防爆破,先把双因子和账号维度退避做完,再考虑改。真改的话,保留一条备份入口(原路径只放行办公网 IP,或者干脆把整个后台前置到 VPN / SSO 后面),并把新路径写进团队文档和监控配置。
上面这些自己动手是一下午的量,最费时间的其实不是代码,是账号盘点、恢复码怎么发、强制 2FA 之后万一有人进不去怎么救这几件流程上的事。
AumCreate 做 WordPress 代部署:模板装到客户自己的主机上,每一页换成客户的内容,交付前不留示例文字,后台这块(管理员账号收敛、应用密码清理、尝试限制、2FA 上线顺序)可以一起做完再交;主机、域名、源码始终是客户自己的,交付后拿 SSH 和 wp-cli 照样能改。$399,材料收齐后 5 个工作日内上线,需要准备什么写在 setup 页。如果你更想自己控制,上面那段 mu-plugin 直接拿去改常量即可,别忘了很多主机面板自带一层登录保护,先看看它做了什么再叠加。
wp user session list <user> 看会话,wp user session destroy <user> --all 之后旧 cookie 应该立刻失效。动手之前先确认自己还有 SSH 和 wp-cli。能停插件、能改用户、能清会话,才有资格去动登录这一层;否则第一个被挡在门外的可能是你自己。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。