后台表单被"顺手牵羊":我如何用 nonce 三重校验堵住 CSRF 与越权提交的"时间差漏洞"

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

上周帮朋友审计一个会员积分插件,发现个挺隐蔽的口子。后台有个"手动调整积分"的表单,管理员填完用户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 注入这层。上面用了 absintfloatval,但如果是字符串字段——比如那个"备注"——千万别直接拼接。我习惯用 $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 的引号陷阱坑过你?欢迎贴代码细聊。

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