后台菜单"挂羊头卖狗肉":我是如何用 `user_has_cap` 伪造权限让"编辑"角色看到超管面板的
上周帮朋友审一个内部插件,发现一个挺隐蔽的权限漏洞——不是没鉴权,而是鉴权"看起来对了,实际错了"。记录一下,给写后台配置页的兄弟提个醒。
问题出在自定义菜单的 `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.php、admin-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 核心、也不属于当前活跃插件"的奇怪权限键名——往往就是历史插件遗留或错误注入的。
你们后台权限还踩过哪些"看起来对了"的坑?

