跳转到帖子

背景

文件上传是 Web 安全里很常见的风险点,但实际排查时不能只盯着“能不能上传 WebShell”。在真实业务里,上传链路通常会经过前端校验、后端接口、对象存储、CDN、异步处理、图片压缩、文件预览等多个环节,任何一个环节处理不当,都可能变成越权访问、存储型 XSS、服务端资源消耗、敏感文件泄露,甚至远程代码执行的入口。

这篇文章整理一次常见上传模块的安全排查思路,重点放在漏洞复现、代码审计和修复验证。内容不提供恶意利用引导,只讨论如何在授权环境中识别风险、定位问题和加固实现。

问题场景

某业务系统提供附件上传能力,用于上传工单图片、PDF 报告和少量办公文档。接口大致形态如下:

POST /api/file/upload HTTP/1.1
Content-Type: multipart/form-data

[email protected]
bizType=ticket

上传成功后接口返回文件访问地址:

{
  "code": 0,
  "url": "
}

表面看是普通上传功能,但排查时发现几个值得关注的点:

  • 后端依赖前端限制文件类型,服务端只做了很弱的扩展名判断。
  • 文件访问路径可预测,且没有权限校验。
  • 部分文件会进入预览服务进行解析。
  • 上传目录和业务静态资源目录混用。

技术分析

上传风险通常不止一个漏洞点,而是一条处理链上的多个安全假设失效。排查时建议按“入口校验、存储策略、访问控制、解析行为、二次处理”五个方向拆开看。

一、入口校验:不要只看扩展名

很多系统会做类似下面的判断:

String filename = file.getOriginalFilename();
if (!filename.endsWith(".jpg") && !filename.endsWith(".png")) {
    throw new IllegalArgumentException("unsupported file type");
}

这种写法问题比较明显:

  • 只判断原始文件名,容易被大小写、双扩展名、特殊字符干扰。
  • 没有检查实际文件内容。
  • 没有处理文件名规范化,可能引入路径穿越或存储覆盖风险。

更稳妥的做法是组合校验:扩展名白名单、MIME 类型参考校验、文件头特征校验、文件内容解析验证。注意 MIME 不能作为唯一依据,因为客户端可控。

private static final Set<String> ALLOW_EXT = Set.of("jpg", "jpeg", "png", "pdf");

String originalName = file.getOriginalFilename();
String safeName = Paths.get(originalName).getFileName().toString();
String ext = StringUtils.substringAfterLast(safeName, ".").toLowerCase(Locale.ROOT);

if (!ALLOW_EXT.contains(ext)) {
    throw new IllegalArgumentException("unsupported file extension");
}

byte[] header = file.getInputStream().readNBytes(16);
if (!FileTypeDetector.match(ext, header)) {
    throw new IllegalArgumentException("file signature mismatch");
}

这里的关键不是“检测所有格式”,而是业务允许什么就检测什么。上传能力越通用,安全边界越难维护。

二、存储策略:上传目录不要参与动态解析

一个经常被忽略的问题是上传文件最终落在哪里。如果上传目录位于应用静态目录,或被 Web 容器按脚本、模板、SSI、HTML 等方式解析,就会放大风险。

推荐策略:

  • 上传文件存储到应用目录之外,避免和代码、模板、静态资源混用。
  • 统一使用随机文件名,不使用用户传入文件名作为存储名。
  • 对象存储桶按业务隔离,读写权限分离。
  • 下载通过受控接口或签名 URL,避免公开目录遍历式访问。

例如生成存储 Key 时,可以保留扩展名但不要保留原始文件名:

String ext = validatedExt;
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String objectKey = "upload/" + datePath + "/" + UUID.randomUUID() + "." + ext;

如果必须提供原始文件名用于下载展示,也建议放在数据库字段里,通过响应头控制:

Content-Disposition: attachment; filename="report.pdf"
X-Content-Type-Options: nosniff

三、访问控制:文件 URL 不等于授权

很多上传功能的问题并不在上传本身,而在访问控制。比如工单附件上传后,只要知道 URL 就能访问。这类问题在业务上通常属于越权访问或敏感信息泄露。

排查时可以重点看:

  • 附件表是否绑定用户、租户、业务对象 ID。
  • 下载接口是否检查当前用户是否有业务对象权限。
  • 文件地址是否长期有效、可预测。
  • CDN 或对象存储是否允许匿名访问私有文件。

比较稳妥的方式是:私有附件不直接暴露永久公开 URL,而是通过下载接口鉴权后返回短期签名地址,或由服务端转发文件流。

GET /api/file/download?id=12345

后端检查:
1. 当前用户是否登录
2. 文件是否存在
3. 文件所属业务对象是否属于当前用户可访问范围
4. 生成短期有效下载地址或返回文件流

这里需要避免一个常见误区:文件名随机并不能替代权限校验。随机路径只能提高猜测成本,不能构成访问控制。

四、预览服务:解析器是高风险边界

很多业务会对 PDF、Office、图片做在线预览。安全上要把预览服务当作不可信文件解析边界处理,因为历史上图片库、压缩库、文档解析库都出现过高危漏洞。

建议重点检查:

  • 解析组件版本是否长期未更新。
  • 预览服务是否和主业务部署在同一权限环境。
  • 解析任务是否有超时、内存、CPU、文件大小限制。
  • 是否允许解析压缩包、嵌套文档、复杂矢量图等高风险格式。

如果条件允许,预览服务最好容器化隔离,并限制系统权限和网络访问。例如只读根文件系统、禁用不必要能力、限制出网:

docker run --rm \
  --read-only \
  --network none \
  --memory 512m \
  --cpus 1 \
  -v /data/preview/input:/input:ro \
  -v /data/preview/output:/output:rw \
  preview-worker:latest

这个配置不是万能方案,但能显著降低解析器漏洞导致横向影响的概率。

五、内容展示:防止存储型 XSS

如果允许上传 SVG、HTML、XML、Markdown,或者上传后的文件会以内联方式展示,就需要关注存储型 XSS。即使只是图片上传,也要留意 SVG 被当成图片格式放行的情况。

常见加固方式:

  • 不允许上传 HTML、SVG 等可执行脚本能力较强的格式,除非确有业务需要。
  • 下载响应使用 attachment,避免浏览器直接渲染。
  • 设置准确的 Content-Type,并加 X-Content-Type-Options: nosniff。
  • 对用户可控文件名、标题、描述做 HTML 转义。
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="safe-name.pdf"
X-Content-Type-Options: nosniff

漏洞复现的安全边界

在授权测试中,复现上传风险不需要放置真实恶意载荷。更推荐使用无害样本验证安全控制是否有效,例如:

  • 构造扩展名和文件头不一致的测试文件,验证服务端是否拒绝。
  • 上传超大文件或异常尺寸图片,验证大小和解析限制。
  • 上传普通文本并伪造 Content-Type,验证是否只依赖客户端 MIME。
  • 使用不同账号访问对方附件,验证鉴权逻辑。
  • 上传带特殊字符的文件名,验证路径规范化和响应头处理。

示例:使用 curl 验证服务端是否仅依赖 MIME 类型:

curl -i -X POST " \
  -H "Authorization: Bearer <token>" \
  -F "[email protected];type=image/png" \
  -F "bizType=ticket"

如果 sample.txt 被当作图片接收,说明服务端校验链路至少存在缺口,需要继续看后续存储和展示是否会造成实际影响。

代码审计关注点

做上传模块代码审计时,我一般会从接口入口顺着数据流往后看,重点确认以下位置:

审计点关注内容
Controller是否限制文件大小、数量、业务类型,是否存在未鉴权接口
文件名处理是否使用原始文件名落盘,是否做路径规范化
类型判断是否仅依赖扩展名或 Content-Type
存储实现是否上传到公开桶,Key 是否可预测,是否覆盖同名文件
下载接口是否校验业务权限,是否存在 IDOR
预览任务解析器版本、资源限制、隔离策略
日志记录是否记录敏感文件名、临时签名 URL、用户隐私信息

一个比较典型的危险写法是直接拼接路径:

String savePath = uploadDir + "/" + file.getOriginalFilename();
file.transferTo(new File(savePath));

建议至少改成路径规范化后再检查目标路径是否仍在允许目录内:

Path base = Paths.get(uploadDir).toAbsolutePath().normalize();
Path target = base.resolve(randomName).normalize();

if (!target.startsWith(base)) {
    throw new SecurityException("invalid path");
}

file.transferTo(target.toFile());

修复建议

综合来看,上传模块加固可以按优先级落地:

  • 服务端强制白名单校验,不信任前端限制。
  • 上传文件使用随机存储名,原始文件名仅作为展示字段。
  • 上传目录与应用执行目录隔离,禁止动态解析。
  • 私有文件必须通过业务权限校验访问。
  • 限制文件大小、数量、分辨率、压缩层级和解析耗时。
  • 预览服务独立部署并做沙箱隔离。
  • 响应头设置 Content-Disposition、Content-Type、nosniff。
  • 增加审计日志,但避免记录敏感下载链接和隐私内容。

上传功能的安全目标不是“拦住所有奇怪文件”,而是让不可信文件在整个生命周期内都不能越过边界:不能被错误解析,不能被未授权访问,不能影响主业务运行。

总结

文件上传看起来是基础功能,但它连接了认证授权、文件系统、对象存储、浏览器渲染和第三方解析库。单点校验很难覆盖完整风险,实际排查时应从链路角度看问题。

比较实用的审计顺序是:先确认接口是否鉴权,再看服务端类型校验,然后看存储位置和访问方式,最后检查预览解析与浏览器展示。这样既能避免停留在表面判断,也能把风险和业务影响讲清楚,便于推动修复。

网络流量与边界分析示意图
网络请求、日志与边界流量分析示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…