跳转到帖子

背景

文件上传是 Web 系统里很常见的能力:头像、附件、报表导入、工单截图、插件包等都可能涉及上传。也正因为它离业务近、入口多,很多安全问题并不是出在单个函数,而是出在一整条处理链路里:前端限制、后端校验、存储位置、访问方式、解析规则、权限控制,只要其中某一环假设错误,就可能产生风险。

这篇整理偏代码审计和防护基线,不讨论攻击利用细节,也不提供可直接用于入侵的样例。重点是从审计角度判断一个上传功能是否安全,以及如何把风险收敛到可控范围。

常见问题场景

审计上传功能时,我通常会先把场景分成几类,因为不同场景的风险点不一样:

  • 普通附件上传:如图片、PDF、Office 文档,主要关注类型校验、存储隔离、访问权限和内容安全。

  • 结构化文件导入:如 CSV、Excel、XML、JSON,除了文件类型,还要关注解析器安全、公式注入、实体解析、反序列化等问题。

  • 压缩包上传:如 ZIP、TAR,用于批量导入或插件安装,重点关注路径穿越、解压炸弹、覆盖文件、符号链接。

  • 业务包或扩展包上传:如主题、插件、规则包,这类最危险,需要严格的签名校验、沙箱或离线审核流程。

如果一个系统把所有上传都交给同一段通用代码处理,但没有按业务场景拆分策略,通常会留下比较多的边界问题。

审计时先看完整链路

不要只盯着后缀名判断。一个上传文件从进入系统到被访问,通常会经过以下链路:

  • 请求入口:接口是否需要认证,是否有业务权限校验。

  • 文件接收:大小限制、字段名、临时目录、异常处理。

  • 类型验证:后缀、Content-Type、Magic Number、文件结构解析。

  • 文件命名:是否使用用户可控文件名,是否存在路径拼接问题。

  • 存储落点:是否落在 Web 可执行目录,是否和业务代码同目录。

  • 访问方式:直链访问、鉴权下载、CDN、对象存储。

  • 后处理:缩略图、转码、解压、导入、杀毒扫描、异步任务。

  • 清理策略:临时文件是否及时删除,失败任务是否留下残留。

很多问题不是上传瞬间发生的,而是发生在后处理阶段。比如缩略图服务调用了不安全的解析库,或者压缩包解压时没有校验目标路径。

后缀、MIME 与 Magic Number 的关系

只依赖后缀名或请求头里的 Content-Type 都不可靠。Content-Type 是客户端可控字段,后缀也可以随意改名。比较稳妥的做法是多层验证,并且以服务端解析结果为准。

推荐的校验顺序:

  • 先做业务白名单:这个接口到底允许哪些类型,不要用黑名单。

  • 检查文件名规范:长度、字符集、是否包含路径分隔符和控制字符。

  • 检查后缀:统一转小写,处理多后缀、空白字符、Unicode 混淆。

  • 检查文件头:通过 Magic Number 判断基础类型。

  • 必要时做深度解析:如图片重新解码,文档进入专用解析沙箱。

一个常见的基线示例:

// 伪代码:仅示意安全思路
allowedExt = ["jpg", "jpeg", "png", "pdf"]
allowedMime = ["image/jpeg", "image/png", "application/pdf"]

filename = normalizeFilename(upload.originalName)
ext = getLowercaseExt(filename)

if ext not in allowedExt:
    reject("unsupported extension")

if upload.size > MAX_SIZE:
    reject("file too large")

mime = detectByMagicNumber(upload.tempPath)
if mime not in allowedMime:
    reject("invalid file type")

newName = randomUUID() + "." + ext
saveToNonExecutableStorage(upload.tempPath, newName)

这里有两个细节值得注意:第一,保存名不要沿用原始文件名;第二,检测通过后也不要把文件放到可执行目录。

存储落点比很多人想得更关键

上传目录的安全边界非常重要。即便前面的类型校验出现失误,只要上传文件无法被解释执行,风险也会降低很多。

比较推荐的做法:

  • 上传文件存放在 Web 根目录之外。

  • 下载统一走后端接口,接口做权限校验和审计记录。

  • 对象存储桶默认私有,通过短期签名 URL 访问。

  • 上传目录挂载为不可执行,避免脚本解释器处理。

  • 文件名使用随机 ID,原始文件名只作为元数据保存。

如果业务必须使用静态服务器直出文件,也至少应保证该目录不会执行任何脚本。以 Nginx 为例,可以对上传目录关闭脚本解析,并明确返回静态内容:

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    default_type application/octet-stream;

    # 不在该 location 内转发到任何脚本解释器
    location ~* \.(php|jsp|asp|aspx|cgi|pl)$ {
        return 403;
    }
}

实际生产环境还要结合框架、容器和网关配置检查。不要只看应用代码,Web Server 的解析规则也经常是风险来源。

文件名与路径拼接问题

用户上传文件时,原始文件名是高度不可信输入。代码审计里经常能看到类似逻辑:

// 风险示例:不要直接这样做
savePath = uploadDir + "/" + request.filename
move(upload.tempPath, savePath)

这里可能出现的问题包括路径穿越、覆盖已有文件、特殊字符导致日志或页面展示异常,以及不同操作系统路径规则差异。正确思路是:原始文件名不参与真实存储路径,只保存在数据库里用于展示,并在展示时做 HTML 编码。

// 更稳妥的处理思路
originName = sanitizeForDisplay(upload.originalName)
storageName = uuid() + safeExtension
storagePath = joinPath(baseUploadDir, storageName)

if not isSubPath(storagePath, baseUploadDir):
    reject("invalid path")

move(upload.tempPath, storagePath)
saveMeta(originName, storageName, ownerUserId, mime, size)

对于 Java、Go、Node.js、Python 等不同语言,都要注意路径规范化函数的差异。尤其是在 Windows 环境下,反斜杠、盘符、保留文件名也要纳入测试。

压缩包上传的特殊风险

压缩包处理是上传功能中比较容易被低估的一类。它的问题不只在文件类型,而在解压过程。

审计时重点看:

  • 是否检查压缩包内每个文件的解压目标路径。

  • 是否限制解压后的总大小、文件数量和目录层级。

  • 是否处理符号链接、硬链接、绝对路径。

  • 是否允许覆盖已有文件。

  • 解压目录是否与业务代码、配置目录隔离。

解压时应对每个 entry 做规范化校验,确保最终路径仍在目标目录内:

// 伪代码:校验解压路径
base = canonicalPath("/data/app/import/tmp/job-123")

for entry in archive.entries:
    target = canonicalPath(joinPath(base, entry.name))

    if not target.startsWith(base + pathSeparator):
        reject("invalid archive entry path")

    if entry.isSymlink:
        reject("symlink not allowed")

    if totalSize + entry.size > MAX_UNPACKED_SIZE:
        reject("archive too large")

    extract(entry, target)

这里不要只做字符串替换,例如简单替换 ../ 并不可靠。应使用系统提供的路径规范化能力,再判断最终路径是否仍位于预期目录。

导入类文件的后处理风险

很多系统允许上传 Excel、CSV、XML、JSON 做数据导入。相比普通附件,这类文件会进入解析器和业务逻辑,风险面更大。

常见注意点:

  • CSV/Excel:导出给管理员或财务人员打开时,要注意公式注入。对以 =、+、-、@ 开头的单元格内容进行转义或前置单引号。

  • XML:关闭外部实体解析和 DTD,避免解析器访问本地文件或内网资源。

  • JSON:限制最大深度和字段数量,避免解析资源耗尽。

  • Office/PDF:不要在主业务进程里做复杂解析,建议进入隔离服务处理。

以 XML 解析为例,安全配置应明确关闭外部实体。不同语言写法不同,思路是一致的:

// Java 示例思路:关闭外部实体和 DTD
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(" true);
dbf.setFeature(" false);
dbf.setFeature(" false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);

这类配置建议写成统一工具类,并加单元测试覆盖,避免后续新功能又引入默认不安全的解析器。

权限校验不能只做上传,不做下载

有些系统上传接口权限做得不错,但下载接口直接通过文件 ID 返回内容,缺少资源归属校验。这样会导致越权访问附件。

下载接口至少要校验:

  • 当前用户是否登录。

  • 文件是否属于当前用户或当前业务对象。

  • 业务对象当前状态是否允许访问附件。

  • 管理员、审计员、外部协作方是否有不同权限边界。

不要把文件存储路径直接暴露给前端。前端拿到的最好是文件 ID,后端根据 ID 查元数据,再做权限判断后输出文件流。

日志与审计信息

文件上传相关日志很有价值,但也容易记录过多敏感信息。建议记录以下字段:

  • 上传用户、租户、业务对象 ID。

  • 文件大小、类型、检测结果。

  • 存储 ID,不记录服务器绝对路径给普通日志系统。

  • 失败原因分类,如类型不允许、大小超限、解析失败。

  • 后处理任务 ID 和处理结果。

同时要注意原始文件名可能包含控制字符或 HTML 片段,写日志和页面展示前都要做安全处理,避免污染日志或引入展示层问题。

一份实用的审计清单

检查项关注点建议
接口权限是否任意用户可上传按业务对象做鉴权,不只校验登录态
类型校验是否只看后缀或 Content-Type白名单 + Magic Number + 必要时深度解析
文件命名是否使用原始文件名保存随机文件名,原始名仅作为元数据
存储目录是否位于 Web 可执行目录放到 Web 根目录外或对象存储私有桶
大小限制是否只限制上传前大小同时限制上传大小和解压后大小
压缩包是否校验 entry 路径规范化路径并确认在目标目录内
后处理是否在主进程解析复杂文件隔离服务、资源限制、超时控制
下载权限是否凭 ID 直接下载基于业务对象和用户关系校验
清理机制失败任务是否残留文件定期清理临时目录和孤儿文件

测试与验证建议

在授权测试或内部验证时,不需要使用危险样本,也能覆盖大部分逻辑缺陷。可以准备一组无害测试文件:

  • 合法图片、合法 PDF,用于验证正常流程。

  • 错误后缀但内容合法的文件,用于检查后缀校验。

  • 合法后缀但 Magic Number 不匹配的文件,用于检查内容校验。

  • 超大文件和空文件,用于检查边界处理。

  • 包含多层目录的压缩包,用于检查解压路径控制。

  • 包含大量小文件的压缩包,用于检查数量限制。

  • 包含特殊字符文件名的样本,用于检查展示、日志和路径处理。

测试环境建议开启详细日志,但不要把生产真实文件拿来做解析实验。涉及第三方解析库时,也要关注版本更新和已知漏洞公告。

注意点

  • 不要相信前端限制,前端校验只用于用户体验。

  • 不要使用黑名单判断危险后缀,黑名单很容易遗漏。

  • 不要让上传目录具备脚本执行能力。

  • 不要把原始文件名直接拼接到路径里。

  • 不要在主业务进程里解析不可信复杂文件。

  • 不要忽略下载接口和文件预览接口的权限校验。

上传安全的核心不是“拦住某个危险后缀”,而是让不可信文件在整个生命周期里都处于受控状态:类型受控、路径受控、权限受控、解析受控、访问受控。

总结

文件上传风险看起来基础,但在真实系统里经常因为链路长、业务多、组件杂而出问题。比较稳的做法是建立统一上传服务和统一策略:白名单校验、随机命名、非执行存储、鉴权下载、后处理隔离、审计日志和清理机制。

做代码审计时,可以先画出文件流转路径,再逐个检查入口、校验、存储、访问和后处理。只要把这条链路梳理清楚,很多隐藏问题都会浮出来,修复也会更有针对性。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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