ASP.NET Core 文件上传安全:Content-Type 为什么不能证明文件类型?

上传接口有三份"关于文件类型的声明",而它们没有一份是可信的:

声明 谁提供 可信吗
扩展名 .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 会被拦下------这正是不校验内容时会漏掉的情形。


六、这套校验的边界

要说清楚它防不住什么,避免过度自信:

  1. Magic Number 是"弱证据"。 攻击者可以构造"图片头 + 恶意载荷"的文件。所以框架又加了 ImageSharp 解码;但解码通过也不代表文件绝对安全(图片解码器本身也可能有漏洞)。
  2. 不做病毒扫描。 需要防恶意文档(宏病毒等)时,要接入杀毒/CDR(内容 disarm & reconstruct)服务。
  3. 不保证渲染安全。 允许 SVG、HTML 这类"能执行脚本"的格式时,必须配合独立域名 + Content-Disposition: attachment + X-Content-Type-Options: nosniff 等措施。
  4. 大小限制是另一道防线。 再严格的内容校验也挡不住"100GB 文件把磁盘写满",所以 MaxSize 必须同时生效。
  5. 存储位置要正确。 上传目录不应被当作可执行目录;框架把文件放在 wwwroot/uploads 下的按日期目录,并通过受控接口提供私有文件下载(第 10 篇)。

七、小结

判断依据 可信度 用途
文件名 / 扩展名 低 只用于选择校验规则,不能作为结论
Content-Type 低 仅作参考(非图片时可直接拒绝)
Magic Number 中 快速排除"改扩展名"这类简单绕过
ImageSharp 解码 较高 图类型的实际内容校验
业务白名单 高 只允许业务真正需要的类型

一句话:类型判断必须基于内容,而不是基于客户端发来的声明。 三份声明都只能当线索,不能当证据。


如果你正在用 .NET 10 + Blazor 做后台,文件上传是必做功能。EasyAdminBlazor 的校验器可以直接复用,也可以只借它的签名表与"fail-closed"原则。

相关推荐
gudufy9 小时前
Blazor 安全设计:一个 Blazor Admin 后台需要防哪些攻击?
csrf·ssrf·越权·easyadminblazor·blazor安全
gudufy3 天前
EasyAdminBlazor 审批模块实战:从提交、审批到最终完成
工作流·blazor审批·admin blazor·blazor admin·easyadminblazor
Crazy Struggle2 年前
.NET 8 高性能跨平台图像处理库 ImageSharp
.net·imagesharp·图像库·图像处理库
假装我不帅2 年前
ImageSharp报错
drawing·imagesharp·sixlabors