后台菜单"隐身越权":我如何用 `current_user_can` 与 `nonce` 的"双重真空"造出一个管理员都看不见的漏洞入口

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

上周给插件加了一个"数据导出"的隐藏入口,本意是方便超级管理员在紧急情况下绕过常规菜单直接拉取全站日志。结果安全审计的同事拿普通编辑账号一点,文件直接开始下载——我人都傻了。复盘之后发现,我把"菜单不可见"和"操作不可执行"混成了一回事,踩了三个连环坑。

坑一:无菜单 ≠ 无路由

我的隐藏入口长这样:

// 错误示范:只藏菜单,不拦请求
add_action('admin_init', function() {
    if (isset($_GET['my_plugin_secret_export'])) {
        // 直接开始输出 CSV...
        $this->dump_logs();
        exit;
    }
});

问题是 admin_init 对所有已登录用户都会触发,不管这人是订阅者还是编辑。菜单项可以靠不 add_submenu_page 来"隐身",但直接 GET 请求根本不经过菜单渲染层。这相当于给后台装了一扇没有门牌的暗门,门牌撕了,锁也没装。

坑二:current_user_can 的"默认放行"陷阱

第一次修补我加了权限检查,但写得很敷衍:

// 仍有问题: capability 字符串拼写错误,WordPress 返回 false,但逻辑写反了
if (!current_user_can('manage_optinos')) {  // 注意 optinos
    return;  // 拼写错误导致这里永远进不来,因为不存在的 capability 对任何人都是 false
}

更隐蔽的是另一种写法:把 current_user_can 的返回值当布尔用,却忘了某些角色在特定场景下会被动态赋予临时权限。比如自定义角色插件可能给编辑者临时开了 export 能力,你的硬编码 administrator 检查就漏了。

我现在强制用"白名单 capability + 显式阻断"模式:

$required_cap = 'my_plugin_full_export';  // 自定义 capability,精确控制
if (!current_user_can($required_cap)) {
    wp_die(__('无权访问此导出端点。', 'my-plugin'), 403);
}

并且这个 capability 只在插件激活时通过 add_cap 绑定给管理员角色,不依赖任何现有系统权限的"近似替代"。

坑三:CSRF 防护的"半吊子"比没有更危险

最开始的版本完全没 nonce。补上之后我又犯了另一个错:只在表单页生成 nonce,导出链接直接裸奔。

// 错误:导出链接没有 nonce
<a href="<?php echo admin_url('admin.php?my_plugin_secret_export=1'); ?>">导出</a>

这等于给 XSS 留了一个完美的 CSRF 触发器。攻击者不需要知道你的隐藏参数,只需要让已登录管理员点一个恶意链接,或者通过一个被注入的脚本自动触发 window.location 跳转。

现在的链路是:生成带 nonce 的 URL → 校验 nonce → 校验 capability → 执行操作,三步缺一不可。

// 生成
$export_url = wp_nonce_url(
    admin_url('admin.php?my_plugin_secret_export=1'),
    'my_plugin_export_action',
    '_my_plugin_nonce'
);

// 校验
if (!isset($_GET['_my_plugin_nonce']) || 
    !wp_verify_nonce($_GET['_my_plugin_nonce'], 'my_plugin_export_action')) {
    wp_die(__('安全校验失败,请刷新页面后重试。', 'my-plugin'), 403);
}

坑四:SQL 导出里的"权限放大"

导出功能本身还要查数据库。我最初直接拿 $_GET['log_type'] 拼进 WHERE 子句,心想反正前面已经鉴权了。但这是一个"已授权用户执行未授权查询"的通道——管理员能导出所有日志,可如果 log_type 被篡改成直接注入 UNION SELECT 呢?

现在的查询层强制用 $wpdb->prepare,并且 log_type 先过白名单映射:

$allowed_types = ['error', 'access', 'audit'];
$type = isset($_GET['log_type']) ? sanitize_key($_GET['log_type']) : 'error';

if (!in_array($type, $allowed_types, true)) {
    wp_die(__('无效的日志类型。', 'my-plugin'), 400);
}

$results = $wpdb->get_results($wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}my_plugin_logs 
     WHERE log_type = %s 
     AND created_at >= %s",
    $type,
    gmdate('Y-m-d', strtotime('-30 days'))
));

现在的校验流总结

画了个小流程贴在工位上:

  1. 请求到达 → 先拦 nonce(防 CSRF)
  2. nonce 通过 → 再验 capability(防越权)
  3. 权限通过 → 参数过白名单 + prepare(防注入)
  4. 全部通过 → 记录审计日志(谁、何时、导出什么类型)

最后一步是后来加的。隐藏入口的问题在于"不可见性"本身就是安全风险——你不知道有没有人偷偷在用。现在每次导出都会写一条审计记录到单独表,定期扫异常。

你们插件里有没有类似的"暗门"设计?是怎么做权限闭环的?我尤其好奇那种"临时提权"场景(比如客服需要导出单个用户数据),你们是走 OAuth 风格的临时 token,还是直接在 capability 里玩动态加减?

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