一个文件上传接口在代码评审时常常只被问一句"校验了文件类型吗",然后就被放行。真正的问题在于,"校验"这个动作被放在了哪条信任边界上、校验的是哪一个变量、以及这个变量是不是攻击者可以单独控制的。对负责这类功能的开发者、架构师和测试人员来说,与其逐条背诵绕过手法,不如把一次上传完整地拆成数据流,在每一段标出不可信输入和对应的安全控制,再把这些假设固化成可以反复执行的检查项和回归测试。下面就以这个思路展开威胁建模,并尽量让产物能直接落进开发与测试流程。

先把一次上传拆成数据流
威胁建模的起点不是漏洞清单,而是数据怎么流动。一个典型的上传功能至少包含这几段:浏览器端选择文件并提交表单、HTTP 请求携带文件内容和元数据到达服务端、服务端解析请求并执行校验、文件被写入存储(本地磁盘、对象存储或数据库)、以及后续被某个下载或预览接口读取回来。
把这五段画成一条线之后,关键动作是找出每两段之间的信任边界——数据从一个控制域跨入另一个控制域的位置。浏览器到服务端之间是最明显的一条:这里之后的所有输入都来自客户端,都不可信。但容易被忽略的是服务端内部也有边界,比如"校验逻辑"与"写盘逻辑"之间:如果校验用的是变量 A,而写盘用的是变量 B,那么 A 的检查结果并不能代表 B 的安全性。
逐段标出不可信输入与安全控制
浏览器与传输段:前端校验只是体验,不是边界
前端 JavaScript 对扩展名或大小的判断,只能改善用户体验,不构成任何安全控制。攻击者可以关掉脚本、直接构造 HTTP 请求,或用代理篡改报文。下面这段常见的前端校验,在威胁建模里应当被明确标注为"信任边界之外、不计入防护":
// 仅前端校验,可被绕过,不作为安全控制
function validateFile(file) {
var ext = file.name.split('.').pop().toLowerCase();
return ext === 'jpg' || ext === 'png';
}
同样不可信的还有请求里的 Content-Type 头、filename 字段以及任何由前端填入的隐藏参数。把它们逐一列为"攻击者可控输入",是后面设计测试用例的素材来源。
服务端校验段:注意"校验了哪个变量"
这是威胁建模最该较真的一段。CN-SEC 披露的 Ninja Forms File Uploads 插件漏洞(CVE-2026-0740,CVSS 9.8,CWE-434,影响版本 ≤ 3.3.26)是一个很说明问题的例子:它的 AJAX 上传处理器只校验了源文件名的后缀,却没有校验攻击者可控的目标文件名参数。按摘录中的成因代码,大致是这样的结构:
public function handle_upload() {
$source = $_FILES['file'];
$destination = $_POST['destination_filename']; // 攻击者可控,未校验
if (!$this->is_allowed_type($source['name'])) { // 只校验源文件名
return $this->error('File type not allowed');
}
move_uploaded_file($source['tmp_name'], $this->upload_dir . $destination);
}
攻击者上传一个能通过类型检查的 image.jpg,却把 destination_filename 指向 shell.php 甚至带路径穿越,最终以可执行脚本写入 Web 根目录,实现未认证远程代码执行。据公开信息,Wordfence 曾在 24 小时内拦截超过 3,600 次相关攻击。这个案例的威胁建模教训很直接:校验的变量和真正决定落盘结果的变量必须是同一个。在数据流图上,is_allowed_type 作用在 source['name'] 上,而写盘动作依赖 destination,两者之间就是一条没有被守住的信任边界。
因此,这一段需要列出的处理假设包括:扩展名是否基于白名单而非黑名单、是否对大小写变形与多重扩展名(如 shell.php.jpg、shell.PhP)做了归一化、MIME 判断是否依赖可伪造的请求头、是否解析了真实文件内容,以及最终写入路径和文件名是由服务端生成还是沿用了客户端输入。

存储段:文件头伪造与可执行目录
即便类型校验收紧,攻击者仍可能在合法文件头后拼接恶意代码(例如在 PHP 代码前加 GIF 文件头)来骗过基于文件头的检测。所以存储段的安全假设不能只依赖"内容看起来像图片",还要看文件落在哪里:上传目录是否关闭了脚本解析执行权限、文件名是否被服务端重命名为随机值以消除路径穿越和覆盖风险、以及存储位置是否与 Web 可执行目录隔离。这里的信任边界是"被写入的数据"与"Web 服务器的解析行为"之间的交界。
下载段:越权访问常被漏掉
上传功能的威胁建模很容易止步于"传上去安不安全",却忽略了"取回来谁能取"。下载或预览接口如果仅凭文件名或自增 ID 定位资源,攻击者就可能通过遍历标识符访问他人文件,形成越权访问(IDOR)。这一段要标注的假设是:读取接口是否校验了请求者对该文件的所有权或授权、文件标识是否可预测、以及返回时的 Content-Disposition 和 Content-Type 是否会诱发浏览器直接执行或触发 SVG、PDF 等格式的被动攻击。
把威胁假设整理成检查项
数据流走完一遍,每一段的"我们假设……"都应翻译成一条可判断真假的检查项。下面这张表把前面标出的边界和假设归并,可直接作为代码评审清单的雏形:
| 信任边界 | 不可信输入 | 需要验证的假设 | 典型风险 |
|---|---|---|---|
| 浏览器→服务端 | 文件内容、filename、Content-Type | 服务端不信任任何前端校验结果 | 前端校验绕过 |
| 校验→落盘 | 目标文件名/路径参数 | 校验变量与写盘变量一致 | 校验绕过、路径穿越 |
| 落盘→解析 | 文件内容与扩展名 | 存储目录禁用脚本执行、服务端重命名 | WebShell、文件覆盖 |
| 上传→下载 | 文件标识符 | 读取时校验授权,标识不可预测 | 越权访问 |
检查项要尽量写成能回答"是/否"的句子,而不是"注意安全"这类无法验收的描述。这样它们既能进入评审,也能映射到自动化测试。
转化为可回归的安全测试
威胁建模的产物如果只停留在文档,很快就会和代码脱节。更有价值的做法,是把每条检查项改写成一个可重复执行的测试用例,纳入 CI 流水线,让它在每次改动后都重新验证一遍——这就是把威胁假设变成回归验证。
按攻击假设设计用例
针对上面每条边界,至少准备一组正向与负向用例:
- 前端校验绕过:直接用脚本或代理发送
filename=shell.php、Content-Type: image/jpeg的请求,断言服务端拒绝。 - 校验与落盘不一致:发送一个合法源文件,但把目标文件名参数设为
shell.php或../../shell.php,断言写入路径被服务端强制改写、请求被拒绝。 - 扩展名变形:批量提交
shell.PhP、shell.php.jpg、带空格或特殊字符的文件名,断言全部落在白名单之外。 - 文件头伪造:构造 GIF 头拼接脚本的样本,断言内容校验或存储目录禁用执行使其无法被解析。
- 越权下载:用账号 A 上传、用账号 B 的凭据遍历标识符请求该文件,断言返回 403 或 404。
这些用例可以用脚本或接口测试框架表达。下面是一个用 Python 伪代码表示的负向断言示意,适用语言为 Python,仅演示结构,实际需替换为真实接口与认证逻辑:
# 边界条件:仅验证服务端拒绝行为,不涉及真实 payload 执行
def test_target_filename_cannot_override_extension(client):
resp = client.post("/upload",
files={"file": ("ok.jpg", VALID_IMAGE, "image/jpeg")},
data={"destination_filename": "shell.php"})
assert resp.status_code in (400, 403)
assert not stored_file_exists("shell.php")
判断结果与修复回归
执行测试后,结果判断要落到可观测的事实上:是否返回了预期的拒绝状态码、磁盘上是否没有出现危险文件、下载接口是否对越权请求返回了拒绝。只有当负向用例全部被拦截、正向用例(合法图片)仍能正常上传时,才算通过。
修复一个具体漏洞后,务必把触发它的那条用例保留在回归集中,而不是修完就删。以 Ninja Forms 的问题为例,修复思路是让服务端用自己生成的安全文件名决定落盘,忽略客户端传入的目标文件名;对应的回归测试就应持续验证"客户端目标文件名参数永远无法改变最终扩展名与路径"。这样下一次重构或插件升级若意外引入回退,流水线会立即报警。
关于误报与阻断策略
把这类安全用例接入 CI/CD 时,建议区分阻断级别:路径穿越、校验绕过这类直接导致 RCE 的用例应设为硬失败、阻断合并;而对内容扫描、SVG/PDF 这类依赖具体解析环境、容易产生误报的检查,可先设为告警并人工复核,避免因环境差异频繁误阻断正常提交。调整判定阈值或样本集时,记录改动原因,以便必要时回退到更保守的规则。
需要说明的是,以上数据流分段、检查项和测试用例是一套通用方法,并不替代针对你所用框架、Web 服务器配置和部署环境的专门安全审查;也不是每条风险都适用于每个系统——没有下载功能的接口不必考虑越权取回,使用对象存储的系统则要重点关注存储桶权限而非本地执行目录。威胁建模的价值,恰恰在于让你基于自己系统的真实数据流,决定哪些边界必须守、哪些假设必须测,并把这些判断沉淀成能持续运行的回归验证。


广东省深圳市 1F
校验变量和写盘变量一致这条太关键了