跳转到帖子

WordPress 自定义 REST API 未校验权限导致敏感数据越权读取:验证、留痕与修复

精选回复

发布于

一、漏洞概述

本文记录一类常见于 WordPress 自研主题和插件的权限控制漏洞:插件注册自定义 REST API 路由时未设置有效的 permission_callback,或回调函数只判断用户是否登录,却没有校验角色、能力和对象归属,导致未授权用户读取订单、客户资料、工单或内部配置。

该问题通常不属于 WordPress 核心漏洞,而是插件业务接口的访问控制缺陷。漏洞验证应在获得书面授权的测试环境或指定生产范围内进行,重点确认接口是否越过了登录边界和对象权限边界,不应批量导出真实个人信息。

项目内容
影响对象使用自定义 REST API 的 WordPress 插件、主题或业务扩展
漏洞类型未授权访问、水平越权、垂直越权
常见入口/wp-json/插件命名空间/v1/...
直接影响订单、邮箱、手机号、工单、内部配置等业务数据泄露
风险边界若接口还支持写入操作,可能进一步影响业务数据完整性

二、典型问题代码

以下代码展示了一个不安全的 REST 路由。permission_callback 返回真值后,WordPress 不会再对请求者进行能力或对象级授权判断。

<?php
add_action('rest_api_init', function () {
    register_rest_route('acme/v1', '/orders', [
        'methods'  => 'GET',
        'callback' => 'acme_list_orders',
        'permission_callback' => '__return_true'
    ]);
});

function acme_list_orders(WP_REST_Request $request) {
    global $wpdb;
    return $wpdb->get_results(
        "SELECT id, customer_email, phone, total FROM {$wpdb->prefix}acme_orders LIMIT 100"
    );
}

另一个常见问题是仅判断登录状态:

'permission_callback' => function () {
    return is_user_logged_in();
}

这种写法仍然存在垂直越权风险。普通订阅者、低权限员工账号或被盗用的前台账号,都可能访问本应只对客服或管理员开放的数据。

三、授权验证方法

验证前先确认测试域名、时间窗口、允许的请求速率和数据脱敏要求。推荐使用仅包含一条测试记录的预发布环境。验证目标是证明访问控制缺失,不是获取完整数据集。

  1. 从插件源码、路由文档或站点接口清单中确认命名空间和路径。
  2. 先发送无认证请求,记录 HTTP 状态码、响应字段和响应长度。
  3. 再使用不同权限的测试账号分别验证管理员、客服、编辑、订阅者和普通访客。
  4. 如果接口包含对象编号,只使用测试对象编号,并检查是否能读取其他测试账号创建的对象。
  5. 将响应中的邮箱、手机号等字段脱敏保存,仅保留足以复现问题的证据。
# 仅用于已授权的测试环境,避免使用真实数据编号
curl -sk -D /tmp/headers.txt \
  -H 'Accept: application/json' \
  ' \
  -o /tmp/response.json

# 查看状态码、响应类型和响应长度
awk 'BEGIN{IGNORECASE=1} /^HTTP\/|^Content-Type:|^Content-Length:/{print}' /tmp/headers.txt
wc -c /tmp/response.json
head -c 500 /tmp/response.json

# 使用低权限测试账号的应用会话进行对比,Cookie 不要提交到公开工单
curl -sk -b 'wordpress_logged_in_TEST=REDACTED' \
  -H 'Accept: application/json' \
  ' \
  -o /tmp/low_privilege.json

判断依据不能只看 HTTP 状态码。以下情况通常可以确认存在访问控制缺陷:未登录请求返回业务对象;普通账号能够读取管理端数据;更换对象编号后仍能读取其他主体数据;响应中出现数据库字段而不是统一的 401 或 403 错误。

四、源码排查与快速定位

对授权范围内的站点,可以先定位 REST 路由注册点,再检查权限回调和数据查询是否绑定当前用户。

# 在插件目录中查找 REST 路由注册和高风险权限回调
rg -n --glob '*.php' \
  'register_rest_route|permission_callback|__return_true|is_user_logged_in|current_user_can' \
  wp-content/plugins wp-content/themes

# 查找可能直接返回敏感字段的查询
rg -n --glob '*.php' \
  'customer_email|phone|wpdb|SELECT .*orders|SELECT .*users' \
  wp-content/plugins/acme-plugin

# 通过 WP-CLI 查看已安装插件及版本,结果应与资产清单交叉核对
wp plugin list --status=active --fields=name,version,update --format=table

排查时重点关注四个位置:

  • 路由注册是否明确设置了 permission_callback,而不是依赖默认行为。
  • 权限回调是否使用了符合业务要求的能力,例如 manage_woocommerce 或自定义 capability。
  • 查询是否限制到当前用户、当前租户或当前工单所属部门,不能只依赖前端传入的对象编号。
  • 返回对象是否经过字段白名单处理,避免把数据库中的内部备注、令牌和系统字段一并序列化。

五、修复示例

修复应同时覆盖路由级授权、对象级授权和输出字段控制。下面示例要求调用者具备指定能力,并且只返回允许暴露的字段。

<?php
add_action('rest_api_init', function () {
    register_rest_route('acme/v1', '/orders', [
        'methods'  => WP_REST_Server::READABLE,
        'callback' => 'acme_list_orders',
        'permission_callback' => function () {
            return current_user_can('acme_view_orders')
                ? true
                : new WP_Error(
                    'rest_forbidden',
                    'Insufficient permissions',
                    ['status' => 403]
                );
        },
        'args' => [
            'limit' => [
                'default' => 20,
                'sanitize_callback' => 'absint',
                'validate_callback' => function ($value) {
                    return $value > 0 && $value <= 50;
                }
            ]
        ]
    ]);
});

function acme_list_orders(WP_REST_Request $request) {
    global $wpdb;
    $limit = min(50, max(1, (int) $request['limit']));

    $rows = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT id, status, total, created_at
             FROM {$wpdb->prefix}acme_orders
             WHERE tenant_id = %d
             ORDER BY id DESC LIMIT %d",
            get_current_user_id(),
            $limit
        ),
        ARRAY_A
    );

    return array_map(function ($row) {
        return [
            'id'         => (int) $row['id'],
            'status'     => sanitize_key($row['status']),
            'total'      => (float) $row['total'],
            'created_at' => $row['created_at']
        ];
    }, $rows ?: []);
}

实际项目中,tenant_id = get_current_user_id() 只是示例,不能机械复制。多租户系统应从服务端会话或用户所属组织计算租户范围,不能信任请求参数中的租户编号。若接口需要管理员跨用户查询,应单独建立明确的管理能力,并记录操作者、查询条件和结果数量。

六、日志留痕与内网攻防链路分析

这类漏洞经常作为内网入侵后的数据发现环节:攻击者获得低权限 WordPress 账号后,通过 REST API 扩大数据访问范围,再利用泄露的邮箱、内部 URL 或配置字段寻找其他系统入口。因此修复时不能只关闭单个接口,还要检查同一站点及相邻系统的权限边界。

Nginx 或 Apache 访问日志中可重点检索 REST 路径、异常频率、不同账号访问相同对象以及大量 200 响应。示例:

# Nginx 日志格式为:$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent
awk '$7 ~ /\/wp-json\/acme\/v1\/orders/ {print}' /var/log/nginx/access.log \
  | awk '{print $1, $4, $7, $9, $10}' | head -n 100

# 统计该接口按来源地址的请求量
awk '$7 ~ /\/wp-json\/acme\/v1\/orders/ {count[$1]++} END {for (ip in count) print count[ip], ip}' \
  /var/log/nginx/access.log | sort -nr | head

同时检查 WordPress、WAF、反向代理和身份系统日志。重点字段包括请求时间、源地址、User-Agent、认证主体、HTTP 状态码、响应大小和对象编号。若发现低权限账号在短时间内连续访问大量对象,应先保全原始日志和账号审计记录,再执行会话吊销、密码轮换和令牌替换。

七、风险与修复建议

  • 升级存在问题的插件或主题,并确认更新后路由权限回调仍然生效,不能只根据版本号判断修复完成。
  • 对所有自定义 REST、AJAX 和 GraphQL 接口进行统一权限审计,特别是返回用户、订单、文件和配置数据的接口。
  • 为能力模型建立最小权限矩阵,明确访客、订阅者、客服、编辑和管理员可访问的资源范围。
  • 在网关或 WAF 层对异常高频读取、单账号大量对象遍历和批量分页请求设置告警,但不要把 WAF 规则当作根本修复。
  • 对已暴露的数据进行影响评估,检查是否包含密码重置令牌、云访问密钥、数据库连接信息或内网地址,并按应急流程轮换相关凭据。
  • 将接口测试纳入 CI,至少覆盖未登录、低权限、合法权限和跨对象访问四组用例,并断言返回 401、403 或脱敏结果。

八、验证修复是否生效

修复完成后,应使用与初次验证相同的测试请求进行回归。未登录请求应返回 401 或 403;低权限账号应被拒绝;合法账号只能获取授权范围内的对象;更换对象编号、分页参数和租户标识不能突破数据边界。还应检查应用日志中是否记录了拒绝事件,以及错误响应是否泄露数据库表名、文件路径或堆栈信息。

漏洞库记录的有效证据应包含:受影响组件和版本、接口路径、请求身份、最小化响应样本、权限差异、日志时间点、修复版本和回归结果。不要在报告中保存完整客户数据或可继续使用的认证材料。

九、总结

WordPress 自定义 REST API 的主要风险不在路由本身,而在权限判断没有落实到业务对象。漏洞验证应围绕认证边界、角色能力和对象归属逐层进行;修复则需要同时完善路由权限、服务端数据范围、输出字段和审计告警。对于已经暴露的站点,还应沿着账号、接口、配置和内网系统的链路检查凭据复用与横向影响,确保漏洞关闭后不存在遗留访问路径。

服务器与云环境安全示意图
服务器、云资产与权限边界梳理示意
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

这类问题建议先区分“未登录可读”和“低权限用户越权读取”两种情况,修复点不完全一样。可以先对自定义路由做一轮复现和留痕:

curl -i '
curl -i -H 'Authorization: Basic ...' \
  '

同时检查是否存在分页、批量查询或可控 ID 参数,避免只验证单个对象时漏掉:

curl -s '
curl -s '

路由注册时不要使用空的 permission_callback,也不要仅依赖前端隐藏接口。读取对象前应同时校验能力和对象归属,例如:

add_action('rest_api_init', function () {
    register_rest_route('acme/v1', '/orders/(?P<id>\d+)', [
        'methods'  => WP_REST_Server::READABLE,
        'callback' => 'acme_get_order',
        'permission_callback' => function (WP_REST_Request $request) {
            $id = (int) $request['id'];

            if (!is_user_logged_in()) {
                return new WP_Error(
                    'rest_forbidden',
                    'Authentication required',
                    ['status' => 401]
                );
            }

            if (current_user_can('manage_woocommerce')) {
                return true;
            }

            $order = wc_get_order($id);
            if (!$order || (int) $order->get_customer_id() !== get_current_user_id()) {
                return new WP_Error(
                    'rest_forbidden',
                    'You cannot access this resource',
                    ['status' => 403]
                );
            }

            return true;
        },
        'args' => [
            'id' => [
                'validate_callback' => function ($value) {
                    return ctype_digit((string) $value) && (int) $value > 0;
                },
                'sanitize_callback' => 'absint',
            ],
        ],
    ]);
});

回调里还应再次按当前用户查询和裁剪字段,不能直接把完整的数据库对象或用户元数据序列化返回。尤其要检查邮箱、手机号、地址、内部备注、重置令牌等字段是否被无条件暴露。

function acme_get_order(WP_REST_Request $request) {
    $order = wc_get_order((int) $request['id']);

    if (!$order) {
        return new WP_Error('not_found', 'Order not found', ['status' => 404]);
    }

    $is_admin = current_user_can('manage_woocommerce');
    $data = [
        'id'     => $order->get_id(),
        'status' => $order->get_status(),
        'total'  => $order->get_total(),
    ];

    if ($is_admin) {
        $data['billing_email'] = $order->get_billing_email();
    }

    return rest_ensure_response($data);
}

留痕方面,重点记录请求时间、用户 ID、IP、路由、对象 ID、响应状态和失败原因,但不要把 Authorization、Cookie 或完整敏感响应写入日志。修复后建议用未登录、普通用户、其他用户、管理员四组身份回归测试,并检查 Nginx/CDN/WordPress 缓存是否缓存过旧的 200 响应;涉及个人数据时应同步清理缓存和历史日志中的敏感内容。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。