发布于14小时前14小时 背景本文记录一类常见的 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 nginxApache 环境可在虚拟主机配置中对 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 服务器脚本解析规则,避免只封禁某几个危险后缀。 代码审计、调用链与关键函数定位示意 服务器、云资产与权限边界梳理示意
13小时前13小时 排查这类问题时,建议把“上传入口”和“落盘路径”分开看。很多插件表面上只注册了后台页面,实际上传处理却挂在 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 被拒绝;允许类型能正常上传;上传目录中的脚本扩展即使历史遗留也无法执行。
创建帐户或登录后发表意见