« Back to home

The .NET Export Portal

A while back I published a post looking at how to craft a .NET assembly which exposes managed code via DLL exports, RunDLL32 your .NET.

While working on some tooling recently I revisited this topic and wanted to know just why this works in the way that it does. After all, by now we've all seen the COM calls required to spin up the CLR, so what makes unmanaged exports so special?

As it turns out there is an interesting transformation that happens when you add in your .export directive before ilasm'ing your warez, something which I'll walk you through in this post. Primarily we will focus on how an unmanaged call of an exported DLL function transports us into managed code, and also look at a novel way that we can call managed functions without having to mark anything as exported. Let's get started.

A Quick Recap of Unmanaged Exports

I won't cover all of the previous material here which you can find in my original post, but let's create a very simple .NET assembly with an exported function that we will use as our baseline while exploring the concepts within this post.

The magic of unmanaged exports lies with the .export directive added to a method which, although supported by the IL standard, isn't exposed directly via C# (although nuget's have been provided to make this process a lot easier).

Let's stick with IL for now to demonstrate this concept:

.assembly extern mscorlib
{
  .ver 4:0:0:0
}

.assembly Test
{
    .ver 1:2:3:4
}

.module test.dll
.class public auto ansi beforefieldinit Test.TestClass extends [mscorlib]System.Object
{
    .method public static void TestExport() cil managed
    {
        .maxstack 8
        .export [1]
        
        ldstr "W00t, you called TestExport()"
        call valuetype [System.Windows.Forms]System.Windows.Forms.DialogResult [System.Windows.Forms]System.Windows.Forms.MessageBox::Show(string)
        pop
        ret
    }
}

To compile this we use the following command:

ilasm test.il /DLL /output=test.dll;

And when executed using rundll32.exe, we get our expected dialog:

NETPortal-1

So it's about now when that nagging feeling starts to creep over.. why does this work? After all, we know the mess of COM calls we need to make to initialise the .NET runtime and load an assembly, and here we are just calling a single DLL export and we're transported into the managed runtime, so what gives? Let's fire up our old friend Ghidra and take a look.

.export Under the Hood

To better understand the magic of the .export directive, the first thing we can do is load the .NET assembly into a disassembler to see just what our exported function looks like. After all, we should see some unmanaged code if native tools are able to call this exported function... right?

Well our TestExport function looks like this:

NETPortal-2

Looking at the address being referenced by this jmp, we see a value stored within the .sdata section of 06000001 which is obviously not a valid address within this binary:

NETPortal-3

Pretty unimpressive... but for anyone that has spent enough time with IL, this gives us a good spoiler as to what might be happening.

Taking the same assembly and loading it into a debugger, we can add a breakpoint to the exported function before execution:

NETPortal-4

Aha, so it looks like the previous 06000001 value has changed, and now we have a pointer to a nice stub which makes a promising call to a mscoreei.dll function. So what is it exactly that is performing this transformation and when exactly does it occur?

If we pause and take a look at what is within the .NET assemblies DllMain function, what we see is this trampoline into _CorDllMain which is a documented API exposed from the CLR to kick off the runtime when loading an assembly:

NETPortal-5

And it is here that we see the transformation happen. For example, if we view our exported functions jmp target address before allowing the _CorDllMain function to be called:

NETPortal-6

And after:

NETPortal-7

So we clearly something is happening within _CorDllMain to direct us to this stub... but there is still more to uncover, so let's go deeper.

VTFixup and VTable

Let's take a look at another area of this translation process, something that you may never have heard of but which plays an important part in allowing the .NET _CorDllMain startup to remap method tokens to native stubs... the vtfixup and vtable directives.

Surprisingly there doesn't seem to be much documentation on this process outside of the awesome book "Inside Microsoft .NET IL Assembler by Serge Lidin" which is always invaluable when reviewing .NET internals, and the source code of the .NET runtime here.

Going back to our initial compiled payload we find the following within the COR header:

NETPortal-8

Here the header field VTableFixups has been populated with an address and a size of 8 bytes. So what exactly is a VTableFixup? Well if we check out the runtime source, we can find our answer within corhdr.h which gives us a simple structure:

typedef struct IMAGE_COR_VTABLEFIXUP // From CoreCLR's corhdr.h
{
    std::uint32_t      RVA;                    // Offset of v-table array in image.
    std::uint16_t      Count;                  // How many entries at location.
    std::uint16_t      Type;                   // COR_VTABLE_xxx type of entries.
} IMAGE_COR_VTABLEFIXUP;

The three struct members consist of the RVA, which is the PE virtual address of a VTable, the Count field which is simply how many array elements there are within the VTable, and finally the Type field which is one of the following enumeration values:

// V-table constants
COR_VTABLE_32BIT                    =0x01,          // V-table slots are 32-bits in size.
COR_VTABLE_64BIT                    =0x02,          // V-table slots are 64-bits in size.
COR_VTABLE_FROM_UNMANAGED           =0x04,          // If set, transition from unmanaged.
COR_VTABLE_FROM_UNMANAGED_RETAIN_APPDOMAIN=0x08,    // NEW
COR_VTABLE_CALL_MOST_DERIVED        =0x10,          // Call most derived method

Dumping the entry from our original IL compiled .export example, we find that the Type field is set to COR_VTABLE_FROM_UNMANAGED | COR_VTABLE_32BIT.

Now if we dereference the RVA contained within our sample DLL, we see a pointer to a .sdata section in the PE file. This is a VTable, which contains an array of values (each entry depends on the size shown in the above enumeration). It is this table which is being dereferenced by our export function jmp instruction, and the first entry is currently set to the 6000001 value.

So what is this 6000001 value exactly? Well it's a token which corresponds to a .NET method, for example, if we throw our DLL into dnSpy, we will see that this token corresponds to the method TestExport:

NETPortal-9

Now once the _CorDllMain function is called, this token is taken and translated to a pointer to a stub which is responsible for transporting us from the unmanaged runtime to .NET, where execution of the managed method occurs.

And this is the magic behind the .export function, it's all just VTableFixup's and VTables's:

travel_to_hell

So can we use this as an alternate method of transporting ourselves from unmanaged to managed code? Of course we can, and we can actually do it without exporting anything from our target assembly!

A Portal to .NET

Let's take a very straightforward C# binary with a few methods:

using System;
using System.Windows.Forms;

namespace TestLibrary
{
    public class Test
    {
        public static void TestFunction1()
        {
            MessageBox.Show("TestFunction1() called");
        }

        public static void TestFunction2()
        {
            MessageBox.Show("TestFunction2() called");
        }
    }
}

Compiled as an assembly, we don't need to expose these methods via our DLL export table as we'll be using the power of VTableFixups to jump our way to these methods.

Note: One important thing we need to do before we can jump into our .NET assembly is to clear out any ILOnly flag which may be added to the COR header. There are a number of ways to do this, but for our POC we will just use the Windows SDK corflags utility:

corflags.exe /ILONLY- testassembly.dll

With our assembly ready, the first thing we need to do is to load the assembly into memory. To do this I'm using the LoadLibraryEx API call along with the DONT_RESOLVE_DLL_REFERENCES flag. This allows us to map the assembly correctly into memory without triggering the _CorDllMain call from DllMain:

char* assembly = (char*)LoadLibraryExA("TestLibrary.dll", 0, DONT_RESOLVE_DLL_REFERENCES);

Once loaded we need to construct a VTableFixup. The easiest way to do this is to steal some slack space between sections. For example, if we look at each section within the .NET PE file we usually find a few bytes of space where we can construct our entries:

NETPortal-10

So finding an appropriate section is just a case of:

IMAGE_SECTION_HEADER* findSlackSpace(IMAGE_SECTION_HEADER* sectionHeaders, int sectionCount) {
	for (int i = 0; i < sectionCount; i++) {
		if (sectionHeaders[i].Misc.VirtualSize - sectionHeaders[i].SizeOfRawData > VTALLOC_SIZE) {
			return &sectionHeaders[i];
		}
	}

	return NULL;
}

Once we have an appropriate section we need to update the IMAGE_SECTION_HEADER values in memory to account for our injected VTableFixup:

VirtualProtect(slackSectionHeader, sizeof(IMAGE_SECTION_HEADER), PAGE_READWRITE, &old);
slackSectionHeader->Misc.VirtualSize += VTALLOC_SIZE;

VirtualProtect(assembly + vtfixupAddr, VTALLOC_SIZE, PAGE_READWRITE, &old);
vtFixupEntry = (IMAGE_COR_VTABLEFIXUP *)((char*)assembly + vtfixupAddr);
vtFixupEntry->Count = 1;
vtFixupEntry->RVA = vtableAddr;
vtFixupEntry->Type = COR_VTABLE_32BIT | COR_VTABLE_FROM_UNMANAGED;

Having constructed our VTableFixup entry, we next need to add our method token that we want _CorDllMain to create the portal to. We'll go with 60000001 for this example which is the token for our first method:

*(DWORD *)((char *)assembly + vtableAddr) = 0x06000001;

With the VTableFixup and VTable constructed, we can now modify the IMAGE_COR20_HEADER and point the VTableFixups field to our newly injected VTableFixup entry:

VirtualProtect(corheader, sizeof(IMAGE_COR20_HEADER), PAGE_READWRITE, &old);
corheader->VTableFixups.VirtualAddress = vtfixupAddr;
corheader->VTableFixups.Size = 8;

Now we're all set, all that is left to do is to call _CorDllMain which will invoke the .NET runtime and create us our portal into managed space:

BOOL loadCOR(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved) {

	_CorDllMain* corInit;

	HMODULE cor = LoadLibraryA("mscoree.dll");
	corInit = (_CorDllMain*)GetProcAddress(cor, "_CorDllMain");

	return corInit(hinstDLL, fdwReason, lpReserved);
}

And executing:

poc-portal-1

Awesome, and what about if we want that second function instead? Simple, we just change the method token within the VTable:

	// Add the method token we want converting
	*(DWORD *)((char *)assembly + vtableAddr) = 0x06000002;

And re-execute _CorDllMain:

poc-portal-2

And there we have it, hopefully this post was useful for anyone like me who likes to understand just what magic a technology is hiding.

The source code for this POC can be found here.