后台鉴权别只盯 `current_user_can`,这三个"隐形后门"让我背过两次 P0 事故

插件开发 22 浏览 0 回复 返回上级

写插件这几年,权限漏洞出过两回血,都不是 `current_user_can` 没写,而是写了但没用对地方。今天把三个最隐蔽的鉴权陷阱摊开聊,附带我当时踩坑的代码和修补思路。

陷阱一:REST 端点的 `permission_callback` 成了"摆设回调"

WordPress 5.5 之后注册 REST 路由必须写 `permission_callback`,很多人直接扔个 `__return_true` 或者抄段 `is_user_logged_in()` 完事。但这里有个细节:如果你的回调里调了 `current_user_can`,却没处理用户未登录时返回的 `null` 能力值,逻辑会静默跑偏。

我当时的翻车代码:

register_rest_route('myplugin/v1', '/sensitive-action', [
    'methods'  => 'POST',
    'callback' => [__CLASS__, 'handle_sensitive'],
    'permission_callback' => function () {
        // 坑:未登录用户 wp_get_current_user() 返回 ID 为 0 的对象
        // current_user_can('manage_options') 对 ID 0 永远返回 false
        // 但这里我顺手加了 || is_super_admin(),多站点下未登录时 is_super_admin(0) 居然返回 true!
        return current_user_can('manage_options') || is_super_admin();
    },
]);

多站点环境里 `is_super_admin()` 不传参或传 0 时的行为极其诡异,文档里不会告诉你。修补后我强制显式传当前用户 ID,并且把 `is_super_admin` 包进 `is_multisite()` 判断:

'permission_callback' => function () {
    $user = wp_get_current_user();
    if (!$user->exists()) {
        return false;
    }
    return current_user_can('manage_options') 
        || (is_multisite() && is_super_admin($user->ID));
}

陷阱二:AJAX 动作的 `nopriv` 与 `wp_ajax_` 的"权限真空"

后台 AJAX 很多人写 `wp_ajax_my_action` 时顺手鉴权,但漏了同名的 `wp_ajax_nopriv_my_action` 钩子——或者更阴险的是,两个钩子指向同一个处理函数,函数内部只在某个分支判断权限,另一个分支直接执行。

我重构过一段第三方插件的代码,结构大概这样:

add_action('wp_ajax_myplugin_export', [$this, 'export_data']);
add_action('wp_ajax_nopriv_myplugin_export', [$this, 'export_data']); // 这里开了 nopriv

public function export_data() {
    if (isset($_POST['admin_mode'])) {
        check_ajax_referer('admin_export', 'nonce');
        if (!current_user_can('manage_options')) {
            wp_send_json_error('无权访问');
        }
        // 执行管理员导出...
    }
    
    // 非 admin_mode 分支直接走,没有任何鉴权!
    $this->do_public_export($_POST);
    wp_send_json_success();
}

攻击者直接 POST 不带 `admin_mode` 的数据,绕过所有权限检查。修复方案是钩子入口即鉴权,不要指望函数内部分支兜底:

public function export_data() {
    // 所有路径必须先过 nonce + 能力检查
    check_ajax_referer('myplugin_export', 'nonce');
    
    $cap = isset($_POST['admin_mode']) ? 'manage_options' : 'read';
    if (!current_user_can($cap)) {
        wp_send_json_error('权限不足', 403);
    }
    
    // ...后续逻辑
}

陷阱三:CSRF Token 的"重放窗口"与 `check_admin_referer` 的盲区

WordPress 的 nonce 不是真正的"一次性令牌",默认 12-24 小时有效。更麻烦的是,同一个 nonce 在有效期内可以被无限次使用。如果你的插件有"删除全部数据"这类高危操作,标准 nonce 不够。

我现在的做法是给敏感操作加操作级令牌 + 用户会话绑定

public function generate_destructive_nonce(string $action): string {
    $user_id = get_current_user_id();
    $session_token = wp_get_session_token(); // 用户当前会话唯一标识
    
    // 绑定到具体用户 + 具体会话,换浏览器/清 Cookie 立即失效
    $hash = wp_hash($action . '|' . $user_id . '|' . $session_token . '|' . wp_rand(), 'nonce');
    
    set_transient('myplugin_destructive_' . $hash, [
        'user_id' => $user_id,
        'action'  => $action,
        'used'    => false,
    ], HOUR_IN_SECONDS);
    
    return $hash;
}

public function verify_destructive_nonce(string $nonce, string $action): bool {
    $data = get_transient('myplugin_destructive_' . $nonce);
    
    if (!$data || $data['used'] || $data['action'] !== $action) {
        return false;
    }
    
    // 标记已使用,彻底防重放
    $data['used'] = true;
    set_transient('myplugin_destructive_' . $nonce, $data, HOUR_IN_SECONDS);
    
    return $data['user_id'] === get_current_user_id();
}

代价是多一次 transient 读写,但高危操作值得这个开销。

SQL 注入的"边缘案例":IN 子句与动态表名

最后顺嘴提个 SQL 注入的冷门场景。`$wpdb->prepare` 处理不了 `IN (...)` 里的数组,很多人直接 `implode(',', $ids)` 拼进去。如果 `$ids` 来源是用户输入的字符串数组,哪怕做了 `intval` 逐个转,中间也可能被注入注释符截断。

更隐蔽的是动态表名:`$wpdb->prefix . $_GET['table']` 这种写法,前缀可控时直接变成任意表操作。我现在强制用白名单映射:

$allowed_tables = ['myplugin_logs', 'myplugin_settings'];
$table = $_GET['table'] ?? '';
if (!in_array($table, $allowed_tables, true)) {
    wp_die('非法表名');
}

权限这块没有"差不多就行",每个入口都要假设会被直接调用。你们有遇到过更阴间的鉴权绕过方式么?

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