后台菜单"挂羊头卖狗肉":我是如何用 `user_has_cap` 伪造权限让"编辑"角色看到超管面板的

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

上周帮朋友审一个内部插件,发现一个挺隐蔽的权限漏洞——不是没鉴权,而是鉴权"看起来对了,实际错了"。记录一下,给写后台配置页的兄弟提个醒。

问题出在自定义菜单的 `capability` 参数上。很多人知道 `add_menu_page` 要传权限字符串,但容易忽略 WordPress 的 capability 系统是可以被"注入"的。

先说踩坑现场。插件里有段代码:

add_menu_page(
    '数据导出',
    '数据导出',
    'manage_options',  // ← 这里
    'my-export',
    'render_export_page'
);

同时为了"灵活控制",作者在别处加了段 `user_has_cap` 过滤:

add_filter( 'user_has_cap', function( $allcaps, $caps, $args, $user ) {
    // 让"编辑"也能进导出页
    if ( in_array( 'editor', $user->roles ) ) {
        $allcaps['manage_options'] = true;  // ← 炸弹埋这里
    }
    return $allcaps;
}, 10, 4 );

表面看只多开了一个菜单,实际是把 `manage_options` 这个超管权限全局伪造了。编辑角色不仅能看到导出页,所有检查 `current_user_can( 'manage_options' )` 的地方全部放行——包括其他插件的后台、WordPress 核心的站点设置、用户管理。

更坑的是,这种伪造在 `map_meta_cap` 之前生效,很多依赖细粒度映射的防护会失效。比如某处代码做了 `current_user_can( 'delete_user', $user_id )`,底层会走 `map_meta_cap` 拆成 `delete_users` 或 `delete_user` + 同级检查,但如果前面已经被 `user_has_cap` 塞了 `manage_options`,直接短路返回 true。

我后来给的修复方案是自定义 capability + 独立映射,不碰系统权限:

// 注册菜单用自定义字符串
add_menu_page( '数据导出', '数据导出', 'my_plugin_export', 'my-export', ... );

// 只在这个 capability 上做文章
add_filter( 'map_meta_cap', function( $caps, $cap, $user_id, $args ) {
    if ( $cap !== 'my_plugin_export' ) {
        return $caps;
    }
    // 超管直接过
    if ( user_can( $user_id, 'manage_options' ) ) {
        $caps = ['manage_options'];
    // 编辑需要额外校验(比如是否绑定过二次认证)
    } elseif ( user_can( $user_id, 'edit_others_posts' ) && has_2fa_enabled( $user_id ) ) {
        $caps = ['edit_others_posts'];
    } else {
        $caps = ['do_not_allow'];
    }
    return $caps;
}, 10, 4 );

几个写后台时常忽略的权限点:

1. `admin_init` 不是万能盾牌

很多人以为 `admin_init` 里写 `current_user_can` 就安全了。但 `admin_init` 在 /wp-admin/admin-post.phpadmin-ajax.php 都会触发,而这两个入口的 `DOING_AJAX` / `DOING_CRON` 状态会让部分流程绕过预期检查。建议敏感操作再加 `check_admin_referer` 或自建 nonce 校验。

2. Nonce 别复用,更别硬编码

见过把 wp_create_nonce( 'my_nonce' ) 写死成常量字符串的,相当于没有 nonce。正确做法是每个会话/每个操作单独生成,校验时用 wp_verify_nonce 的返回值判断(1=12小时内,2=12-24小时,false=无效),别只写 if ( ! wp_verify_nonce(...) ) wp_die(); 这种"非黑即白"的判断。

3. SQL 拼接时,权限过滤和参数过滤要分层

典型错误:先按权限拼好 WHERE,再用 $wpdb->prepare 处理参数。但如果权限条件本身来自用户输入(比如按角色筛选的接口),prepare 不会帮你转义字段名和表名。权限相关的列名、表名要用白名单校验,别直接拼接。

4. 卸载/降级时的权限残留

插件卸载时记得清自定义 role 和 cap。WordPress 的 `remove_cap` 不会自动回收,如果用户角色是通过插件动态添加的,停用后 capability 可能还挂在用户元数据里,变成"僵尸权限"。

最后说个自查技巧:装 User Role Editor 或自己写段脚本,遍历所有角色的 `capabilities` 数组,看有没有"不属于 WordPress 核心、也不属于当前活跃插件"的奇怪权限键名——往往就是历史插件遗留或错误注入的。

你们后台权限还踩过哪些"看起来对了"的坑?

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