跳转到帖子

WordPress 插件未授权文件上传漏洞的定位、无害验证与修复模板

精选回复

发布于

背景

本文记录一类常见的 WordPress 插件文件上传漏洞:插件通过 wp_ajax_nopriv 注册上传接口,但未校验登录状态、Nonce、用户权限和文件真实类型,最终导致匿名用户可向站点可访问目录写入任意文件。该问题常见于附件上传、工单提交、头像裁剪、备份恢复等功能。

以下内容用于已获授权的漏洞验证与修复。验证阶段只上传不具备执行能力的随机文本文件,不使用 WebShell,也不修改业务数据。

漏洞场景与权限边界

典型问题代码如下。接口对未登录用户开放,并直接使用客户端提供的文件名保存文件:

add_action('wp_ajax_nopriv_demo_upload', 'demo_upload');
add_action('wp_ajax_demo_upload', 'demo_upload');

function demo_upload() {
    $upload = wp_upload_dir();
    $name = basename($_FILES['file']['name']);
    $dest = $upload['path'] . '/' . $name;

    if (move_uploaded_file($_FILES['file']['tmp_name'], $dest)) {
        wp_send_json_success(array('url' => $upload['url'] . '/' . $name));
    }
    wp_send_json_error('upload failed', 500);
}

这里至少存在四个边界缺失:匿名访问没有身份校验;没有 check_ajax_referer 校验请求来源;没有 current_user_can 校验业务权限;只依赖原始文件名,没有检查扩展名、MIME 和文件内容。

若 uploads 目录允许 PHP 解析,风险可能从任意文件写入升级为远程代码执行。即使服务器禁止脚本执行,攻击者仍可能上传钓鱼页面、恶意 SVG、覆盖同名资源或消耗磁盘空间。

问题定位

首先在插件源码中检索匿名 AJAX、REST 路由和直接文件写入函数。重点确认回调是否同时具备身份、权限、Nonce 和文件类型校验。

cd /var/www/html/wp-content/plugins

grep -RInE "wp_ajax_nopriv_|register_rest_route|move_uploaded_file|wp_handle_upload|file_put_contents" .
grep -RInE "check_ajax_referer|wp_verify_nonce|current_user_can|permission_callback" .

排查 REST API 时,不要只看 permission_callback 是否存在,还要确认它是否错误地返回 __return_true。排查 AJAX 时,需要沿 add_action 找到实际回调,检查文件是否最终写入 wp_upload_dir、插件目录或站点根目录。

授权环境中的无害验证

验证目标应使用测试站点或明确授权的生产窗口。先生成不可执行的随机标记文件,再通过接口上传。不要使用固定文件名,避免覆盖现有文件。

MARKER="upload-check-$(date +%s)-$(openssl rand -hex 4)"
printf '%s\n' "$MARKER" > "/tmp/${MARKER}.txt"

curl --fail-with-body -i \
  -X POST ' \
  -F 'action=demo_upload' \
  -F "file=@/tmp/${MARKER}.txt;type=text/plain"

如果未携带 Cookie 和 Nonce 的请求返回成功,并给出可访问 URL,基本可以确认存在未授权上传。随后仅对响应中的精确 URL 发起一次 GET,核对响应体是否等于随机标记;验证完成后立即删除测试文件。

不能仅凭 HTTP 200 判断漏洞成立。WordPress admin-ajax.php 在动作不存在或执行失败时也可能返回 200 和字符 0,应结合响应 JSON、落盘文件及访问日志确认。

服务端落盘与日志排查

在服务器侧根据验证时间窗口检查新增文件。find 的时间参数应按测试时间调整,避免递归扫描造成额外负载。

find /var/www/html/wp-content/uploads -type f -mmin -30 \
  -printf '%TY-%Tm-%Td %TH:%TM:%TS %s %p\n' | sort

find /var/www/html/wp-content/uploads -type f \
  \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.phar' -o -iname '*.svg' \) \
  -printf '%TY-%Tm-%Td %TH:%TM:%TS %s %p\n'

grep -E 'POST /wp-admin/admin-ajax.php|/wp-json/' /var/log/nginx/access.log | tail -n 200

进一步检查 access.log 中同一来源地址是否先上传、后访问新文件;检查 error.log、PHP-FPM 日志中是否出现 uploads 路径脚本执行、include 失败或异常子进程。若日志由反向代理转发,应确认记录的是可信代理解析后的真实来源地址,避免直接信任可伪造的 X-Forwarded-For。

证据重点字段判断价值
Nginx 访问日志时间、来源地址、URI、状态码、请求体大小、User-Agent关联上传请求与后续访问
文件元数据路径、mtime、大小、属主、哈希确认落盘范围及时间线
PHP-FPM 日志脚本路径、异常调用、子进程信息判断文件是否被解释执行
WordPress 审计日志用户、角色、插件操作区分匿名利用与合法后台操作

安全修复代码

若业务只允许后台管理员上传,应删除 wp_ajax_nopriv 注册,并同时校验登录状态、能力和 Nonce。文件处理优先使用 WordPress API,不要自行拼接客户端文件名。

add_action('wp_ajax_demo_upload', 'demo_upload_secure');

function demo_upload_secure() {
    if (!is_user_logged_in()) {
        wp_send_json_error('authentication required', 401);
    }

    if (!current_user_can('upload_files')) {
        wp_send_json_error('forbidden', 403);
    }

    check_ajax_referer('demo_upload_action', 'nonce');

    if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
        wp_send_json_error('invalid upload', 400);
    }

    if ((int) $_FILES['file']['size'] > 2 * MB_IN_BYTES) {
        wp_send_json_error('file too large', 413);
    }

    $allowed = array(
        'txt' => 'text/plain',
        'jpg|jpeg' => 'image/jpeg',
        'png' => 'image/png',
    );

    require_once ABSPATH . 'wp-admin/includes/file.php';
    $result = wp_handle_upload(
        $_FILES['file'],
        array(
            'test_form' => false,
            'mimes' => $allowed,
        )
    );

    if (!empty($result['error'])) {
        wp_send_json_error($result['error'], 400);
    }

    wp_send_json_success(array('url' => esc_url_raw($result['url'])));
}

注意:Nonce 用于降低跨站请求伪造风险,不等同于身份认证或权限控制;current_user_can 也不能被前端传入的角色字段替代。若业务允许普通用户上传,应定义最小化 capability,而不是直接赋予管理员能力。

Web 服务器兜底配置

应用修复之外,应禁止 uploads 目录解析脚本。Nginx 可在 PHP location 之前增加更具体的拒绝规则,修改后执行配置测试再平滑重载。

location ~* ^/wp-content/uploads/.*\.(?:php[0-9]?|phtml|phar)$ {
    deny all;
    return 403;
}

location ~ \.php$ {
    try_files $uri =404;
    include fastcgi_params;
    fastcgi_pass unix:/run/php/php-fpm.sock;
}

# 验证并重载
nginx -t && systemctl reload nginx

Apache 环境可在虚拟主机配置中对 uploads 目录关闭 PHP Handler。不要只依赖可被覆盖或未启用 AllowOverride 的 .htaccess。

<Directory "/var/www/html/wp-content/uploads">
    <FilesMatch "\.(php[0-9]?|phtml|phar)$">
        Require all denied
    </FilesMatch>
</Directory>

风险处置与复核

  • 先隔离可疑文件并计算 SHA-256,不要在原路径直接执行或打开。
  • 根据最早异常文件时间向前扩展日志窗口,关联上传、访问、后台登录和插件安装记录。
  • 检查 uploads、插件目录、主题目录、mu-plugins、wp-config.php 以及计划任务中的异常改动。
  • 升级或下线问题插件;若为自研组件,将认证、授权、Nonce 和类型校验纳入回归测试。
  • 轮换可能暴露的 WordPress 管理员凭据、数据库口令、应用密钥和部署密钥,并使现有会话失效。
  • 修复后重复匿名上传测试,预期应返回 401 或 403;再使用最低权限合法账户验证正常业务。

总结

这类漏洞的核心不是单一的扩展名绕过,而是上传入口同时缺失身份认证、权限校验和文件处理约束。漏洞验证应使用不可执行的随机文本文件,并通过请求日志、文件落盘和访问记录形成闭环。修复时需要同时处理 WordPress 回调权限、上传 API、允许列表、大小限制及 Web 服务器脚本解析规则,避免只封禁某几个危险后缀。

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

排查这类问题时,建议把“上传入口”和“落盘路径”分开看。很多插件表面上只注册了后台页面,实际上传处理却挂在 wp_ajax_nopriv_*、REST 路由或直接可访问的 PHP 文件上。

先在插件目录定位入口和危险落盘函数:

cd wp-content/plugins/目标插件

rg -n "wp_ajax_nopriv_|register_rest_route|admin_post_nopriv_|wp_handle_upload|wp_upload_bits|move_uploaded_file|file_put_contents|copy\s*\(" .
rg -n "\$_FILES|php://input|base64_decode|unserialize" .

重点检查以下几项:

  • wp_ajax_nopriv_* 是否确实需要匿名访问;不需要就删除该 hook。
  • REST 路由是否缺少 permission_callback,或直接返回 true。
  • 是否只校验 nonce。nonce 只能防 CSRF,不能代替登录状态和权限校验。
  • 是否直接信任客户端文件名、扩展名或 Content-Type。
  • 上传目录是否位于 Web 可访问路径,且允许 PHP、PHTML、PHAR 等被解析。

无害验证不要上传脚本文件。可以使用内容固定的随机 TXT 标记文件,确认三个事实:未登录能否提交、服务端是否接受、文件是否能从公开 URL 访问。验证后立即删除文件和日志中的敏感请求数据。例如:

printf 'upload-audit-%s\n' "$(date +%s)" > /tmp/wp-upload-audit.txt

curl -i \
  -F 'file=@/tmp/wp-upload-audit.txt;type=text/plain' \
  '

若接口要求 nonce,应分别测试“无 Cookie”“有 nonce 但无权限”“具备权限”三种状态,避免把 nonce 校验误判成授权校验。不要通过上传 PHP 再执行来证明漏洞;能在未授权状态下落盘并获得可访问地址,通常已经足够形成证据。

处理函数可以按下面的顺序收紧:先认证和能力检查,再校验 nonce,最后校验文件类型并落盘。

function myplugin_upload_file() {
    if (!is_user_logged_in() || !current_user_can('upload_files')) {
        wp_send_json_error(array('message' => 'forbidden'), 403);
    }

    check_ajax_referer('myplugin_upload', 'nonce');

    if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
        wp_send_json_error(array('message' => 'invalid upload'), 400);
    }

    $file = $_FILES['file'];

    if ((int) $file['size'] > 2 * MB_IN_BYTES) {
        wp_send_json_error(array('message' => 'file too large'), 400);
    }

    $allowed = array(
        'jpg|jpeg' => 'image/jpeg',
        'png'      => 'image/png',
        'pdf'      => 'application/pdf',
    );

    $checked = wp_check_filetype_and_ext(
        $file['tmp_name'],
        sanitize_file_name($file['name']),
        $allowed
    );

    if (empty($checked['ext']) || empty($checked['type'])) {
        wp_send_json_error(array('message' => 'file type not allowed'), 400);
    }

    require_once ABSPATH . 'wp-admin/includes/file.php';

    $result = wp_handle_upload(
        $file,
        array(
            'test_form' => false,
            'mimes'     => $allowed,
        )
    );

    if (!empty($result['error'])) {
        wp_send_json_error(array('message' => $result['error']), 400);
    }

    wp_send_json_success(array(
        'url'  => esc_url_raw($result['url']),
        'type' => $result['type'],
    ));
}

如果是 REST API,权限应放在路由的 permission_callback,不要只在业务回调末尾补判断:

register_rest_route('myplugin/v1', '/upload', array(
    'methods'             => 'POST',
    'callback'            => 'myplugin_rest_upload',
    'permission_callback' => function () {
        return current_user_can('upload_files');
    },
));

Web 服务器层还应作为兜底,禁止上传目录执行脚本。Nginx 可增加:

location ~* ^/wp-content/uploads/.*\.(php|phtml|phar|php[0-9]*)$ {
    return 403;
}

Apache 环境可在上传目录配置:

<FilesMatch "\.(?i:php|phtml|phar|php[0-9]*)$">
    Require all denied
</FilesMatch>

修复后别只复测接口,还要排查历史文件。可以先列出上传目录中的可执行扩展名和近期异常文件,确认后再隔离,不建议直接批量删除:

find wp-content/uploads -type f \
  \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.phar' -o -iname '*.php[0-9]' \) \
  -print

find wp-content/uploads -type f -mtime -30 -printf '%TY-%Tm-%Td %TH:%TM %s %p\n' \
  | sort -r | head -200

最后建议把复测标准写明确:匿名请求返回 401/403;低权限账号无法上传;双扩展名、大小写变体和伪造 MIME 被拒绝;允许类型能正常上传;上传目录中的脚本扩展即使历史遗留也无法执行。

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

最近浏览 0

  • 没有会员查看此页面。

SiteMap HACKHAT © 2026 All rights reserved.