Zsens Admin 插件安全补丁实录:一次"合法登录态"下的越权删表事件与我的鉴权链路重构

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 54 浏览 0 回复

上周测试环境出了件离谱事——我用管理员A账号登录,没退出的情况下在另一个标签页用管理员B的Cookie覆盖了session,结果B居然能调用A的权限执行DROP TABLE。更讽刺的是,所有check_tokennonce校验全绿通过,因为请求本身"合法"。

这事逼我把后台鉴权从"单点校验"改成了链路级零信任,分享几个重构时的硬核踩坑点。

一、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)";

问题:intval0x11 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 被消耗,且如果接口有副作用

评论0
回复 · 0
还没有回复
微信客服 微信客服