ASP.NET Web Forms 中的安全图片上传

Web Forms 示例中的上传示意图:浏览器、web.config 处理程序映射、RteFiles 的检查、磁盘上的 uploads 文件夹,以及 IIS 只将其作为静态文件提供

ASP.NET 站点内的上传文件夹,IIS 会毫无顾忌地运行其中的代码。如果某天有 .aspx 文件因为程序缺陷、FTP 或后来的改动落入其中,IIS 就会编译并运行它。本页介绍 Web Forms 示例在写入任何字节之前所做的检查,以及让 uploads/ 始终只提供静态文件的 web.config 配置块——即使这些检查将来被绕过也同样有效。每一层我们都在 IIS Express 上测试过。

写入之前

C# · App_Code/RteFiles.cs
StorageRoot root = GetRoot(context);   // 401 here first in per-user mode
if (file == null || file.ContentLength == 0)
    throw new RteException(400, "No file was uploaded.");

// FileName is whatever the browser sent (old IE sends a full path). Don't feed it
// to Path.GetFileName: on .NET Framework that throws on characters like " < > |.
string clientName = file.FileName ?? "";
clientName = clientName.Substring(Math.Max(clientName.LastIndexOf('/'), clientName.LastIndexOf('\\')) + 1);
int dot = clientName.LastIndexOf('.');
string ext = dot >= 0 ? clientName.Substring(dot).ToLowerInvariant() : "";
if (Array.IndexOf(ImageExtensions, ext) < 0)
    throw new RteException(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
if (file.ContentLength > MaxUploadBytes)
    throw new RteException(413, "The image is larger than the 4 MB (4,000,000 bytes) limit.");

string dir = ResolveFolder(root, folder ?? "");

byte[] data = new byte[file.ContentLength];
Stream input = file.InputStream;
int read = 0;
while (read < data.Length)
{
    int n = input.Read(data, read, data.Length - read);
    if (n <= 0) break;
    read += n;
}
// file.ContentType is chosen by the browser (or the attacker), so look at the bytes.
if (read != data.Length || !SignatureMatches(ext, data))
    throw new RteException(400, "The file is not a valid " + ext.Substring(1).ToUpperInvariant() + " image.");
  • 扩展名允许列表:.jpg、.jpeg、.png、.gif、.webp。.svg 会被拒绝,因为它可以携带脚本。.php 或 .aspx 无论字节内容是什么都会被拒绝。
  • 大小:4,000,000 字节,超出时返回 JSON 格式的 413。
  • 检查文件签名,而不是 ContentType。HttpPostedFile.ContentType 只是浏览器发送的值。我们提交了标记为 text/html 的 PNG 字节,它显示的就是这个值。示例忽略它,而是把文件开头的字节与扩展名对应的签名进行比较:
C# · App_Code/RteFiles.cs
static bool SignatureMatches(string ext, byte[] d)
{
    switch (ext)
    {
        case ".jpg":
        case ".jpeg":
            return d.Length >= 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF;
        case ".png":
            return d.Length >= 8 && d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47
                && d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A;
        case ".gif":
            return d.Length >= 6 && d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38;
        case ".webp":
            return d.Length >= 12 && Encoding.ASCII.GetString(d, 0, 4) == "RIFF"
                && Encoding.ASCII.GetString(d, 8, 4) == "WEBP";
    }
    return false;
}

名为 holiday.jpg、内容是 PHP 源代码的文件无法通过这项检查。但末尾追加了其他内容的真实 JPEG 仍然能通过。签名只能证明类型,不能证明文件无害,所以下文还要锁定文件夹本身。

同一段代码中还有两个 .NET Framework 细节。客户端文件名从不传给 Path.GetFileName,因为在 .NET Framework 4.8 上,它遇到 < 或 | 会抛出 ArgumentException。上传处理程序还读取 Request.Unvalidated.Files,因为请求验证也覆盖文件名:否则名为 <script>x.png 的文件会导致 HTML 500。文件名只保留 a-z0-9-,并加上 8 个随机十六进制字符,因此客户端选择的任何内容都不会写到磁盘上。

可以写到哪里

folder 字段与图片库使用同一个 ResolveFolder。每一段都必须是有效的文件夹名,Path.GetFullPath 的结果必须位于根目录之内。代码见 Web Forms 中的图片库文件夹。在契约测试中,带 folder=../ 的上传得到 400,不存在的文件夹得到 404。

uploads/ 只提供文件,不运行任何内容

IIS 根据处理程序映射而不是文件夹来决定运行什么。为了验证,我们在站点根目录下放了一个只有一行 <%= 6*7 %> 的页面 probe-root.aspx。请求它返回了 42:它被执行了。示例的 web.config 阻止了 uploads/ 中发生这种情况:

XML · web.config
<!-- Uploaded files are served as static files and nothing else. Clearing the handler list
     means that even if a .aspx, .ashx or .config ever lands in uploads/, IIS will not run
     it - it gets 404, not executed. The extension allow-list also refuses to serve
     anything that is not an image (.html, .svg, .txt ...), wherever it came from. -->
<location path="uploads">
  <system.webServer>
    <handlers>
      <clear />
      <add name="StaticFile" path="*" verb="GET,HEAD" modules="StaticFileModule" resourceType="File" requireAccess="Read" />
    </handlers>
    <security>
      <requestFiltering>
        <fileExtensions allowUnlisted="false">
          <add fileExtension=".jpg" allowed="true" />
          <add fileExtension=".jpeg" allowed="true" />
          <add fileExtension=".png" allowed="true" />
          <add fileExtension=".gif" allowed="true" />
          <add fileExtension=".webp" allowed="true" />
        </fileExtensions>
      </requestFiltering>
    </security>
    <httpProtocol>
      <customHeaders>
        <add name="X-Content-Type-Options" value="nosniff" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</location>
  • <clear /> 加上仅保留 StaticFile。在加入扩展名列表之前,uploads/ 中同样的 .aspx 和一个 .ashx 返回 404.3。既没有运行它们的处理程序,也没有把它们作为文本发送的 MIME 类型。
  • fileExtensions allowUnlisted="false"。有了允许列表后,uploads/probe.aspx 和保存为 uploads/probe.txt 的 HTML 载荷都返回 404.7,而 .png 和 .webp 能正常提供。
  • 提供的图片带有 X-Content-Type-Options: nosniff,因此浏览器不会根据内容猜测成其他类型。

在 IIS 的 applicationhost.config 中,handlers 节默认是锁定的,但其中的 <location path="" overrideMode="Allow"> 块为站点解除了锁定,因此 web.config 可以修改它。我们在 IIS Express 的模板中确认了这一点,没有在完整的 IIS 服务器上测试。在锁定了该节的主机上,站点会以 500.19 配置错误失败,而不会悄悄地运行脚本。

测试

在端口 5401 上运行本指南的示例项目,然后执行:

Shell
echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5401/rte/upload
{"error":"The file is not a valid PNG image."}

curl -F "file=@fake.png;filename=x.svg" http://localhost:5401/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

两个响应都是 HTTP 400。要查看文件夹锁定的效果,可以把任意 .aspx 复制到 uploads\ 并请求它。您会看到 IIS 的 404.7 页面,而不是该页面的输出。在图片库对话框中,被拒绝的文件会显示 "Upload failed for <name>."。对话框使用的是自己的消息,而不是服务器返回的文本。

其他平台的同一指南

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5

延伸阅读