Zsens Admin 插件安全补丁实录:一次"合法登录态"下的越权删表事件与我的鉴权链路重构
上周测试环境出了件离谱事——我用管理员A账号登录,没退出的情况下在另一个标签页用管理员B的Cookie覆盖了session,结果B居然能调用A的权限执行DROP TABLE。更讽刺的是,所有check_token和nonce校验全绿通过,因为请求本身"合法"。
这事逼我把后台鉴权从"单点校验"改成了链路级零信任,分享几个重构时的硬核踩坑点。
一、Cookie 与 Session 的"身份漂移":别信单一凭证
Zsens 默认的鉴权链路是 cookie → session → user_id → capability,问题出在第二步。两个管理员共享同一台机器、同一浏览器时,session 文件如果没做设备指纹绑定,就会出现"登录态正确、身份错误"的幽灵场景。
我的补丁是在 Session 初始化时塞入一个客户端令牌:
// 登录成功时写入
$client_token = hash_hmac('sha256', $_SERVER['HTTP_USER_AGENT'] . $_SERVER['REMOTE_ADDR'], AUTH_KEY);
setcookie('zsens_ct', $client_token, ['httponly' => true, 'samesite' => 'Strict']);
$_SESSION['_zsens_client'] = $client_token;
// 每次请求校验
if (empty($_SESSION['_zsens_client']) || !hash_equals($_SESSION['_zsens_client'], $_COOKIE['zsens_ct'] ?? '')) {
wp_logout();
wp_die('会话绑定异常', 403);
}
注意:REMOTE_ADDR 对 CDN 用户不友好,我实际用的是 HTTP_X_FORWARDED_FOR 的首跳 IP + UA 的前 32 字节,兼顾代理和稳定性。
二、CSRF 的"时间窗口攻击":Nonce 不是一次性票根
WordPress 的 wp_create_nonce 默认 12 小时有效,这在后台表单场景够用,但我的插件有批量操作接口(比如一次性删除 50 条规则),攻击者可以在这段时间内重放请求。
我的方案是引入操作级短期令牌,和 WP Nonce 双层叠加:
// 生成时绑定具体操作对象
function zsens_action_token($action, $object_id) {
$salt = wp_rand(100000, 999999);
$token = wp_hash($action . '|' . $object_id . '|' . $salt . '|' . time(), 'auth');
set_transient('zsens_act_' . substr($token, 0, 16), [
'action' => $action,
'object_id' => $object_id,
'salt' => $salt,
'time' => time(),
'used' => false
], 300); // 5 分钟有效,且单次使用
return $token . ':' . $salt;
}
// 校验时原子性标记已用
function zsens_verify_action_token($token_raw, $action, $object_id) {
[$token, $salt] = explode(':', $token_raw, 2);
$transient_key = 'zsens_act_' . substr($token, 0, 16);
$stored = get_transient($transient_key);
if (!$stored || $stored['used'] || $stored['action'] !== $action || $stored['object_id'] != $object_id) {
return false;
}
// 原子性删除,防止竞态重放
delete_transient($transient_key);
$expected = wp_hash($action . '|' . $object_id . '|' . $salt . '|' . $stored['time'], 'auth');
return hash_equals($expected, $token);
}
关键设计:delete_transient 不是幂等的,Redis/Memcached 下天然原子,但如果是数据库型 transients,建议加 FOR UPDATE 或乐观锁。我这边强制要求了 object cache 扩展。
三、SQL 注入的"参数化盲区":IN 子句与动态列名
参数化查询防住了 90% 的注入,但有两个死角:
1. IN 子句的参数化困境
// 错误示范:直接拼接
$ids = implode(',', array_map('intval', $_POST['rule_ids'])); // 以为转 int 就安全?
$query = "DELETE FROM {$wpdb->prefix}zsens_rules WHERE id IN ($ids)";
问题:intval 对 0x1、1 UNION SELECT... 这类输入处理有边界情况,且代码可读性差。我的做法是占位符批量展开:
$ids = array_filter(array_map('intval', (array)$_POST['rule_ids']));
if (empty($ids)) return;
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$query = $wpdb->prepare(
"DELETE FROM {$wpdb->prefix}zsens_rules WHERE id IN ($placeholders)",
...$ids // PHP 5.6+ 解包
);
2. 动态列名的完全无解
排序字段、搜索列如果由前端传入,$wpdb->prepare 不支持对标识符(表名、列名)做参数化。我的方案是白名单硬编码 + 哈希校验:
const ALLOWED_ORDERBY = ['created_at', 'priority', 'rule_name'];
const ALLOWED_ORDER = ['ASC', 'DESC'];
function zsens_safe_orderby($input_col, $input_order) {
$col = in_array($input_col, self::ALLOWED_ORDERBY, true) ? $input_col : 'created_at';
$order = in_array(strtoupper($input_order), self::ALLOWED_ORDER, true)
? strtoupper($input_order)
: 'DESC';
// 额外加一层:对最终拼接结果做签名验证,防止中间被篡改
$sig = hash_hmac('sha256', "{$col}|{$order}", NONCE_SALT);
return [
'sql' => "{$col} {$order}",
'sig' => $sig // 传给前端,下次带回校验
];
}
前端回传时校验签名,虽然增加一次往返,但彻底堵死了列名注入的口子。
四、权限校验的"能力扩散":别用 manage_options 一盖到底
我最开始图省事,所有后台接口统一检查 current_user_can('manage_options')。结果产品要求拆分"规则查看员"和"规则管理员"角色时,整个权限体系要推倒重来。
重构后的能力矩阵:
// 注册时细分能力
add_action('admin_init', function() {
$caps = [
'zsens_view_rules' => ['administrator', 'zsens_auditor'],
'zsens_edit_rules' => ['administrator', 'zsens_manager'],
'zsens_delete_rules' => ['administrator'],
'zsens_export_data' => ['administrator', 'zsens_manager'], // 敏感操作单独控制
];
foreach ($caps as $cap => $roles) {
foreach ($roles as $role_name) {
$role = get_role($role_name);
$role && $role->add_cap($cap);
}
}
});
// 接口层按最小权限校验
function zsens_check_cap($required_cap) {
if (!current_user_can($required_cap)) {
// 记录审计日志,方便溯源
do_action('zsens_unauthorized_access', $required_cap, wp_get_current_user()->ID);
wp_send_json_error(['code' => 'insufficient_cap', 'need' => $required_cap], 403);
}
}
特别加了未授权访问日志,生产环境抓到过两次内部人员的越权探测,比技术防御更早发现问题。
五、一个让我失眠的边界 case:Ajax 接口的 CORS 与凭证泄露
插件有个对外暴露的 Ajax 端点 wp_ajax_zsens_sync,本意是供同源前台调用。但发现如果另一个站点嵌入图片 <img src="https://目标站/wp-admin/admin-ajax.php?action=zsens_sync&nonce=xxx">,虽然拿不到响应,但请求确实发出了——Nonce 被消耗,且如果接口有副作用

