Skip to content
Go back

Building a Supply Chain Attack with .NET and NuGet

Maarten Balliauw
Maarten Balliauw

Every time npm has a supply chain incident, it’s tempting to think “haha, npm had yet another supply chain attack!” and feel safe in .NET land. But the tools to do the same thing in .NET, or at least similar things, are all there. Module initializers, source generators, MSBuild targets, startup hooks. A number of techniques exist to smuggle code into someone’s codebase, and most of them run before your application’s Main method is even called.

After I jokingly published a NuGet package that would be a fake “Microsoft version of Automapper”, I found that using a Turkish i (we’ll look at this later) allowed me to publish a package that one could see as “Microsoft” (even if the i would be different).

Now, I’m not a security researcher. But I was intrigued by the ability to publish this type of package… Here’s my story of building a supply chain attack for .NET.

What Is a Software Supply Chain Attack?

The core idea: attack elements in the software supply network. Take legitimate libraries, tools, or vendors, add a malicious payload, and let the impact flow downstream from the vendor to the ultimate targets. The payload might steal data, deploy ransomware, or establish remote control over systems. These attacks are often carried out with a “sleeper” approach: quiet infiltration first, activation later.

While this type of attack seems to occur almost daily, here are two recent examples that made a lot of press:

SolarWinds Orion (2020): Attackers compromised SolarWinds’ CI/build infrastructure and smuggled malicious code into the product shipped to customers. This gave them access to customer IT systems, including US government agencies.

npm Shai Hulud (November 2025): Compromised maintainer accounts for popular npm packages (Zapier, PostHog, Postman, ENS Domains) and published trojanized versions. This one is worth looking at in detail because the techniques translate directly to .NET.

Dissecting The Shai Hulud Attack

The Shai Hulud campaign compromised roughly 700 npm packages and affected over 25000 GitHub repositories. Wiz’s blog post covers the attack and its components in detail, but I’ll do a quick summary here:

Infection: The attackers compromised maintainer accounts and published new versions of legitimate packages. When developers ran npm install, the trojanized version was pulled automatically.

Dropper: The malicious code ran via npm’s preinstall lifecycle hook (the equivalent of NuGet’s .targets files or module initializers). On CI runners, the dropper executed synchronously to ensure it finished before the runner shut down. On dev machines, it spawned a background process to avoid suspiciously long install times. It detected the environment by checking GITHUB_ACTIONS, CI, TF_BUILD, and similar env vars.

Payload: The dropper harvested credentials from ~/.aws/credentials, ~/.azure/, environment variables, and cloud metadata services (IMDS). It dumped secrets from AWS Secrets Manager, Google Secret Manager, and Azure Key Vault. It attempted Docker privilege escalation by mounting the host filesystem into a privileged container. Everything was exfiltrated to GitHub repositories, cross-victim: your secrets could end up in someone else’s public repo.

C2 (the clever bit): Rather than using a traditional command-and-control server, the malware registered the compromised machine as a GitHub Actions self-hosted runner named SHA1HULUD. It then created a workflow file:

name: Discussion Create
on:
  discussion:
jobs:
  process:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v5
      - name: Handle Discussion
        run: echo ${{ github.event.discussion.body }}

The ${{ github.event.discussion.body }} is an injection vulnerability. The attacker could execute arbitrary commands on the compromised machine simply by opening a Discussion in the GitHub repository. GitHub Discussions became the C2 channel. Completely legitimate-looking traffic, no suspicious outbound connections to flag.

Scale: 25000+ malicious repos across ~500 GitHub users, growing at roughly 1000 repos per 30 minutes. 775 compromised GitHub tokens, 373 AWS credentials, 300 GCP credentials, 115 Azure credentials identified.

The npm preinstall hook is the npm equivalent of what .NET offers through module initializers, source generators, MSBuild targets, and startup hooks. The same patterns work in .NET, and some of them are harder to detect.

The Usual Components

A supply chain attack typically has three parts:

The rest of this post walks through how to build each component in .NET, and how to defend against them.

Infection

First, let’s look at how you can get someone to install a malicious package.

Dependency Confusion

NuGet searches all configured package sources when restoring packages. If a package exists in multiple sources, the resolution is non-deterministic. So if your company has an internal package called AcmeCorp.Framework on a private feed, and someone publishes AcmeCorp.Framework on NuGet.org, there’s a good chance the NuGet.org version will be downloaded instead of your internal one.

As a mitigation against dependency confusion, you can use package source mapping in your nuget.config to bind specific package patterns to specific sources:

<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="internal" value="https://nuget.acmecorp.com/v3/index.json" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
    <packageSource key="internal">
      <package pattern="AcmeCorp.*" />
    </packageSource>
  </packageSourceMapping>
</configuration>

In addition, you can use package signing and verification so NuGet only trusts your own AcmeCorp.Framework. Another mitigation would be registering your private package ID prefix on NuGet.org (so nobody else can push AcmeCorp.*), and enable RestorePackagesWithLockFile + RestoreLockedMode on CI to catch unexpected version changes.

Typosquatting

Back to this blog post’s introduction… Back when I uploaded Mİcrosoft.Extensions.AutoMapper to NuGet.org (note the Turkish İ), I was surprised this was at all possible. I reported the issue, and it was remediated. However, it got me thinking about how far Unicode lookalikes could go.

I started experimenting. The first attempt was the classic “rn looks like m” trick: Microsoft.EntityFrarneworkCore. That worked, but I wasn’t convinced this would be a good way to forge a fake package. Next, I tried Mіcrosoft.EntityFrameworkCore (with only one substitution), and NuGet.org’s internal TypoSquattingService caught that one immediately. NuGet.org presented me with an error (“The uploaded package’s id is too similar to the already existing packages: Microsoft.EntityFrameworkCore”). Good!

Then I tried lookalike Unicode characters. Ukrainian dotted і (which looks identical to Latin i), Armenian ո (looks like n), Greek ο (looks like o), Greek е (looks like e). With enough substitutions, I was able to upload Mіcrosοft.EոtityFramewοrkCorе to NuGet.org. It looked legitimate: version 10.0.0, the real EF Core description copied verbatim, package icon matching. See for yourself!

Mіcrosοft.EոtityFramewοrkCorе on NuGet

If I’d have a script download the package a few thousand times each day, the download count could even look legitimate.

I reported this to the Microsoft Security Response Center (MSRC). Their initial response: “it does not meet Microsoft’s requirement as a security vulnerability for servicing.” I’ll add a spoiler: in the end, it was fixed, but it needed me to do something else first, which was convincing people to install this package.

So, I started toying with the idea of doing “drive-by PRs”, and being genuinely helpful to other public projects on GitHub in updating their EntityFramework Core version. When I tried this on an internal repo, I was… shocked. The diff shown in GitHub’s PR interface would show the changes, but without a clear indication that I meddled with the package ID. See for yourself: it looks like only the version was bumped:

GitHub PR UI showing unicode? No.

Imagine I would do this with 2000 GitHub projects, over time. I’m sure some would accept the PR without any issue, which would be great!

I also reported the broader issue to GitHub’s bug bounty program via HackerOne (the vulnerability: homoglyph package names go undetected in GitHub’s PR diff view). GitHub closed it as “by design.” The implication was uncomfortable: I could open a drive-by PR to 100 OSS projects changing a package reference in their .csproj file, and very likely a few would get merged because the diff looks identical to the human eye.

GitHub Copilot’s PR review does catch it. When I requested a Copilot review, it flagged the homoglyph immediately: “The package name contains homoglyph characters (non-ASCII lookalikes). ‘Microsoft.EntityFrameworkCore’ should be ‘Microsoft.EntityFrameworkCore’.”

A takeaway here: be rigorous in checking pull requests. Use Copilot code review as a second pair of eyes. Use package source mapping and signing. Enable lock files. Use SBOM analysis tools. And keep in mind the call may come from inside the house (so not only apply these mitigations to external PRs, but also internal.)

Dropper

With some infection vectors covered, let’s move on to the dropper. How can you run code on a victim’s machine, merely by installing a NuGet package? I covered most of these techniques in a 2021 blog post as well, but they’ve only gotten more powerful since then.

Module Initializers

The [ModuleInitializer] attribute tells the runtime to execute a method when the containing module is loaded (by using a type in it). If you ship a NuGet package with a module initializer, your code runs the moment the consuming assembly references it, and uses a type from the assembly. The method is static void, takes no parameters, and runs before anything else in that assembly.

To make it stealthy, you decorate it with attributes that hide it from tooling:

[CompilerGenerated]       // Exclude from code analysis
[DebuggerHidden]          // Debugger skips this
[DebuggerNonUserCode]     // Not user code
[EditorBrowsable(EditorBrowsableState.Never)]  // Hidden from IntelliSense
[ModuleInitializer]
public static async void Manage()
{
    try
    {
        // Your dropper code here
    }
    catch (Exception)
    {
        // Silently ignore errors
    }
}

The [EditorBrowsable(Never)] hides the class from IntelliSense (not all IDEs respect this, but most do). [CompilerGenerated] excludes it from code analysis. [DebuggerHidden] ensures you never accidentally step into it during debugging. Together, these make the class effectively invisible in day-to-day development.

The try/catch with an empty handler is deliberate: if the dropper fails (no network, file locked, whatever), we don’t want an exception leaking out and alerting anyone.

The only downside to [ModuleInitializer] is that the assembly/module must be loaded. You have to convince the developer using your dropper assembly to consume a type from it in order for it to run.

ModuleInitializerAttribute was added in .NET 5.0, and it has perfectly legitimate uses (initializing an image library before a PDF generator can use it, setting up test infrastructure). That’s part of what makes it dangerous: you can’t just ban it.

Source Generators

Source generators are even more interesting. A NuGet package can ship a source generator that runs at compile time and injects code directly into the consuming project’s compilation output. The generated code lives in the obj/ folder, not in source control, so it’s invisible in the repository. I used this technique in my 2021 post too, but here’s the updated version with a CI-only trigger:

[Generator]
public class InitializerGenerator : IIncrementalGenerator
{
    private bool IsCi()
    {
        if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))
            && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TF_BUILD"))
            && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI_SERVER")))
            return false;

        return true;
    }

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        if (!IsCi()) return;

        var rootNamespace = context.AnalyzerConfigOptionsProvider
            .Select((c, _) => c.GlobalOptions
                .TryGetValue("build_property.RootNamespace", out var ns) ? ns : null);

        context.RegisterSourceOutput(rootNamespace, (ctx, ns) =>
        {
            ctx.AddSource("Startup.g.partial.g.cs", @"
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace " + (ns ?? "System.Data.Infrastructure") + @"
{
    [CompilerGenerated]
    [DebuggerHidden]
    [EditorBrowsable(EditorBrowsableState.Never)]
    public static class PolicyDiscoveryManager
    {
        [ModuleInitializer]
        public static async void Manage()
        {
            // Dropper payload here
        }
    }
}");
        });
    }
}

The CI check means a developer building locally never sees the dropper. Only the CI build server gets the injected code. The generated file name (Startup.g.partial.g.cs) looks like a normal source-generated file, and it uses the project’s own root namespace so it blends in.

Source generators come with two additional “features” for our supply chain attack:

In terms of mitigation, inspect source generator output. Some IDEs allow you to expand solution tree into source generator output. You can also set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> to write generated files to disk for review. But this isn’t airtight: the source generator itself runs code at build time and could do damage there without generating any visible output. Just depends if the attacker wants to do something on your machine, or sneak code into the assemblies you are building. Or both.

MSBuild .targets/.props

NuGet packages can include MSBuild .targets or .props files in a build/ or buildTransitive/ folder. These are automatically imported into the consuming project’s build. They can write files, run processes, or (as in this example) generate and compile C# code:

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <PropertyGroup>
        <GeneratedText><![CDATA[using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace System.Data.Infrastructure.EntityFramework.Security
{
    public static class PolicyDiscoveryManager
    {
        [CompilerGenerated]
        [DebuggerHidden]
        [DebuggerNonUserCode]
        [EditorBrowsable(EditorBrowsableState.Never)]
        [ModuleInitializer]
        public static void Manage()
        {
            // Payload here
        }
    }
}]]></GeneratedText>
    </PropertyGroup>

    <Target Name="AddGeneratedFile"
            BeforeTargets="BeforeCompile;CoreCompile"
            Inputs="$(MSBuildAllProjects)"
            Outputs="$(IntermediateOutputPath)GeneratedFile.cs">
        <PropertyGroup>
            <GeneratedFilePath>$(IntermediateOutputPath)GeneratedFile.cs</GeneratedFilePath>
        </PropertyGroup>
        <ItemGroup>
            <Compile Include="$(GeneratedFilePath)" />
            <FileWrites Include="$(GeneratedFilePath)" />
        </ItemGroup>
        <WriteLinesToFile Lines="$(GeneratedText)"
                         File="$(GeneratedFilePath)"
                         WriteOnlyWhenDifferent="true"
                         Overwrite="true" />
    </Target>
</Project>

Add it to the NuGet package you ship, and your victim will see a .cs file being written into the intermediate output directory (obj/) and added to compilation. The file never appears in source control. The build target runs before compilation, so the generated code is included in the final assembly.

So, also here, review your dependencies and the imported targets/props in your IDE (Visual Studio shows them in the project’s build evaluation). But there’s no way to “block” this mechanism without disabling a lot of core .NET SDK functionality, since the .NET SDK itself relies on the same pattern.

Startup Hooks

The DOTNET_STARTUP_HOOKS environment variable tells the .NET runtime to load and execute code before an application’s Main method. Any assembly with a StartupHook.Initialize() method can be injected. Here’s a harmless example (based on Kevin Gosse’s blog post on startup hooks) that replaces Console.Out with an inverted text writer:

public static class StartupHook
{
    public static void Initialize()
    {
        Console.WriteLine("Hello from injected code!");
        Console.SetOut(new InvertedTextWriter(Console.Out));
    }
}

This runs before the application does anything. A malicious NuGet package could set this environment variable via MSBuild (using a .targets file), and from that point forward, every .NET process on the machine would run the hook.

Monitor environment variable changes with endpoint detection tools. Startup hooks require the env var to be set, so if you can detect when DOTNET_STARTUP_HOOKS gets modified, you can catch this early.

Other Places That Can Run Code

The list goes on:

Payload: What Do You Want to Do?

Once you have code running on the target machine, the question is what to do with it. Common payloads are:

I won’t go into too much detail here, as the payload will highly depend on the business logic of your malicious operation.

For stealth, the payload can be obfuscated in various ways. One technique could use Unicode invisible characters to encode arbitrary bytes:

public static class Unicoder
{
    public static string Encode(byte[] input)
    {
        var builder = new StringBuilder();
        var bits = new BitArray(input);
        for (var i = 0; i < bits.Length; i++)
        {
            builder.Append(bits[i] ? '\u200C' : '\u2063');
        }
        return builder.ToString();
    }

    public static byte[] Decode(string input)
    {
        var bits = new BitArray(input.Length);
        for (var i = 0; i < input.Length; i++)
        {
            bits[i] = input[i] == '\u200C';
        }

        var bytes = new byte[bits.Length / 8];
        bits.CopyTo(bytes, 0);
        return bytes;
    }
}

The encoded output is a string of zero-width characters (\u200C and \u2063). In source code, it looks like an empty string. The actual payload is invisible to anyone reading the code.

Command and Control (C2)

The C2 needs to be as stealthy as possible. Some options:

And if you ever thought malware doesn’t have its own ready-made components and services, think again. For .NET-specific C2, Covenant is an open-source .NET command-and-control framework with HTTP listeners, agent management (“Grunts”), task execution, and a web UI. In case you don’t want to build your own C2.

What Can You Do to Mitigate?

Think of this as the Swiss Cheese model: every layer of defense has holes, but stacked together they’re effective. No single mitigation is enough, but the combination makes attacks much harder.

Package management:

Code review and analysis:

Runtime protection:

General:

Every product has a supply chain and quality assurance at many steps, like when manufacturing a box of breakfast cereal. There are checks on ingredients, on machines, on output. Your software supply chain already has checks (CVE warnings in NuGet, GitHub security scanning, assembly signing). The question is whether you’re paying attention to all of them, and whether you’ve plugged the gaps that remain.

But to be honest? There is no 100% failsafe. A modern application pulls in hundreds of transitive dependencies, and nobody has time to audit every single one of them on every update. Mitigations often add add friction to your workflow (lock files break your restore and cause merge conflicts, source mapping requires maintenance, PR reviews take longer). Friction is the enemy of shipping software. So some teams will skip steps, and some attacks will get through. That’s not a reason to give up on mitigations, but it is a reason to be realistic about what they can and can’t do. Supply chain attacks will keep happening. The goal isn’t perfection, it’s making your project an expensive enough target that attackers move on to the next one.


Edit page
Share this post on:

Next Post
Hosting the .NET Aspire Dashboard as a Standalone Container in Azure Web Apps