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:
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:
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:
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:
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:
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:
And after:
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:
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
:
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
:
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:
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 §ionHeaders[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:
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
:
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.