后台鉴权"三层皮"被戳穿实录:我是怎么让 `check_ajax_referer` 变成摆设,又让订阅者摸到数据导出接口的
上周帮朋友审计一个会员插件,发现一个特经典的"层层有校验、层层都漏风"案例。不是没写权限检查,是写了但互相没衔接上, attacker 用一根针就捅穿了三层皮。整理出来给大伙提个醒。
第一层皮:AJAX 入口的 `nonce` 成了"安慰剂"
插件注册了个 `wp_ajax_*` 处理数据导出,代码长这样:
```php add_action( 'wp_ajax_myplugin_export_data', function() { check_ajax_referer( 'myplugin_export_nonce', 'nonce' ); // 这里直接开始查库、拼 CSV $data = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}myplugin_orders" ); // ... } ); ```看起来有 nonce 校验对吧?问题出在 nonce 参数名。前端 JS 里实际发的是 _ajax_nonce,但后端读的是 nonce。check_ajax_referer 找不到参数时,默认行为是 die(-1)——但这位开发者前面套了个自定义错误处理器,把 -1 当成"业务异常"吞了,流程继续往下走。
更骚的是,这个 nonce 本身是在前台页面用 wp_create_nonce 生成的,任何登录用户都能拿到,根本不区分角色。所以第一层皮:有校验,但校验对象错了、失败处理也错了。
第二层皮:REST 路由的 `permission_callback` 写了,但写"活"了
同个插件还有 REST 端点做批量操作:
```php register_rest_route( 'myplugin/v1', '/bulk-update', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_bulk_update' ], 'permission_callback' => function( $request ) { $params = $request->get_json_params(); return ! empty( $params['admin_secret'] ) && $params['admin_secret'] === get_option( 'myplugin_secret' ); } ] ); ```看到没?权限回调里压根没调 current_user_can,搞了个"共享密钥"方案。这密钥存在 wp_options 里,前端配置页用 wp_localize_script 直接输出给 JS 了——任何能打开 F12 的人都能拿到。而且 REST 端点没要求 cookie/nonce,跨站直接 POST 就行。
第二层皮:有 permission_callback 这个壳,但壳里装的是稻草。
第三层皮:数据层最后一道闸,被 SQL 拼接绕过去了
最离谱的是导出接口里的查询。前面代码直接 SELECT * FROM ...,没加 WHERE user_id = ... 之类的隔离。本来就算前两层漏了,如果数据层按"当前用户只能看自己的"来查,损失也能控住。结果这里全量拉,订阅者导出了全站订单。
三层皮各自独立,每层都"看起来有防护",但 attacker 的路线是:
拿到前端泄露的 secret → 直接调 REST 批量操作 或 伪造 AJAX 请求 → 触达无隔离的数据层
我现在的"补漏"习惯
1. 校验失败必须硬中断:不用自定义处理器包 check_ajax_referer,要么 wp_die() 要么抛异常让上层框架处理,-1 就是 -1,别转义。
2. REST 权限回调必须显式验角色:哪怕再加业务层校验,底层也得有 current_user_can( 'manage_options' ) 或对应 capability 兜底。共享密钥只当二次验证,不能替代身份鉴权。
3. 数据查询默认带用户隔离:后台管理接口可以全量,但任何前端可达的接口,SQL 先写 WHERE user_id = %d 再考虑其他条件。用 $wpdb->prepare 是必须的,但这和权限隔离是两回事。
4. 前后端参数名对一遍:nonce 字段名、secret 参数名,前后端各写各的是重灾区。我现在会在接口文档里强制标注"后端读取键名",review 时专门扫这个。
这插件三层问题单看都不算罕见,但叠在一起就形成了一条完整的渗透链。大伙有类似"层层有、层层漏"的经历么?