Home About

Common Language Runtime 3: Finalising my CLR Harness

Introduction

Back in September 2020 I started looking into the Common Language Runtime to get a better understanding of what execute-assembly actually does. Those first two blogs were my first proper encounters with CPP and CLRs and the second post required the .NET method to be passed in. Since then, the harness I frequently use has changed quite a bit and since this process is now quite heavily documented and I'm getting triggered having an unfinished series on here, I'm finally finishing it

To name a few, here are some of the implementations I've found online for this:

  1. Hnisec/execute-assembly
  2. b4rtik/metasploit-execute-assembly
  3. xpn/unmanaged_dotnet_unhook_etw.c
  4. med0x2e/ExecuteAssembly
  5. etormadiv/HostingCLR

The previous issues

The previous version I had annoyed me because it required the namespace and whatnot to be passed, and its quite trivial to solve. Not sure why, but I was originally using Get_Type_2() which can be seen in this commit. But the fix is actually to replace all that rubbish with this:

if(spAssembly->get_EntryPoint(&pMethodInfo) != S_OK){
        printf("[!] _MethodInfoPtr->get_EntryPoint()\n");
        cleanup(safeArrayArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
        return false;
}
else {
    printf("[+] _AppDomainPtr->get_EntryPoint() was successful\n");
}

This call maps to Assembly.EntryPoint within .NET and does exactly that. It gets the entry point to the assembly. For anyone who doesn't know, a .NET Assembly entry point has to be one of the following: DllMain, WinMain or Main. "Main" has a whole article from Microsoft detailing its purpose. Note, an assembly can be execute based on any entry point name, but it would have to be specified. "Main" is default.

The next issue was method arguments. The solution I ended up with was stolen from Hnisec. However, I had to change how it performed with no arguments. This is where I ended up:

if (argsSize > 0)
{
    vtPsa.vt = VT_ARRAY | VT_BSTR;
    SAFEARRAYBOUND argsBound[1];
    argsBound[0].lLbound = 0;
    size_t argsLength = arguments.data() != NULL ? argsSize : 0;
    argsBound[0].cElements = argsLength;
    vtPsa.parray = SafeArrayCreate(VT_BSTR, 1, argsBound);
    safeArrayArgs = SafeArrayCreateVector(VT_VARIANT, 0, 1);
    LPWSTR* szArglist;
    int nArgs;
    wchar_t* wtext = (wchar_t*)malloc((sizeof(wchar_t) * argsSize + 1));
    mbstowcs(wtext, (char*)arguments.data(), argsSize + 1);
    szArglist = CommandLineToArgvW(wtext, &nArgs);
    vtPsa.parray = SafeArrayCreateVector(VT_BSTR, 0, nArgs);
    for (long i = 0; i < nArgs; i++)
    {
        BSTR strParam1 = SysAllocString(szArglist[i]);
        SafeArrayPutElement(vtPsa.parray, &i, strParam1);
    }
    long iEventCdIdx(0);
    SafeArrayPutElement(safeArrayArgs, &iEventCdIdx, &vtPsa);
    ZeroMemory(&vtPsa, sizeof(VARIANT));
}
else
{
    // I dont know why i cant just pass a LONG 0, but i had to do all this? seems wrong
    vtPsa.vt = VT_ARRAY | VT_BSTR;
    SAFEARRAYBOUND argsBound[1];
    argsBound[0].lLbound = 0;
    size_t argsLength = arguments.data() != NULL ? argsSize : 0;
    argsBound[0].cElements = argsLength;
    vtPsa.parray = SafeArrayCreate(VT_BSTR, 1, argsBound);

    LONG idx[1];
    idx[0] = 0;

    SAFEARRAYBOUND paramsBound[1];
    paramsBound[0].lLbound = 0;
    paramsBound[0].cElements = 1;
    safeArrayArgs = SafeArrayCreate(VT_VARIANT, 1, paramsBound);
    SafeArrayPutElement(safeArrayArgs, idx, &vtPsa);
    ZeroMemory(&vtPsa, sizeof(VARIANT));
}

Everyone did the same solution, if no args create an empty SafeArrayVector:

psaStaticMethodArgs = SafeArrayCreateVector(VT_VARIANT, 0, 0);

However, for the life of me, this wouldnt work. But the above does. So, whatever, it works.

Running without args:

[+] Succeeded: CLRCreateInstance()
[+] Succeeded: pMetaHost->GetRuntime()
[+] Succeeded: pRuntimeInfo->IsLoadable()
[+] Succeeded: pRuntimeInfo->GetInterface()
[+] Succeeded: pRuntimeHost->Start()
[+] Succeeded: pRuntimeHost->GetDefaultDomain()
[+] Succeeded: pAppDomainThunk->QueryInterface()
[+] Succeeded: SafeArrayAccessData()
[+] Succeeded: SafeArrayUnaccessData()
[+] Succeeded: pDefaultAppDomain->Load_3()
[+] Succeeded: pAssembly->get_EntryPoint()

|----> Hello From .NET <----|
Arguments: 0

[+] Succeeded: pMethodInfo->Invoke_3() succeeded

And with args:

[+] Succeeded: CLRCreateInstance()
[+] Succeeded: pMetaHost->GetRuntime()
[+] Succeeded: pRuntimeInfo->IsLoadable()
[+] Succeeded: pRuntimeInfo->GetInterface()
[+] Succeeded: pRuntimeHost->Start()
[+] Succeeded: pRuntimeHost->GetDefaultDomain()
[+] Succeeded: pAppDomainThunk->QueryInterface()
[+] Succeeded: SafeArrayAccessData()
[+] Succeeded: SafeArrayUnaccessData()
[+] Succeeded: pDefaultAppDomain->Load_3()
[+] Succeeded: pAssembly->get_EntryPoint()

|----> Hello From .NET <----|
Arguments: 1
        |> Argument: args!

[+] Succeeded: pMethodInfo->Invoke_3() succeeded

Converting to a DLL

Because I'm not a psychopath and didnt fancy converting this whole process to a BOF, although its a badass project, I didnt fancy it. So, the next logical step is a DLL.

Cobalt Strike has two means of loading DLLs:

  1. DLL Load
  2. DLL Spawn

As bdllspawn exists, this is the natural step. Raffie is also kind enough to supply a template as to how the DLL should look:

BOOL WINAPI DllMain( HINSTANCE hinstDLL, DWORD dwReason, LPVOID lpReserved ) {
    BOOL bReturnValue = TRUE;
    switch( dwReason ) {
        case DLL_QUERY_HMODULE:
            if( lpReserved != NULL )
                *(HMODULE *)lpReserved = hAppInstance;
            break;
        case DLL_PROCESS_ATTACH:
            hAppInstance = hinstDLL;
    
            /* print some output to the operator */
            if (lpReserved != NULL) {
                printf("Hello from test.dll. Parameter is '%s'\n", (char *)lpReserved);
            }
            else {
                printf("Hello from test.dll. There is no parameter\n");
            }

            /* flush STDOUT */
            fflush(stdout);

            /* we're done, so let's exit */
            ExitProcess(0);
            break;
        case DLL_PROCESS_DETACH:
        case DLL_THREAD_ATTACH:
        case DLL_THREAD_DETACH:
            break;
    }
    return bReturnValue;
}

Without revealing too much of how I operate this at this time, I won't document how it put it all together, but there should be enough to figure the rest out. As another tease, the work from Tom Carver can be used to turn this into an in process execution method, with some changes. Finally, dealing with AMSI can be done in several ways at this point, as can ETW.

Conclusion

This blog post is nothing new, its me tying loose ends. The DLL itself is still private, but the EXE version can be found here.