Zsens Admin 插件后台鉴权"三重门":一次越权漏洞复盘让我重构了整个权限校验流

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

上周安全审计扫出一个离谱问题:普通作者角色居然能直接访问我插件的导出数据接口,不是通过菜单点进去的,是直接拼 URL 进来的。复盘之后发现我在三个关卡各自漏了一点,单看都没事,串起来就是条通路。这篇把踩的坑摊开,给同样在写后台管理插件的老哥提个醒。

第一关:菜单注册不是"挡箭牌"

我最开始的写法是典型的"菜单即权限"思维:

add_menu_page(
    '数据导出',
    '数据导出',
    'manage_options',  // ← 这里写了
    'zsens-export',
    'zsens_render_export_page'
);

然后页面渲染函数里直接干活,没有任何二次校验。问题是 WordPress 的菜单权限只控制显不显示,不控制能不能访问。用户只要知道 admin.php?page=zsens-export 这个 URL,直接敲进去,add_menu_page 的 capability 参数不会拦他。

现在我的页面渲染函数第一行必加:

function zsens_render_export_page() {
    if ( ! current_user_can( 'zsens_export_data' ) ) {
        wp_die( __( '无权访问此页面。', 'zsens' ), 403 );
    }
    // ... 实际渲染
}

自定义 capability zsens_export_data 是在插件激活时通过 add_rolemap_meta_cap 分配的,不依赖 WordPress 原生角色体系。这样即使菜单被隐藏、URL 被猜中、或者别的插件把菜单重新注册了一遍,这关都能拦住。

第二关:AJAX 端点的"裸奔"陷阱

我的导出功能后来改成了 AJAX 异步生成,问题在这里炸开的:

// 错误的示范(不要抄)
add_action( 'wp_ajax_zsens_do_export', function() {
    // 直接开始查数据库、写文件、返回下载链接
    // 完全没有校验当前用户是谁
});

WordPress 的 wp_ajax_* 钩子只验证用户"是否登录",不验证"是谁"。订阅者登录后也能触发这个端点。更隐蔽的是,我还写了个 wp_ajax_nopriv_* 的变体给"定时任务模拟"用,结果忘记下掉,成了公开接口。

现在的端点结构:

add_action( 'wp_ajax_zsens_do_export', 'zsens_handle_export' );

function zsens_handle_export() {
    // 1. nonce 校验(防 CSRF,见第三关)
    check_ajax_referer( 'zsens_export_' . get_current_user_id(), 'nonce' );
    
    // 2. 权限二次确认
    if ( ! current_user_can( 'zsens_export_data' ) ) {
        wp_send_json_error( array( 'code' => 'forbidden' ), 403 );
    }
    
    // 3. 用户级速率限制(防爆破)
    $transient_key = 'zsens_export_' . get_current_user_id();
    if ( false !== get_transient( $transient_key ) ) {
        wp_send_json_error( array( 'code' => 'rate_limited' ), 429 );
    }
    set_transient( $transient_key, 1, MINUTE_IN_SECONDS );
    
    // ... 实际业务
}

注意 nonce 字符串里拼进了 get_current_user_id(),这样 A 用户的 nonce 给 B 用户用会直接失效,防止那种"复制 curl 命令换 cookie 重放"的攻击。

第三关:CSRF 防护的"形似神不似"

我最早加 nonce 的方式是错的,只是"看起来安全":

// 错误:nonce 生成和校验不匹配
// 前端
wp_localize_script( 'zsens-admin', 'zsensData', array(
    'ajaxUrl' => admin_url( 'admin-ajax.php' ),
    'nonce'   => wp_create_nonce( 'zsens_export_nonce' )  // 全局通用
));

// 后端
check_ajax_referer( 'zsens_export_nonce', 'nonce' );  // 谁都能用

这个 nonce 没有绑定用户、没有绑定动作、没有绑定时间窗口里的特定上下文。攻击者只要诱导管理员点一个恶意页面,就能用他的登录状态触发任意操作。

现在的生成逻辑:

// 前端:每个用户、每个动作、每次页面加载都独立
wp_localize_script( 'zsens-admin', 'zsensData', array(
    'ajaxUrl' => admin_url( 'admin-ajax.php' ),
    'nonce'   => wp_create_nonce( 'zsens_export_' . get_current_user_id() )
));

// 后端:严格匹配
check_ajax_referer( 'zsens_export_' . get_current_user_id(), 'nonce' );

对于非 AJAX 的表单提交(比如传统的 POST 到 admin-post.php),我改用 wp_nonce_field() + check_admin_referer(),并且额外校验 HTTP Referer 是否来自本站后台:

function zsens_handle_bulk_action() {
    check_admin_referer( 'zsens_bulk_action', 'zsens_nonce' );
    
    // 额外一层:Referer 校验(防止跨站 POST)
    $referer = wp_get_referer();
    if ( ! $referer || 0 !== strpos( $referer, admin_url() ) ) {
        wp_die( '非法来源请求' );
    }
    
    // ...
}

SQL 注入:占位符不是"万能符"

权限校验过了,数据操作还有坑。我曾经自信满满地用了 $wpdb->prepare,结果还是漏了:

// 错误:IN 子句没法用单个占位符
$ids = $_POST['selected_ids'];  // [1, 2, 3] 或者 "1,2,3" 或者 "1; DROP TABLE..."
$sql = $wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}zsens_logs WHERE id IN (%s)",
    implode( ',', $ids )  // 整个当成字符串,IN 只匹配一个"1,2,3"
);

正确的 IN 子句处理,我现在封装了一个辅助函数:

function zsens_prepare_in( $ids, $format = '%d' ) {
    $ids = array_map( 'intval', $ids );  // 强制类型清洗
    if ( empty( $ids ) ) {
        return array( 'WHERE 1=0', array() );  // 空数组返回永假条件
    }
    $placeholders = implode( ', ', array_fill( 0, count( $ids ), $format ) );
    return array( "IN ({$placeholders})", $ids );
}

// 使用
list( $in_clause, $in_values ) = zsens_prepare_in( $_POST['selected_ids'] );
$sql = "SELECT * FROM {$wpdb->prefix}zsens_logs WHERE id {$in_clause}";
$results = $wpdb->get_results( $wpdb->prepare( $sql, ...$in_values ) );

关键点:array_map( 'intval', ... ) 在进 prepare 之前就把输入拍死成整数数组,即使后面逻辑有漏洞,基数也是干净的。对于字符串类型的 IN 查询,我用 sanitize_text_field + 白名单校验,而不是依赖转义。

一个完整的"最小鉴权单元"

现在我的每个后台操作函数都遵循这个模板,形成肌肉记忆:

function zsens_sensitive_operation() {
    // 1. 身份确认
    if ( ! is_user_logged_in() ) {
        auth_redirect();
    }
    
    // 2. 能力校验
    if ( ! current_user_can( 'zsens_specific_cap' ) ) {
        wp_die( '无权操作', 403 );
    }
    
    // 3. 来源校验(CSRF)
    check_ajax_referer( 'zsens_action_' . get_current_user_id(), 'nonce' );
    
    // 4. 输入清洗
    $input = array_map( 'intval', $_POST['ids'] ?? array() );
    
    // 5. 业务执行 + 审计日志
    zsens_log_audit( __FUNCTION__, $input, get_current_user_id() );
    
    // ...
}

最后

安全不是"加一把锁",是"每一道门都有人守"。菜单权限、页面渲染、AJAX 端点、表单提交、

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