FlawOpen

flawopen.com/ssrf/Csharp

● CWE-918 · High
FlawOpen Security Research

C# SSRF: secure every HttpClient connection

Unchecked URLs let HttpClient reach internal hosts or cloud metadata. Validate each resolved IP at connect time and disable redirects.

💡 Plain English Explainer (ELI5)

Imagine a hotel’s outbound parcel desk with a guarded loading gate. A guest may write any destination, but every courier must pass through the gate: the guard looks up the address, checks every resolved location, and opens the route only to a public one. In .NET, SocketsHttpHandler.ConnectCallback is that gate at the actual socket connection, so DNS rebinding cannot swap in a private address after a separate check. Keep AllowAutoRedirect = false so a public server cannot hand the courier a new internal destination, and UseProxy = false so the handler checks the address it really connects to. IsIPv4MappedToIPv6 also matters because an IPv4 destination can be written in IPv6 clothing.

Core Concepts & Subsystem Terms

HttpClient follows redirects by default
HttpClient with the default handler has AllowAutoRedirect = true and follows up to 50 redirects (it refuses only HTTPS-to-HTTP). A public URL answering Location: http://169.254.169.254/ reaches the metadata service after any check on the original URL.
Checking the first address with string prefixes
Dns.GetHostAddressesAsync(host)[0] looks at one address of possibly many, and StartsWith("10.") plus IsLoopback misses 172.16.0.0/12, 192.168.0.0/16, 169.254.169.254, 100.64.0.0/10 and every IPv6 private form.
Check-then-fetch and DNS rebinding
The check resolves the hostname once and the handler resolves it again when it connects. A DNS server with a short TTL can give the check a public address and the handler 127.0.0.1 (DNS rebinding).
SocketsHttpHandler.ConnectCallback
Available since .NET 5, ConnectCallback replaces the handler's own connect step: you receive the host and port, resolve them, reject any non-public address and open the socket to exactly those addresses. It runs for every connection, including redirects if you enable them, and IP-literal hosts are checked too because GetHostAddressesAsync returns them unchanged. Set UseProxy = false so a proxy is not what gets checked.
IsIPv4MappedToIPv6 and address classification
IsIPv4MappedToIPv6 and MapToIPv4() turn ::ffff:127.0.0.1 back into 127.0.0.1; IsIPv6LinkLocal, IsIPv6SiteLocal and IsIPv6UniqueLocal (.NET 6+) cover IPv6, and IPv4 private, link-local, CGNAT and multicast ranges need explicit byte checks.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

A minimal API endpoint binds FetchRequest(Url) and passes Url to GetStringAsync or PostAsJsonAsync.

Step 2

The service connects to an internal address

A DNS precheck does not pin the socket: the HTTP client resolves the host when it connects, so rebinding can change a public answer to a private destination. SocketsHttpHandler.ConnectCallback can validate and open the socket to the same resolved address.

Step 3

The internal service answers the trusted caller

HttpClient follows up to 50 redirects by default. A public preview or webhook URL can therefore send the backend to 169.254.169.254 after the original URL was checked.

Step 4

The attacker reads secrets or pivots

The response path creates the disclosure: an ASP.NET endpoint that returns GetStringAsync output. Return only the data the feature needs, and keep internal credentials and response bodies out of caller-controlled fetch results.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// Program.cs: both endpoints let the caller choose the destination
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

var app = WebApplication.Create(args);
var http = new HttpClient();

// any URL is fetched, and HttpClient follows redirects
app.MapPost("/preview", async (FetchRequest input) =>
{
    var body = await http.GetStringAsync(input.Url);
    return Results.Text(body.Length > 5000 ? body[..5000] : body);
});

// one address, three prefixes; the handler resolves again
app.MapPost("/webhooks/test", async (FetchRequest input) =>
{
    var address = (await Dns.GetHostAddressesAsync(new Uri(input.Url).Host))[0];
    if (IPAddress.IsLoopback(address) || address.ToString().StartsWith("10."))
        return Results.BadRequest();
    var response = await http.PostAsJsonAsync(input.Url, new { ping = true });
    return Results.Ok(new { status = (int)response.StatusCode });
});

app.Run();

record FetchRequest(string Url);
HARDENED SECURE PATCH
// Program.cs: the handler resolves, checks and connects in one place
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Net.Sockets;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

var app = WebApplication.Create(args);

// every connection goes through ConnectCallback; no proxy, no redirects
var handler = new SocketsHttpHandler
{
    AllowAutoRedirect = false,
    UseProxy = false,
    ConnectTimeout = TimeSpan.FromSeconds(3),
    ConnectCallback = async (context, cancellationToken) =>
    {
        var host = context.DnsEndPoint.Host;
        var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
        if (addresses.Length == 0 || !addresses.All(IsPublic))
            throw new HttpRequestException($"blocked destination {host}");
        var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
        try
        {
            await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
            return new NetworkStream(socket, ownsSocket: true);
        }
        catch
        {
            socket.Dispose();
            throw;
        }
    },
};
var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10), MaxResponseContentBufferSize = 1_000_000 };

// only http and https URIs reach the guarded client
app.MapPost("/preview", async (FetchRequest input) =>
{
    if (CheckedUri(input.Url) is not { } uri)
        return Results.BadRequest();
    try
    {
        var body = await http.GetStringAsync(uri);
        return Results.Text(body.Length > 5000 ? body[..5000] : body);
    }
    catch (HttpRequestException)
    {
        return Results.BadRequest();
    }
});

app.MapPost("/webhooks/test", async (FetchRequest input) =>
{
    if (CheckedUri(input.Url) is not { } uri)
        return Results.BadRequest();
    try
    {
        var response = await http.PostAsJsonAsync(uri, new { ping = true });
        return Results.Ok(new { status = (int)response.StatusCode });
    }
    catch (HttpRequestException)
    {
        return Results.BadRequest();
    }
});

app.Run();

static Uri? CheckedUri(string raw) =>
    Uri.TryCreate(raw, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
        ? uri
        : null;

// reject loopback, private, link-local, CGNAT and non-global IPv6
static bool IsPublic(IPAddress address)
{
    if (address.IsIPv4MappedToIPv6)
        address = address.MapToIPv4();
    if (IPAddress.IsLoopback(address) || address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6UniqueLocal)
        return false;
    var b = address.GetAddressBytes();
    if (b.Length == 16)
        return (b[0] & 0xe0) == 0x20;
    return !(b[0] == 0 || b[0] == 10 || b[0] == 127 || b[0] >= 224
        || (b[0] == 100 && (b[1] & 0xc0) == 64)
        || (b[0] == 169 && b[1] == 254)
        || (b[0] == 172 && (b[1] & 0xf0) == 16)
        || (b[0] == 192 && b[1] == 168));
}

record FetchRequest(string Url);

Engineering & System Hardening Checklist

Sources