ASP.NET Core 远程图片下载为什么会有 SSRF 风险?EasyAdminBlazor 的完整防护实践

原创 2026-09-28 703 次阅读

后台里有一个很常见的需求:给一个图片地址,让服务器把它下载下来存到本地。采集文章配图、导入商品图、批量替换外链图片,都会用到。

这个功能看起来很无害,实际上它是 SSRF 的经典入口。这篇文章用 EasyAdminBlazor 2.3 的实现,完整讲一遍"一个安全的远程下载器应该长什么样"。


一、SSRF 到底能造成什么后果

SSRF(Server-Side Request Forgery,服务端请求伪造)的本质是:攻击者让服务器代替自己去访问他访问不到的资源。

后台的"远程图片下载"正好符合条件:

  • 输入是一个 URL,由用户(或采集任务)提供;
  • 执行者是服务器,通常在可信内网里;
  • 结果会落盘,甚至能被下载回来看。

几个真实能打的目标:

http://127.0.0.1:6379/          → 本机 Redis
http://127.0.0.1:9200/_cat/     → 本机 Elasticsearch
http://169.254.169.254/latest/meta-data/iam/security-credentials/
                                → 云厂商元数据服务(可能拿到临时凭证)
http://10.0.0.8:8080/admin      → 内网管理后台
http://192.168.1.1/             → 内网设备
file:///etc/passwd              → 本地文件(协议利用)
gopher://127.0.0.1:6379/_info   → 通过 gopher 直接构造 Redis 命令

注意最后两条:协议白名单没做的话,连 file:// 都能读文件。


二、只校验域名为什么不够

很多项目的第一版防护是"域名黑名单"或"域名后缀白名单",然后很快就发现防不住。

1. Content-Type 和扩展名都是声明,不是事实

远程服务返回的 Content-Type: image/png 完全由对方控制。攻击者可以让一个内网接口返回 image/png,或者干脆不带 Content-Type。

所以不能因为 Content-Type 是 image 就放行。这也是为什么源码里把 Content-Type 降级成"参考信息":

var fetch = await downloader.DownloadToTempFileAsync(
    remoteUrl,
    MaxSize,
    maxRedirects: SecurityOptions.Current.MaxRemoteRedirects,
    // 图片直链常返回 application/octet-stream 或不带 Content-Type,
    // 因此 Content-Type 仅作参考,真实性以 扩展名 + Magic Number + ImageSharp 解码 三重校验为准
    requireImageContentType: false);

2. DNS Rebinding:解析时是公网,连接时是内网

最经典的绕过方式:

第 1 次解析 evil.com → 8.8.8.8(公网,校验通过)
第 2 次解析 evil.com → 127.0.0.1(连接时命中内网)

如果你"先解析域名校验 IP,再用域名发起请求",HttpClient 内部会重新解析一次。这两次解析之间的窗口就是 TOCTOU(Time-of-Check to Time-of-Use)漏洞。

3. 重定向绕过

第 1 跳:https://evil.com/a.png      校验通过
第 2 跳:302 → http://169.254.169.254/latest/meta-data/

如果用了 HttpClient 的自动重定向(AllowAutoRedirect = true),第 2 跳根本不会再走你的校验逻辑。

4. 协议降级

https://evil.com/a.png 跳转到 http://...,明文传输,同时也可能借此绕过只针对 HTTPS 的规则。


三、完整防护:七层,一层都不能少

EasyAdminBlazor 的实现把防护拆成七层,分布在 FileSecurityValidator、RemoteContentDownloader 和 FileService 里。

1. 协议 + 主机名 + 字面 IP 校验      IsSafeRemoteUri
2. DNS 解析结果全部必须是公网地址     ResolvePublicAddressesOrNullAsync
3. 禁止自动重定向,逐跳重新校验       DownloadToTempFileAsync
4. 连接建立前再次校验真实 IP          CreatePinnedHandler / ConnectCallback
5. 大小限制 + 直接落盘                declaredLength + 累计计数 + FileStream
6. 内容真实性三重校验                 扩展名 + Magic Number + ImageSharp 解码
7. 临时文件 + 原子移动 + 权限        File.Move + SysFileController 授权

第 1 层:协议、主机名、字面 IP

public static bool IsSafeRemoteUri(Uri uri)
{
    if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
    {
        return false;
    }

    if (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
    {
        return false;
    }

    if (Uri.CheckHostName(uri.Host) != UriHostNameType.Dns && IPAddress.TryParse(uri.Host, out var literalIp))
    {
        return IsPublicIpAddress(literalIp);
    }

    return true;
}

三件事:

  • 协议白名单只允许 http / https。file://、gopher://、ftp:// 一律拒绝。
  • localhost 直接拒绝(注意 localhost 会被 Uri.CheckHostName 识别为 DNS,不走 IP 分支,所以必须单独判)。
  • 字面 IP 要判公网:http://127.0.0.1/、http://192.168.1.1/ 这类直接拒绝;域名留到第 2 层解析后再判。

IsPublicIpAddress 覆盖的网段相当完整:

类型 拒绝范围
回环 / 未指定 127.0.0.0/8、::1、0.0.0.0、::
私网 10/8、172.16/12、192.168/16、IPv6 ULA fc00::/7
链路本地 169.254/16(含云元数据 169.254.169.254)、fe80::/10
运营商级 NAT 100.64/10
组播 / 保留 224/4、240/4、ff00::/8
文档 / 测试网段 192.0.2/24、198.51.100/24、203.0.113/24、2001:db8::/32
隧道 / 转换 Teredo 2001::/32、6to4 2002::/16、NAT64 64:ff9b::/96
IPv4 映射的 IPv6 递归按 IPv4 规则判定
其他 IPv6 只放行 2000::/3 全球单播空间

最后一条特别值得注意:IPv6 不是"没写就默认放行",而是只在 2000::/3 内才认为是公网地址。

第 2 层:DNS 解析结果必须全是公网

/// <summary>
/// 解析主机名并返回全部已通过公网校验的地址;任一地址不是公网地址(含解析失败)时返回 null。
/// 该方法的结果可以安全地用于"锁定"后续的 TCP 连接目标,避免 DNS Rebinding。
/// </summary>
public static async Task<IPAddress[]?> ResolvePublicAddressesOrNullAsync(string host, CancellationToken cancellationToken = default)
{
    ...
    IPAddress[] addresses;
    try
    {
        addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
    }
    catch (SocketException) { return null; }
    catch (ArgumentException) { return null; }

    if (addresses.Length == 0) return null;

    // 只要存在任意一个非公网地址就整体拒绝(防止解析出内网地址后连接命中)
    foreach (var address in addresses)
    {
        if (!IsPublicIpAddress(address)) return null;
    }

    return addresses;
}

这是 fail-closed 策略:一个域名解析出多个 IP 时,只要有一个是非公网,整体拒绝。因为攻击者可以控制 DNS 返回 [公网IP, 127.0.0.1],让连接碰运气命中第二个。

第 3 层:禁止自动重定向,逐跳校验

public async Task<RemoteFetchResult> DownloadToTempFileAsync(
    string remoteUrl,
    long maxSize,
    int maxRedirects = DefaultMaxRedirects,
    bool requireImageContentType = true,
    CancellationToken cancellationToken = default)
{
    if (maxRedirects < 0)
    {
        maxRedirects = DefaultMaxRedirects;
    }

    if (!Uri.TryCreate(remoteUrl, UriKind.Absolute, out var currentUri) ||
        !FileSecurityValidator.IsSafeRemoteUri(currentUri))
    {
        return RemoteFetchResult.Fail(RemoteFetchStatus.BlockedAddress);
    }

    using var handler = FileSecurityValidator.CreatePinnedHandler();
    using var client = new HttpClient(handler, disposeHandler: false)
    {
        Timeout = _timeout
    };
    ...
}

重定向的处理是手工循环:

while (true)
{
    cancellationToken.ThrowIfCancellationRequested();

    // 每一跳都必须重新做主机/地址校验(重定向目标可能是内网地址)
    if (!await FileSecurityValidator.IsSafeRemoteHostAsync(currentUri, cancellationToken))
    {
        return RemoteFetchResult.Fail(RemoteFetchStatus.BlockedAddress, currentUri);
    }

    using var request = new HttpRequestMessage(HttpMethod.Get, currentUri);
    ...
    if (IsRedirect(statusCode))
    {
        var location = response.Headers.Location;
        if (location == null)
            return RemoteFetchResult.Fail(RemoteFetchStatus.BadStatus, currentUri, statusCode);

        if (redirects >= maxRedirects)
            return RemoteFetchResult.Fail(RemoteFetchStatus.TooManyRedirects, currentUri, statusCode);

        var nextUri = location.IsAbsoluteUri ? location : new Uri(currentUri, location);

        // 禁止 HTTPS → HTTP 降级
        if (currentUri.Scheme == Uri.UriSchemeHttps && nextUri.Scheme != Uri.UriSchemeHttps)
        {
            return RemoteFetchResult.Fail(RemoteFetchStatus.InsecureRedirect, currentUri, statusCode);
        }

        if (!FileSecurityValidator.IsSafeRemoteUri(nextUri))
        {
            return RemoteFetchResult.Fail(RemoteFetchStatus.BlockedAddress, nextUri, statusCode);
        }

        redirects++;
        currentUri = nextUri;
        continue;
    }
    ...
}

要点:

  • 每一跳都重新校验域名、解析 IP、检查协议;
  • 重定向次数有上限(默认 3,可被 SecurityOptions.MaxRemoteRedirects 覆盖),负数会被规范化成默认值,不会变成无限重定向;
  • HTTPS → HTTP 降级被显式拒绝,返回 InsecureRedirect。

第 4 层:连接建立前锁定 IP(防 DNS Rebinding)

这是整个方案里最关键、也最少见的一层:

/// <summary>
/// 创建一个禁止自动重定向、且在建立 TCP 连接前再次校验真实目标地址的 HttpClientHandler。
/// </summary>
public static SocketsHttpHandler CreatePinnedHandler(TimeSpan? connectTimeout = null)
{
    return new SocketsHttpHandler
    {
        // 重定向由调用方手工处理,每一步都会重新做 SSRF 校验
        AllowAutoRedirect = false,
        ConnectTimeout = connectTimeout ?? TimeSpan.FromSeconds(10),
        // 连接建立前校验实际解析到的 IP,杜绝"校验公网 DNS 后连接内网 IP"
        ConnectCallback = async (context, cancellationToken) =>
        {
            var addresses = await ResolvePublicAddressesOrNullAsync(context.DnsEndPoint.Host, cancellationToken)
                ?? throw new HttpRequestException($"目标主机 {context.DnsEndPoint.Host} 未通过安全校验");

            Exception? lastError = null;
            foreach (var address in addresses)
            {
                var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
                {
                    NoDelay = true
                };

                try
                {
                    await socket.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), cancellationToken);
                    return new NetworkStream(socket, ownsSocket: true);
                }
                catch (OperationCanceledException) { socket.Dispose(); throw; }
                catch (Exception ex) { socket.Dispose(); lastError = ex; }
            }

            ExceptionDispatchInfo.Capture(lastError ?? new HttpRequestException("无法连接到目标主机")).Throw();
            throw lastError ?? new HttpRequestException("无法连接到目标主机");
        }
    };
}

ConnectCallback 是 SocketsHttpHandler 提供的扩展点:它替换了默认的"解析域名 → 建连接"过程。在这里:

  1. 重新解析域名,并要求所有地址都是公网地址;
  2. 直接用 Socket.ConnectAsync 连接这个已经校验过的 IP,不再让框架自己解析一次。

这就把 TOCTOU 窗口关掉了:校验的 IP 和实际连接的 IP 是同一批。

HttpClient 的注册也直接用了这个 handler:

builder.Services.AddHttpClient("EasyAdminBlazor.RemoteImage", client =>
{
    client.Timeout = TimeSpan.FromSeconds(30);
    ...
}).ConfigurePrimaryHttpMessageHandler(() =>
    // 禁止自动重定向:重定向由 RemoteContentDownloader 逐跳校验后再发起(SSRF 防护)
    // 同时 ConnectCallback 会在建立连接前再次校验真实 IP,杜绝 DNS Rebinding
    FileSecurityValidator.CreatePinnedHandler(TimeSpan.FromSeconds(10)));

第 5 层:大小限制 + 直接落盘

var declaredLength = response.Content.Headers.ContentLength;
if (declaredLength.HasValue && declaredLength.Value > maxSize)
{
    return RemoteFetchResult.Fail(RemoteFetchStatus.TooLarge, currentUri, statusCode);
}

var tempPath = Path.Combine(Path.GetTempPath(), $"eab-remote-{Guid.NewGuid():N}.tmp");
try
{
    await using var remoteStream = await response.Content.ReadAsStreamAsync(cancellationToken);
    await using var fileStream = new FileStream(
        tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
        CopyBufferSize, useAsync: true);

    var buffer = new byte[CopyBufferSize];
    long total = 0;
    int read;
    while ((read = await remoteStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
    {
        total += read;
        if (total > maxSize)
        {
            fileStream.Close();
            TryDelete(tempPath);
            return RemoteFetchResult.Fail(RemoteFetchStatus.TooLarge, currentUri, statusCode);
        }

        await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
    }
    ...
}

两个限制同时存在:

  • 声明长度(Content-Length)超限直接拒绝;
  • 实际累计长度超限立即中断并删除临时文件——因为 Content-Length 可以是假的,也可能是分块传输。

而且响应流是边读边写磁盘,不会把整个远程响应读进内存。这样即使对方返回一个 10GB 的流,也不会把服务器内存打爆。

第 6 层:内容真实性三重校验

下载完只是"拿到了字节",还要证明它确实是图片。FileService.SaveRemoteImageAsync 做了三重校验:

// 内容真实性校验之一: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["远程文件内容与扩展名不匹配"]);
}

Magic Number 对照表在 FileSecurityValidator 里(节选):

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()],
    ...
};

三层的关系是互补的:

校验 防什么
扩展名 只允许白名单内的类型
Magic Number 防"改扩展名"的伪装
ImageSharp 解码 防"头部合法但内容不是图片"的构造文件

第 7 层:临时文件 + 原子移动 + 访问权限

下载全程在临时文件里完成,只有全部校验通过才移动到正式目录:

// 从临时文件移动到正式位置(全程不把整个文件读进内存)
System.IO.File.Move(tempFile!, filePath, overwrite: true);
tempFile = null;

任何一步失败都会清理临时文件:

private static void TryDelete(string path)
{
    try
    {
        if (File.Exists(path)) File.Delete(path);
    }
    catch
    {
        // 临时文件清理失败不影响主流程
    }
}

"先写临时文件再移动"的意义:

  • 校验期间不会在正式目录留下半成品;
  • 多个并发下载不会互相覆盖;
  • 删除/回滚简单,只删临时文件即可。

最后,文件下载回来还要过访问授权。SysFileController 的私有文件判定采用与数据权限一致的规则:

private async Task<bool> IsAuthorizedAsync(SysFile file)
{
    await _adminContext.InitRoles();

    if (_adminContext.Roles.Any(r => r.IsAdministrator)) return true;
    if (_adminContext.Roles.Any(r => r.DataPermission == DataPermissionType.AllData)) return true;

    return file.CreatedUserId == _adminContext.User?.Id;
}

上传人信息缺失时按拒绝处理(fail-closed)。下载下来不等于谁都能看——这是 SSRF 防护之外的另一半。


四、测试锁定了哪些攻击面

SsrfTests.cs 用一组真实调用覆盖了主要攻击路径:

测试 输入 期望
Download_Localhost_IsBlocked http://localhost/secret.png BlockedAddress
Download_LoopbackIp_IsBlocked http://127.0.0.1/secret.png BlockedAddress
Download_NonPublicTargets_AreBlocked 10.0.0.5、192.168.1.10、172.20.0.1、169.254.169.254、[::1]、[fc00::1]、[fe80::1]、file://、gopher://、not-a-url 全部 BlockedAddress
Download_NullOrEmpty_IsBlocked 空字符串 BlockedAddress
Download_NegativeMaxRedirects_FallsBackToDefault maxRedirects = -5 转为默认值,内网地址仍被拒绝
PinnedHandler_MustNotAutoRedirect —— handler.AllowAutoRedirect == false
IsSafeRemoteUri_SchemeAndHostRules example.com / localhost / 127.0.0.1 / 192.168.0.1 / ftp:// 与规则一致

注意 PinnedHandler_MustNotAutoRedirect 这条断言的措辞:

handler.AllowAutoRedirect.Should().BeFalse("自动重定向会绕过逐跳 SSRF 校验");

它锁定的是设计决策,而不是某个实现细节。以后有人为了"支持跳转"把自动重定向打开,测试会立刻失败。


五、这套方案不覆盖的部分

安全方案要讲清楚边界,否则会给人虚假的安全感。

  1. 它保护的是"框架提供的远程下载路径"。 如果你在业务代码里直接 new HttpClient().GetStringAsync(userUrl),这套防护不会生效。团队里应该约定:所有由外部输入触发的出站请求都必须走同一个安全下载器。
  2. HTTP 代理场景要注意。 如果部署环境强制走正向代理(HTTP_PROXY / HTTPS_PROXY),ConnectCallback 连接的会是代理地址而不是目标地址,SSRF 校验语义就会变化。这种环境下需要额外校验代理配置并确认代理自身的访问控制。
  3. 不要记录完整 URL。 攻击者可能把敏感信息(token)放在 URL 里;日志里建议只记录 host 和路径,或者做脱敏。
  4. 重定向的目标域名变化要考虑业务影响。 有些图床会跳到 CDN 域名,这些域名的 IP 也必须是公网地址;如果对方用了内网 CDN 节点,请求会被拒绝,这是安全换来的代价。
  5. localhost 在容器里的语义。 容器内 127.0.0.1 指向容器自身,不等于宿主机;但即使如此也必须拒绝,因为容器内可能运行着数据库、缓存、指标服务。
  6. 文件权限是另一条战线。 SSRF 防的是"下载什么",访问授权防的是"谁能读",两者都要做。

六、小结

一个安全的远程下载器,至少要回答七个问题:

问题 回答
只允许哪些协议? 只允许 http / https
目标地址是不是内网? 字面 IP 直接判;域名解析后要求全部是公网地址
重定向怎么办? 禁止自动重定向,逐跳重新校验,限制次数,禁止 HTTPS 降级
校验完的 IP 和连接的 IP 是同一个吗? 用 ConnectCallback 锁定实际连接地址,关闭 TOCTOU 窗口
对方返回超大响应怎么办? 声明长度 + 实际累计双重限制,边读边落盘
内容真的是图片吗? 扩展名 + Magic Number + ImageSharp 解码三重校验
文件是谁的、谁能下载? 临时文件 + 原子移动 + 基于角色/数据权限的下载授权

SSRF 的防护不是"加一个黑名单",而是在整条链路的每个可被绕过的地方都加上校验。EasyAdminBlazor 的实现把这个原则落到了代码里,也用测试锁定了每一条攻击路径。


如果你的 .NET 10 + Blazor 后台有远程图片/文件下载需求,可以直接参考 EasyAdminBlazor 的实现:协议白名单、逐跳校验、连接前锁定 IP、大小限制、内容真实性校验、临时文件与权限,一整套都开放源码。