后台表单被"顺手牵羊":我如何用 nonce 三重校验堵住 CSRF 与越权提交的"时间差漏洞"
上周帮朋友审计一个会员积分插件,发现个挺隐蔽的口子。后台有个"手动调整积分"的表单,管理员填完用户ID、积分值、备注,点提交就生效。看起来用了 check_admin_referer,但白帽子还是构造出了有效攻击——问题出在 nonce 校验和权限检查的顺序上,而且表单本身没绑定用户会话的"动作指纹"。
先贴当时有问题的简化代码:
// 处理保存请求
public function handle_save() {
// 第一步:只校验了 nonce,但没校验这个 nonce 是不是当前用户生成的
if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'adjust_points' ) ) {
wp_die( '非法请求' );
}
// 第二步:权限检查在数据读取之后,而且用了固定 capability
$user_id = absint( $_POST['target_user'] );
$points = floatval( $_POST['points'] );
if ( ! current_user_can( 'manage_options' ) ) { // ③ 这里才拦
wp_die( '权限不足' );
}
// 实际写入...
update_user_meta( $user_id, 'credit_points', $points );
}
三个漏洞叠在一起了:
第一,nonce 没和用户身份挂钩。 wp_verify_nonce 只验证字符串有效性,不验证"这个 nonce 是不是你本人在当前会话生成的"。如果管理员 A 的浏览器自动保存了表单,或者中间人截获了一次合法请求,重放攻击直接过。
第二,current_user_can 放太后面。 数据已经 $_POST 读进来了,如果前面某步抛异常或者被 hook 拦截,权限检查可能根本走不到。正确的顺序必须是:先鉴权,再验 nonce,最后读数据。
第三,capability 粒度太粗。 manage_options 是给超级管理员的,但"调整积分"这种操作应该单独注册一个 adjust_user_points capability,通过 add_cap 分配给特定角色。否则任何有 manage_options 的人——包括被劫持的子管理员账号——都能动积分。
我改完后的结构是这样的,三层校验嵌套:
public function handle_save() {
// ① 最先:当前用户有没有这个具体操作的权限
if ( ! current_user_can( 'adjust_user_points' ) ) {
wp_send_json_error( [ 'msg' => '无权操作' ], 403 );
}
// ② 其次:nonce 校验,且绑定到当前用户 ID
$nonce_action = 'adjust_points_' . get_current_user_id();
if ( ! wp_verify_nonce( $_POST['_wpnonce'] ?? '', $nonce_action ) ) {
wp_send_json_error( [ 'msg' => '校验失败,请刷新页面重试' ], 403 );
}
// ③ 最后:数据合法性校验(防注入层)
$user_id = absint( $_POST['target_user'] ?? 0 );
if ( ! get_userdata( $user_id ) ) {
wp_send_json_error( [ 'msg' => '目标用户不存在' ] );
}
$points = floatval( $_POST['points'] ?? 0 );
$points = round( $points, 2 ); // 业务精度限制
// ④ 写入前再加一道:操作日志留痕
$this->log_operation( get_current_user_id(), $user_id, $points, $_SERVER['REQUEST_TIME'] );
update_user_meta( $user_id, 'credit_points', $points );
wp_send_json_success();
}
nonce 生成端也要对应改,把用户 ID 揉进 action:
wp_nonce_field( 'adjust_points_' . get_current_user_id() );
这样即使攻击者拿到了某个 nonce 字符串,换到另一个会话里直接失效,因为 get_current_user_id() 对不上。
再说 SQL 注入这层。上面用了 absint 和 floatval,但如果是字符串字段——比如那个"备注"——千万别直接拼接。我习惯用 $wpdb->prepare 或者 WordPress 的 meta 函数(它们内部有 prepare)。但如果你的插件自建了表,且用了原生查询,注意 prepare 的占位符限制:
// 错误的:IN 子句不能直接 prepare
$ids = implode( ',', array_map( 'intval', $_POST['user_ids'] ) ); // 先强制转整
$results = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}my_table WHERE user_id IN ($ids)" );
// 这里 $ids 已经全是整数,但如果是字符串数组呢?
// 更稳的:用 $wpdb->prepare 处理固定数量,或者上 esc_sql
$placeholders = implode( ',', array_fill( 0, count( $user_ids ), '%d' ) );
$query = $wpdb->prepare( "SELECT * FROM table WHERE user_id IN ($placeholders)", ...$user_ids );
有个冷门坑:$wpdb->prepare 从 WP 4.8.3 之后对 %s 加了强制引号,老代码里如果用了 LIKE '%{$search}%' 这种拼接,prepare 会把它当成完整字符串值包起来,导致 LIKE 通配符失效。得拆成:
$like = '%' . $wpdb->esc_like( $search ) . '%';
$query = $wpdb->prepare( "SELECT * FROM table WHERE name LIKE %s", $like );
最后说个我踩过的"时间差"场景。后台列表页批量操作,选中几条记录点"删除",nonce 是在列表页生成的。但如果用户开了两个标签页,在 A 页删了一条,B 页的 nonce 其实没变——WordPress 的 nonce 默认 12-24 小时有效,不是单次消费。敏感操作建议加二次确认层,或者自己实现 token 消费机制(存 transient,用一次删一次)。
你们插件里有没有遇到过 nonce "看起来防了但实际没防住"的情况?或者 prepare 的引号陷阱坑过你?欢迎贴代码细聊。

