上传接口有三份"关于文件类型的声明",而它们没有一份是可信的:
| 声明 | 谁提供 | 可信吗 |
|---|---|---|
扩展名 .jpg |
客户端(文件名) | ❌ 随手就能改 |
Content-Type: image/jpeg |
客户端(请求头) | ❌ 完全由调用方控制 |
文件名 avatar.jpg |
客户端 | ❌ 同上 |
这篇文章讲清"为什么不可信"以及"用什么替代"。
一、攻击者能怎么绕过
场景 1:改扩展名上传脚本
text
原始文件:shell.php
重命名为:shell.jpg
请求头: Content-Type: image/jpeg
如果服务端只校验扩展名和 Content-Type,文件就落到了磁盘上。接下来能不能执行,取决于部署环境------但只要上传目录被当成可访问目录、或者存在解析漏洞,就有机会。
场景 2:伪造 Content-Type
http
POST /api/FileUpload
Content-Type: multipart/form-data; boundary=----x
------x
Content-Disposition: form-data; name="file"; filename="a.jpg"
Content-Type: image/jpeg ← 这行完全由攻击者写
<真正的字节内容是任意东西>
------x
Content-Type 是多部分表单里的一段文本,服务端读到的是"攻击者说他上传的是图片",而不是"这确实是图片"。
场景 3:polyglot 文件
构造一个前几个字节是合法图片头、后面追加其他内容的文件。这类文件在有些解析器眼里是图片,换个解析器就是别的格式。只比对文件头不足以排除这种可能。
场景 4:.txt 里塞二进制
.txt 没有固定的 Magic Number,很容易被当成"无需校验"的类型。但它同样可以把二进制、可执行片段当成文本塞进去。
二、可靠依据:文件内容本身
框架的做法是"读文件头 + 比对签名",签名表就在 FileSecurityValidator 里:
csharp
private static readonly Dictionary<string, byte[][]> FileSignatures = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = [new byte[] { 0xFF, 0xD8, 0xFF }],
[".jpeg"] = [new byte[] { 0xFF, 0xD8, 0xFF }],
[".png"] = [new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }],
[".gif"] = ["GIF87a"u8.ToArray(), "GIF89a"u8.ToArray()],
[".webp"] = ["RIFF"u8.ToArray()],
[".pdf"] = ["%PDF"u8.ToArray()],
[".zip"] = [new byte[] { 0x50, 0x4B, 0x03, 0x04 }, new byte[] { 0x50, 0x4B, 0x05, 0x06 }, new byte[] { 0x50, 0x4B, 0x07, 0x08 }],
[".docx"] = [new byte[] { 0x50, 0x4B, 0x03, 0x04 }],
[".xlsx"] = [new byte[] { 0x50, 0x4B, 0x03, 0x04 }],
[".pptx"] = [new byte[] { 0x50, 0x4B, 0x03, 0x04 }],
};
校验逻辑:
csharp
public static async Task<bool> HasAllowedContentAsync(Stream input, string? extension, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(extension)) return false;
extension = extension.ToLowerInvariant();
if (extension == ".txt")
{
return !await ContainsBinaryBytesAsync(input, cancellationToken);
}
if (LegacyOfficeExtensions.Contains(extension))
{
return await HasAnyPrefixAsync(input, [new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }], cancellationToken);
}
if (FileSignatures.TryGetValue(extension, out var signatures))
{
return await HasAnyPrefixAsync(input, signatures, cancellationToken);
}
return false;
}
四个设计决定值得单独说:
(1).txt 用"有没有二进制字节"判断
csharp
private static async Task<bool> ContainsBinaryBytesAsync(Stream input, CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
var read = await input.ReadAsync(buffer.AsMemory(0, 1024), cancellationToken);
return buffer.AsSpan(0, read).Contains((byte)0);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
读前 1KB,只要出现 0x00 就判定为二进制。简单但有效------纯文本文件里不该有 NUL 字节。
(2)老版 Office 共用复合文档签名
.doc / .xls / .ppt 不是 ZIP 结构(那是 .docx / .xlsx / .pptx),它们共用 D0 CF 11 E0 A1 B1 1A E1。分不清具体是哪个,但至少能确认"是 OLE 复合文档"。
(3)ZIP 系签名有三个
.zip / .docx / .xlsx / .pptx 都是 ZIP 容器,签名有三种:普通文件头 PK\x03\x04、空归档 PK\x05\x06、分卷 PK\x07\x08。只写第一种会误拒部分正常 ZIP。
(4)未知扩展名直接拒绝(fail-closed)
csharp
return false;
这是最重要的一行。不在签名表里的类型一律不通过,而不是"没有签名信息就放行"。
一个实际的副作用
框架的 SysFileController.ResolveContentType 里列了 .svg(image/svg+xml),但签名表里没有 .svg。所以默认配置下 SVG 上传会被拒绝------这是有意的 fail-closed:SVG 是 XML,可以内嵌脚本,是 XSS 的常见载体。
如果你确实需要 SVG,正确做法是"允许上传但强制以附件下载 + 独立域名提供 + 内容清洗",而不是把它加进签名表了事。
三、更强的依据:真的把它当图片解析一次
Magic Number 只看了文件头几个字节,polyglot 依然可能通过。所以图片类型还要再走一道:用 ImageSharp 实际解码。
csharp
public bool IsImageByImageSharp(string filePath)
{
try
{
// 使用 Image.Load 检测文件是否为有效图片
using (var image = Image.Load(filePath))
{
return true;
}
}
catch (SixLabors.ImageSharp.UnknownImageFormatException)
{
// 格式不支持或不是图片
return false;
}
catch (Exception)
{
// 其他异常,如文件不存在等
return false;
}
}
Image.Load 会完整解析图片结构(尺寸、色彩通道、编码数据)。能通过解析的,至少是一个结构完整的图片文件。 上传路径和图片处理路径都会用它:
csharp
// UploadFileAsync 之后
sysFile = await ProcessImageAsync(sysFile, filePath, file.GetExtension());
// ProcessImageAsync 内部
if (!IsImageByImageSharp(filePath))
{
File.Delete(filePath);
throw new InvalidOperationException(_commonLocalizer["图片文件格式无效或已损坏"]);
}
注意 File.Delete(filePath):校验失败时把刚写下的文件删掉,不留残骸。
四、远程下载场景:Content-Type 只当参考
远程图片下载最容易犯的错是"看服务端返回的 Content-Type"。框架的处理是把三件事分开:
csharp
var fetch = await downloader.DownloadToTempFileAsync(
remoteUrl,
MaxSize,
maxRedirects: SecurityOptions.Current.MaxRemoteRedirects,
// 图片直链常返回 application/octet-stream 或不带 Content-Type,
// 因此 Content-Type 仅作参考,真实性以 扩展名 + Magic Number + ImageSharp 解码 三重校验为准
requireImageContentType: false);
// 内容真实性校验之一:Magic Number 与扩展名匹配
await using (var validationStream = new FileStream(tempFile!, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true))
{
if (!await FileSecurityValidator.HasAllowedContentAsync(validationStream, extention))
{
return (null, _commonLocalizer["远程文件内容与扩展名不匹配"]);
}
}
// 内容真实性校验之二:Content-Type 如果存在且不是图片,直接拒绝
if (!string.IsNullOrEmpty(fetch.ContentType) &&
!fetch.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
return (null, _commonLocalizer["远程文件不是图片"]);
}
// 内容真实性校验之三:ImageSharp 实际解码
if (!IsImageByImageSharp(tempFile!))
{
return (null, _commonLocalizer["远程文件内容与扩展名不匹配"]);
}
三层的逻辑是:
text
Content-Type 是 image/* → 只是"不反对",不代表通过
Content-Type 不是 image/* → 直接拒绝(宁严勿宽)
真正的通过条件 → Magic Number + ImageSharp 解码都通过
五、测试怎么锁定这些规则
FileSecurityValidatorTests.cs 用构造出来的字节流逐条验证:
| 测试 | 输入 | 期望 |
|---|---|---|
HasAllowedContentAsync_JpegContent_ReturnsTrue |
FF D8 FF E0 00 10 + .jpg |
true |
HasAllowedContentAsync_PngContent_ReturnsTrue |
PNG 签名 + .png |
true |
HasAllowedContentAsync_WrongExtension_ReturnsFalse |
JPEG 字节 + .png |
false |
HasAllowedContentAsync_UnknownExtension_ReturnsFalse |
任意字节 + .unknown |
false |
HasAllowedContentAsync_NullExtension_ReturnsFalse |
--- | false |
HasAllowedContentAsync_EmptyExtension_ReturnsFalse |
--- | false |
HasAllowedContentAsync_TxtWithBinary_ReturnsFalse |
48 00 65 6C + .txt |
false |
HasAllowedContentAsync_TxtPlainText_ReturnsTrue |
"Hello World" + .txt |
true |
其中 WrongExtension 那条最直观:把 JPEG 改名成 .png 会被拦下------这正是不校验内容时会漏掉的情形。
六、这套校验的边界
要说清楚它防不住什么,避免过度自信:
- Magic Number 是"弱证据"。 攻击者可以构造"图片头 + 恶意载荷"的文件。所以框架又加了 ImageSharp 解码;但解码通过也不代表文件绝对安全(图片解码器本身也可能有漏洞)。
- 不做病毒扫描。 需要防恶意文档(宏病毒等)时,要接入杀毒/CDR(内容 disarm & reconstruct)服务。
- 不保证渲染安全。 允许 SVG、HTML 这类"能执行脚本"的格式时,必须配合独立域名 +
Content-Disposition: attachment+X-Content-Type-Options: nosniff等措施。 - 大小限制是另一道防线。 再严格的内容校验也挡不住"100GB 文件把磁盘写满",所以
MaxSize必须同时生效。 - 存储位置要正确。 上传目录不应被当作可执行目录;框架把文件放在
wwwroot/uploads下的按日期目录,并通过受控接口提供私有文件下载(第 10 篇)。
七、小结
| 判断依据 | 可信度 | 用途 |
|---|---|---|
| 文件名 / 扩展名 | 低 | 只用于选择校验规则,不能作为结论 |
Content-Type |
低 | 仅作参考(非图片时可直接拒绝) |
| Magic Number | 中 | 快速排除"改扩展名"这类简单绕过 |
| ImageSharp 解码 | 较高 | 图类型的实际内容校验 |
| 业务白名单 | 高 | 只允许业务真正需要的类型 |
一句话:类型判断必须基于内容,而不是基于客户端发来的声明。 三份声明都只能当线索,不能当证据。
如果你正在用 .NET 10 + Blazor 做后台,文件上传是必做功能。EasyAdminBlazor 的校验器可以直接复用,也可以只借它的签名表与"fail-closed"原则。