Post

A problem with .NET Self-Contained Apps and how to pop calculators in dnSpy

Programming languages that operate on a virtual machine often promise safety guards against many unsafe operations. However, virtual machines can have pretty serious bugs. In this post, we explore one interesting limitation of self-contained applications in .NET, and see how we can exploit it to pop calculators from our trusty decompiler dnSpy:

Summary

If you’re just here just for some actionable advice, here is the gist:

The public releases of dnSpy versions 6.1.8 up to 6.4.0 contain a critical bug allowing for arbitrary code execution via a DLL hijack that can be triggered upon opening a file and analyzing it statically.

dnSpyEx 6.4.1 and above have the issue patched.

You can download the latest version of dnSpyEx at its releases page on GitHub.

Acknowledgements:

  • Ellie: For finding the initial problem and helping me with researching the problem.
  • ElektroKill: For updating dnSpyEx quickly.

Timeline:

  • 2023-09-08: Bug discovered, reported, and patch released.
  • 2023-09-23: Public disclosure.

Interested in the technical details and the process that we followed to find and exploit the bug? Read on!

A Quick Refresher: What are Self-Contained Apps?

Writing applications using a .NET language such as C# has one downside: Every binary the compiler spits out requires a runtime to be installed for it to function properly.

To maximize the number of people that can run your application, one option is to choose a low enough version of .NET such that most people would have it installed on their computer, either by default or because a lot of other programs would be using it. For example, Windows 10 comes with a version anywhere between 4.6 and 4.8.1 pre-installed, so a natural choice would be to choose one of those. However, we are approaching the release of .NET 8 already, and the lower the version you choose, the fewer features and performance improvements you will be able to benefit from. Especially the latest versions of .NET have seen substantial performance increases, with the introduction of Span<T> and many JIT improvements, this would be far from ideal.

An application next to an installation of the .NET runtime.

Luckily, with the introduction of .NET Core 3.1, Microsoft came up with an alternative. Instead of relying on the user to install the right dependencies, it is now possible to publish your program as a self-contained application. Essentially, this means you can tell the compiler to include all files required to run your application in your deployment, including the .NET runtime itself. This solves the problem as no installation is required on the user’s side. It should work on any machine out-of-the-box, at the cost of a larger footprint on the disk that it has.

An application that ships its own version of the runtime.

Looking at dnSpy and its dependencies

dnSpy is such a program that is published as a self-contained application. It is therefore no surprise that in the assembly directory of dnSpy we can find many DLLs that are part of the .NET runtime that it targets. Notice for instance the existence of coreclr.dll:

dnSpy is a self-contained application that ships with its own version of the .NET runtime.

dnSpy also depends on many other libraries. At its core, dnSpy relies on dnlib, the main driving force for reading and writing .NET binaries. For some of its features, dnlib in turn also (weakly) depends on another DLL called Microsoft.DiaSymReader.Native.amd64.dll. When it is present in the assembly’s directory, dnlib can be instructed to use it to read and extract metadata from Windows PDB files placed next to an input binary. PDB files are useful to a reverse engineer, as it can provide additional context to decompiled source code such as the names of local variables as well as original file names and line numbers a binary was compiled with.

dnlib itself does not ship with a copy of Microsoft.DiaSymReader.Native.amd64.dll. This is because this library is written in C++ and only targets Windows platforms. Having a hard-link to this library would make cross-platform usage of dnlib impossible. Furthermore, it is a whopping 1.75MB large. For a feature that is not necessarily required to perform most features dnlib provides, the author decided to not reference it directly.

dnSpy, however, does ship with its own version of Microsoft.DiaSymReader.Native.amd64.dll as part of its self-contained runtime libraries, effectively allowing dnlib to be instructed to use this DLL.

dnSpy ships with its own copy of Microsoft.DiaSymReader.Native.amd64.dll.

As it so turns out, this DLL is one of the main sources of trouble for us.

A weird DLL load event

Ellie, a friend of mine, once had a Procmon instance running in the background while opening and analyzing some funky .NET binary that she was crafting for some .NET obfuscation-related research. She noticed that for this binary sometimes an image load event of a DLL called Microsoft.DiaSymReader.Native.amd64.dll would appear in the Procmon logs originating from dnSpy. The interesting part here is that very sporadically dnSpy would load the DLL not from its installation folder but from somewhere else on the system. Trying to reproduce it on my own machine, I saw that indeed sometimes the DLL is loaded from the Windows SDK rather than the one shipped with dnSpy:

dnSpy loading Microsoft.DiaSymReader.Native.amd64.dll from the installation folder of the Windows SDK.

On Windows, DLLs are resolved using a search algorithm, where a list of specific directories are searched for files with the requested DLL name. Usually, this list of directories contains the running assembly’s directory, as well as system directories such as C:\Windows\System32 and directories defined in the PATH environment variable.

This last detail is important because it opens up for an attack known as DLL Hijacking. If we place a file called Microsoft.DiaSymReader.Native.amd64.dll with our own code in one of these directories, and it happens to be the case that this directory is searched before the one containing the actual library, we are essentially tricking dnSpy into loading our own DLL instead of the real one.

What’s worse is that it also includes the current working directory of the process.

This is really bad, because dnSpy comes with a feature known as “Windows Integration”. This adds an “Open with dnSpy” item to Windows Explorer’s context menu allowing files to be opened quickly with dnSpy with a single click:

dnSpy’s Windows Explorer integration.

Many people have this feature enabled because it is very convenient [citation needed]. But it gets worse. Using Windows Explorer, files can also be dragged into dnSpy.exe directly, and this does NOT require enabling this “Windows Integration” feature.

Dragging a file into dnSpy.exe.

In both cases, Windows Explorer will start a new dnSpy process with its current working directory set to the directory the input binary is located at.

Opening via Windows Explorer sets the current working directory.

This means that if we can consistently trick dnSpy into loading Microsoft.DiaSymReader.Native.amd64.dll while also placing our own malicious version of this DLL in the same directory as our input sample and having the user open files from the current working directory (which is quite likely to happen), then dnSpy will load and execute our DLL instead of the one it ships with!

Making it consistent

So far it seemed like there was no real concrete reason for why this even happened in the first place. It did not trigger for “normal” binaries. It did not even always trigger consistently for the abnormal binary that we were initially analyzing. So where does it come from, and more importantly, how do we make dnSpy consistently load this DLL from the current working directory?

Is dnlib at fault?

Is this a bug in dnlib because dnlib uses this DLL?

Not quite! dnlib is actually perfectly fine here. At least in its source code we can see it restricts the DLL search paths to only the assembly and pre-defined safe directories:

1
2
3
4
5
6
7
8
static class SymbolReaderWriterFactory {
    /* ... */

    [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories | DllImportSearchPath.AssemblyDirectory)]
    [DllImport("Microsoft.DiaSymReader.Native.amd64.dll", EntryPoint = "CreateSymReader")]
    static extern void CreateSymReader_x64(ref Guid id, [MarshalAs(UnmanagedType.IUnknown)] out object symReader);

    /* ... */

Furthermore, dnlib also prefers to use its fully managed implementation of a (limited) PDB reader to extract a lot of information it may need (dnlib/src/DotNet/Pdb/PdbReaderOptions.cs). dnSpy does not change any of the default options when loading symbols (dnSpy/dnSpy.Contracts.DnSpy/Documents/DsDocument.cs#L210), and as such, even if these DLL search path flags were set incorrectly, dnlib would never be instructed to load Microsoft.DiaSymReader.Native.amd64.dll while trying to resolve debugging symbols.

Is the CLR at fault?

So is this a bug in the CLR itself?

We inspected the source code of the runtime responsible for interpreting these DLL search path flags (dotnet/runtime/src/coreclr/vm/nativelibrary.cpp:L740), and it does not reveal anything out of the ordinary. No problems there!

It turns out that it is possible to trigger this load consistently when dnSpy fails to decompile some part of the binary. In such a case, the ILSpy engine throws an exception which is caught by dnSpy’s front-end and then displayed as a block comment in the decompiler view.

Attaching WinDbg reveals the true origin of the module load. It is not coming from dnlib at all but from a call to Exception::get_StackTrace instead:

Call stack after the DLL load.

To help developers debug their application, the .NET runtime enriches stack traces with additional information such as file paths and line numbers whenever possible. For that, the .NET runtime uses its own Debugging Interface Access (DIA), to read the associated PDB files of the assembly that caused the exception. To do this type of enrichment, the CLR obtains an instance of ISymUnmanagedBinder by calling FakeCoCreateInstanceEx, a function that is similar to CoCreateInstanceEx but takes a path to the DLL that implements the component. The FakeCoCreateInstanceEx call can be found in dotnet/runtime/src/coreclr/vm/ceeload.cpp:2269. An excerpt of the relevant code can be seen below. Here, we find a nice reference to our troublemaker DLL:

1
2
3
/* ... */
#define NATIVE_SYMBOL_READER_DLL W("Microsoft.DiaSymReader.Native.arm64.dll")
/* ... */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* ... */

// We're going to be working with Windows PDB format symbols. Attempt to CoCreate the symbol binder.
// CoreCLR supports not having a symbol reader installed, so CoCreate searches the PATH env var
// and then tries coreclr dll location.
// On desktop, the framework installer is supposed to install diasymreader.dll as well
// and so this shouldn't happen.
hr = FakeCoCreateInstanceEx(CLSID_CorSymBinder_SxS, NATIVE_SYMBOL_READER_DLL, IID_ISymUnmanagedBinder, (void**)&pBinder, NULL);
if (FAILED(hr))
{
    PathString symbolReaderPath;
    hr = GetClrModuleDirectory(symbolReaderPath);
    if (FAILED(hr))
    {
        RETURN (NULL);
    }
    symbolReaderPath.Append(NATIVE_SYMBOL_READER_DLL);
    hr = FakeCoCreateInstanceEx(CLSID_CorSymBinder_SxS, symbolReaderPath.GetUnicode(), IID_ISymUnmanagedBinder, (void**)&pBinder, NULL);
    if (FAILED(hr))
    {
        RETURN (NULL);
    }
}

/* ... */

As can be read from the comments as well as the code, the CLR prefers to use a relative path to Microsoft.DiaSymReader.Native.arm64.dll, which makes it look into the current PATH (and thus also working directory!) for an implementation of the DIA. Luckily, Pull Request #87782 addresses this by removing the first call and always goes straight to the installation directory of the CLR using GetClrModuleDirectory to find the DLL. This patch has been shipped with .NET 6.0.20, and Windows Update has probably taken care of updating your local installation of .NET.

However, since dnSpy is a self-contained application, its version of coreclr.dll had not been updated to this version yet. This is an inherent limitation to any self-contained application. Developers of these types of applications need to be careful that their binaries keep up to date with security patches.

This also means that whenever an exception occurs and a stack trace is requested, the runtime will load Microsoft.DiaSymReader.Native.amd64.dll from the current working directory as opposed to the one shipped by the assembly itself!

Weaponizing it

We now have a way to load arbitrary code into dnSpy pretty consistently. Let’s pop some calculators!

All we need to do is three things:

  1. Create a DLL called Microsoft.DiaSymReader.Native.amd64.dll that spawns calc.exe in its DllMain.
  2. Create sample that triggers an exception in dnSpy where a stack trace is queried and printed out.
  3. Place the malicious DLL next to the sample and ship it.

The first step is easy. Just create a new C++ DLL project, call it Microsoft.DiaSymReader.Native.amd64.dll and add a WinExec call in its DllMain:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "pch.h"

BOOL APIENTRY DllMain( 
    HMODULE hModule,
    DWORD  ul_reason_for_call,
    LPVOID lpReserved
)
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        WinExec("calc.exe", 0);
        break;
    }
    return TRUE;
}

Step two is pretty easy as well. As was mentioned before, the ILSpy decompiler engine likes to display full error messages when a decompilation of a method fails. Thus, we can simply write a Hello World application, add a dummy method to its main class, and replace its code with some junk data that doesn’t decode to proper CIL instructions such that it fails to decompile. By default, when navigating to an individual method in the assembly explorer, dnSpy always decompiles the entire enclosing type as opposed to just the method alone.

1
2
3
4
5
6
7
public static class Program
{
    public static void Main() => Console.WriteLine("Hello, world!");

    // Replace some opcode bytes of the following method body with some junk.
    private static void Dummy() => Console.WriteLine("This is never called"); 
}

Step three is just copying two files into the same folder.

Download PoC

Demo time!

If you did everything right, opening the PoC binary via Windows Explorer in a vulnerable version of dnSpy will pop a calculator when navigating to the entry point method:

Takeaways

This is a nice example of a pretty fundamental limitation of self-contained applications that ship their own versions of a runtime or standard library. An update in the runtime requires an update of the program. This means a security update in the runtime requires a security update of the program as well. Developers that ship self-contained applications really need to stay aware of any vulnerabilities that may be present in their dependencies. Luckily, for most developers, this is the only thing they would need to do, but they cannot rely on Windows Update to update their own DLLs!

Additionally, Virtual Machines such as .NET promise a safe space that promises a safe environment. In most cases they are right and in most cases we can assume so. But as this post has proven (as well as many others have in the past), critical bugs are still found every now and then that can lead to some pretty bad things such as arbitrary code execution. Also, static analyzers can have bugs! Therefore, always use a sandboxed environment (such as a VM) for unknown binaries. You will never know what other people may have found and/or are exploiting!

Thanks once again to Ellie for finding and helping me out with this project, as well as ElektroKill for a quick response in updating and redeploying dnSpy.

Happy hacking, update your dnSpy version, and stay safe!

This post is licensed under CC BY 4.0 by the author.