后台 AJAX 端点裸奔事件:一次 `check_ajax_referer` 漏写引发的越权遍历复盘
上周帮朋友审计一个会员插件,在后台用户管理页发现一个" harmless "的 AJAX 接口——根据用户 ID 返回详细资料,用来画个弹窗卡片。看起来人畜无害,直到我删掉 nonce 直接 curl:
curl -X POST https://target.com/wp-admin/admin-ajax.php \
-d "action=myplugin_get_user_card&user_id=2" \
-H "Cookie: 普通订阅者Cookie"
返回了管理员完整信息,包括邮箱、注册 IP、甚至未公开的备注字段。根因不是 current_user_can 没写,是压根没校验请求来源——check_ajax_referer 被当成"可选装饰"省略了。
这事让我重新梳理了插件 AJAX 安全的"三层皮",不是重复讲 CSRF 原理,而是说清 WordPress 生态里几个容易"想当然"的坑:
第一层:nonce 不是防重放,是防"跨站借刀"
很多人以为 nonce 像短信验证码,其实 WordPress 的 nonce 生命周期是 12-24 小时,同一用户同一动作生成的 token 在窗口期内完全复用。它的设计意图是确保请求来自"你自己站点的合法页面",而非阻止攻击者截获后重放。
所以别在 nonce 校验通过后就把业务逻辑"裸奔":
// 错误示范:校验完 nonce,后面直接干活
check_ajax_referer('myplugin_nonce', 'security'); // 抛异常或返回 -1
// 然后就直接读 user_id 查库了?不行
正确姿势是 nonce + capability 双校验,且顺序不能反:
public function handle_get_user_card() {
// 1. 先验来源
check_ajax_referer('myplugin_admin_user', 'security', true); // 第三个参数 true = 失败直接 die
// 2. 再验权限(注意不是 is_admin(),那只是个 URL 判断)
if (!current_user_can('list_users')) {
wp_send_json_error('权限不足', 403);
}
// 3. 最后才是参数过滤与业务
$user_id = absint($_POST['user_id'] ?? 0);
if (!$user_id) {
wp_send_json_error('参数异常', 400);
}
// 查库前再加一道:只能查自己权限范围内的用户
$user = get_userdata($user_id);
if (!$user || !user_can($user, 'subscriber')) { // 举例:管理员只能看订阅者,不能看其他管理员
wp_send_json_error('越权访问', 403);
}
wp_send_json_success($this->sanitize_response($user));
}
第二层:SQL 注入在 prepare 的"边缘地带"
审计时还发现另一处,用了 $wpdb->prepare 但依然注入:
$orderby = sanitize_text_field($_GET['orderby'] ?? 'id');
$results = $wpdb->get_results(
$wpdb->prepare("SELECT * FROM {$wpdb->prefix}myplugin_logs ORDER BY %s DESC", $orderby)
);
%s 会被加上单引号变成 'created_at',ORDER BY 列名带引号直接语法错误;开发者"修复"时把 %s 改成 %1$s 想裸插,结果成了格式化字符串注入。最终正确方案是白名单枚举:
$allowed = ['id', 'created_at', 'user_id', 'action_type'];
$orderby = in_array($orderby, $allowed, true) ? $orderby : 'id';
// 然后直接拼进 SQL,因为已经是硬编码白名单
核心教训:prepare 防的是"值"注入,对"标识符"(表名、列名、排序方向)无能为力。插件里凡动态拼接这些位置,必须白名单或转义函数($wpdb->_real_escape 也不够用,标识符需要反引号包裹)。
第三层:后台页面的"隐式信任"陷阱
WordPress 后台 URL 自带 /wp-admin/,容易让人产生"进了后台就安全"的错觉。实际上:
admin_init钩子对前台请求也会触发(有人直接 POST 到 admin-post.php)is_admin()只看 URL 字符串,不验权限- 自定义的
add_menu_page回调里,WordPress 只帮你校验了"能不能看见这个菜单",不拦截直接访问 URL
所以我的习惯是给每个后台处理函数套个"门卫"trait:
trait AdminGate {
protected function require_capability(string $cap): void {
if (!is_user_logged_in()) {
auth_redirect();
}
if (!current_user_can($cap)) {
wp_die('无权访问', 403, ['response' => 403]);
}
// 再加一层:确保不是前端伪造的 admin 请求
if (!is_admin() && !defined('DOING_AJAX')) {
wp_die('非法入口', 400);
}
}
}
最后说个冷知识:WordPress 的 check_ajax_referer 在失败时默认会 die(-1),但返回 -1 给前端,攻击者能区分"nonce 错误"和"其他错误",属于信息泄露。建议自定义失败响应,或者至少统一成 403 状态码:
if (!wp_verify_nonce($_POST['security'] ?? '', 'myplugin_action')) {
wp_send_json_error('校验失败', 403); // 别用 -1
}
这次审计完,我给团队加了一条规矩:所有新增 AJAX 端点必须过 CR 清单——nonce、capability、参数类型、返回脱敏,缺一项打回。安全不是"加把锁",是"每一道门都有人查岗"。
你们插件里有没有类似"看起来加了防护其实漏风"的写法?欢迎贴代码互相挑刺。

