background
In the previous post, we implemented a basic host program that could load up the CLR and execute an assembly. The issue with that approach is loading of the .NET assembly, we loaded the assembly from the disk and required passing of additional information like type and method name.
In this post we will take a look at a better implementation eliminating the need for the additional information to be passed from the user as mentioned before. This series of post expects the reader to have some foundational knowledge of Windows COM. I would suggest the readers to read Dale Rogerson\’s Inside COM and this post to understand the concept of COM and its internals.
Execute-Assemblyy
Game Plan
In our previous post, we executed the assembly from the disk and explicitly passed some data like assembly path, type information and method to invoke. We followed below steps to achieve it.
- Host program instantiating the CLR.
- Then loading the CLR into the host.
- And finally starting the CLR and executing the target assembly.
There is still room for improvement, to make it more flexible in terms of assembly loading and execution. In the post we will follow following steps to make host program that could dynamically fetch the assembly payload from memory and execute the assembly without providing much of the explicit information like target method to invoke and type information.
- We will follow through the same steps seen in previous post up until the starting of CLR.
- After the activation of the CLR, we will load the assembly stored as byte array in the memory into an application domain. This AppDomain can be either a newly user created domain or DefaultAppDomain. In this post we will use DefaultAppDomain.
- We will retrieve the entry point of the assembly and invoke the target method.
The target assembly used in this post is shown below, This program will simply capture the user arguments from the host program written in C++ and calls TargetMethod from Main by passing the user data to MessageBox. Compile this to an executable.
using System.Windows.Forms;
namespace AsmEx
{
class Program
{
public static int TargetMethod (string arguments)
{
MessageBox.Show(arguments);
return 0;
}
static void Main(string[] args)
{
string arg = args[0] + args[1];
TargetMethod(arg);
}
}
}
Implementation
Data processing
We need to perform some data processing mainly in two places :
- Right before assembly byte array is given to \”assembly load\” COM method exposed by _AppDomain interface.
- And right before executing \”invoke\” COM method exposed by _MethodBase interface to process the user supplied arguments meant for the target assembly
In COM we use SAFEARAY to toss the data around in methods exposed by an Interface and readers have to be familiar with a special structure called VARIANT type to fully grasp the idea and logic behind the code.
the breakdown
The assembly[] array holds the assembly payload in the memory and assembly_size holds the size of our assembly.
A quick Powershell-Fu to generate the shellcode of any PE file is shown below:
$Bytes = Get-Content \"PATH\\TO\\ASSEMBLY\" -Encoding Byte
$Bytes.Length
$HexString = [System.Text.StringBuilder]::new($Bytes.Length * 4)
ForEach($byte in $Bytes) { $HexString.AppendFormat(\"\\x{0:x2}\", $byte) | Out-Null }
$HexString.ToString() > assm_bytes.txt
A quick look at the instantiated Interfaces and other variables to hold the data.
SAFEARRAYs are used to store the assembly payload as shown in the image below. The AsmSafeArray holds the target assembly which will get passed into Load_3 method of _AppDomain Interface which loads the provided assembly into an application domain.
After initializing the AsmSafeArray, it is passed to Load_3 method. This will load the assembly in the DefaultAppDomain since we are not creating any new domain to host our assembly.
To understand more about the implementation and declaration of these weird looking methods, I would suggest the reader to refer to TLI and TLH of \”mscorlib.TLB\”. Below shows the declaration of Load_3 method _AppDomain Interface.
As load_3 method returns a reference to the loaded assembly, now we can use it to get the entrypoint method as shown in the image below using get_EntryPoint method exposed by the _Assembly Interface.
The implementation of the GetEntryPoint call is shown below. Using the pMethodInfo we can call the target method in the assembly via invoke_3 method in _MethodBase Interface.
We need to find a way to execute the assembly, before invoking the method, we need some processing to be performed on the user arguments fetched from the command line as shown below.
Above code does the following things:
- The line 163 to 169: vtPsa of VARIANT type will hold the user arguments in wide format in the parray member of type SAFEARRAY. It acts as an intermediate data holder to initialize the MethodArgs SAFEARRAY.
- MethodArgs of type SAFEARRAY will hold the user arguments for the method to be invoked in the target assembly.
- The line 171 to 176: The command line arguments are converted to wide and CommandLineToArgvW is called to properly parse the user supplied arguments given to host program over CLI.
- The line 179 to 182: The user arguments are written into vtPsa.parray member.
- On line 185 finally the user arguments are being written into MethodArgs safearray.
Now we have all information needed in order to call Invoke_3 method as shown below. This will execute the Main function in our assembly and passes the user argument and calls our target method.
The implementation of Invoke_3 method in _MethodBase Interface.
If any COM api fails, cleanup procedure is called to release the allocated resources. The implementation of the cleanup function is shown below.
Result
We have successfully loaded an assembly from memory and passed user supplied data to the target method for execution. We achieved all of this without explicitly providing any additional information like type or even method name like the host we saw in the previous post.
single argument : host_program.exe Arg1
Multiple arguments : host_program.exe \"Arg1 Arg2..\"
The host program code is shown below:
#include <metahost.h>
#include <windows.h>
#include <string>
#include <shellapi.h>
#pragma comment(lib, \"mscoree.lib\")
#import <mscorlib.tlb> raw_interfaces_only \\
high_property_prefixes(\"_get\",\"_put\",\"_putref\") \\
rename(\"ReportEvent\", \"InteropServices_ReportEvent\") \\
rename(\"or\", \"InteropServices_or\")
using namespace mscorlib;
using namespace std;
unsigned char assembly[] =\"\";
unsigned int assembly_size = 0;
void cleanup(SAFEARRAY* pSafeArray, ICorRuntimeHost* pCorRuntimeHost, ICLRRuntimeInfo* pCLRRuntimeInfo, ICLRMetaHost* pCLRMetaHost)
{
if (pCLRMetaHost)
{
pCLRMetaHost->Release();
pCLRMetaHost = NULL;
}
if (pCLRRuntimeInfo)
{
pCLRRuntimeInfo->Release();
pCLRRuntimeInfo = NULL;
}
if (pSafeArray)
{
SafeArrayDestroy(pSafeArray);
pSafeArray = NULL;
}
if (pCorRuntimeHost) {
pCorRuntimeHost->Stop();
pCorRuntimeHost->Release();
}
}
int main(int argc, char* argv[])
{
ICLRRuntimeInfo* pCLRRuntimeInfo = NULL;
ICorRuntimeHost* pCorRuntimeHost = NULL;
ICLRMetaHost* pCLRMetaHost = NULL;
_MethodInfoPtr pMethodInfo = NULL;
_AssemblyPtr spAssembly = NULL;
IUnknownPtr spAppDomainThunk = NULL;
_AppDomainPtr spDefaultAppDomain = NULL;
VARIANT retVal, obj, vtPsa;
BOOL isLoadable = FALSE;
HRESULT hr;
PVOID pvData = NULL;
string user_args = argv[1];
SAFEARRAY* MethodArgs = NULL;
hr = CLRCreateInstance(CLSID_CLRMetaHost, IID_PPV_ARGS(&pCLRMetaHost));
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]Failed to create CLR instance\");
return -1;
}
printf(\"[+]CLR Created!\");
hr = pCLRMetaHost->GetRuntime(L\"v4.0.30319\", IID_PPV_ARGS(&pCLRRuntimeInfo));
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]Failed to fetch runtime info\");
return -1;
}
printf(\"\\n[+]RuntimeInfo fetched!\");
hr = pCLRRuntimeInfo->IsLoadable(&isLoadable);
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]IsLoadable check: Failed\");
return -1;
}
printf(\"\\n[+]IsLoadable check: Passed\");
hr = pCLRRuntimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_PPV_ARGS(&pCorRuntimeHost));
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]Failed to load CLR into current process\");
return -1;
}
printf(\"\\n[+]CLR loaded into current process\");
hr = pCorRuntimeHost->Start();
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]Failed to start CLR\");
return -1;
}
printf(\"\\n[+]CLR started\");
hr = pCorRuntimeHost->GetDefaultDomain(&spAppDomainThunk);
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]Failed to get AppDomain Interface Ptr\");
return -1;
}
printf(\"\\n[+]Got the AppDomain Interface Ptr\");
hr = spAppDomainThunk->QueryInterface(IID_PPV_ARGS(&spDefaultAppDomain));
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]Failed to get DefaultAppDomain Interface\");
return -1;
}
printf(\"\\n[+]Got the DefaultAppDomain Interface\");
SAFEARRAYBOUND rgsabound[1] = {};
rgsabound[0].lLbound = 0;
rgsabound[0].cElements = assembly_size;
SAFEARRAY* AsmSafeArray = SafeArrayCreate(VT_UI1, 1, rgsabound);
hr = SafeArrayAccessData(AsmSafeArray, &pvData);
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]Failed to lock SAFEARRAY \");
return -1;
}
printf(\"\\n[+]Locked SAFEARRAY | Copying Assembly\");
memcpy(pvData, assembly, assembly_size);
hr = SafeArrayUnaccessData(AsmSafeArray);
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"\\n[-]Copy Failed \");
return -1;
}
printf(\"\\n[+]Copy Success\");
hr = spDefaultAppDomain->Load_3(AsmSafeArray, &spAssembly);
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"\\n[-]Failed: Assembly load in DefaultDomain \");
return -1;
}
printf(\"\\n[+]Success: Assembly load in DefaultDomain\");
hr = spAssembly->get_EntryPoint(&pMethodInfo);
if (FAILED(hr))
{
cleanup(MethodArgs, pCorRuntimeHost, pCLRRuntimeInfo, pCLRMetaHost);
printf(\"[-]Failed to get entrypoint \");
return -1;
}
printf(\"\\n[+]Entrypoint fetched\");
vtPsa.vt = VT_ARRAY | VT_BSTR;
SAFEARRAYBOUND argsBound[1];
argsBound[0].lLbound = 0;
size_t argsLength = user_args.size();
argsBound[0].cElements = argsLength;
vtPsa.parray = SafeArrayCreate(VT_BSTR, 1, argsBound);
MethodArgs = SafeArrayCreateVector(VT_VARIANT, 0, 1);
LPWSTR* szArglist;
int nArgs;
wchar_t* userargsW = (wchar_t*)malloc(sizeof(wchar_t) * user_args.size() + 1);
mbstowcs(userargsW, (char*)user_args.data(), user_args.size() + 1);
szArglist = CommandLineToArgvW(userargsW, &nArgs);
for (long i = 0; i < nArgs; i++)
{
SafeArrayPutElement(vtPsa.parray, &i, SysAllocString(szArglist[i]));
}
long idx = 0;
SafeArrayPutElement(MethodArgs,&idx,&vtPsa);
ZeroMemory(&retVal, sizeof(VARIANT));
ZeroMemory(&obj, sizeof(VARIANT));
obj.vt = VT_NULL;
hr = pMethodInfo->Invoke_3(obj, MethodArgs, &retVal);
if (hr != S_OK)
{
_com_error err(hr);
LPCTSTR errMsg = err.ErrorMessage();
wprintf(errMsg);
printf(\"\\nFailed to execute assembly\");
}
printf(\"\\n[+]Method Invoked\");
}
conclusion
In the next post we will analyze the implementation of InlineExecute-Assembly feature of Havoc C2 framework
reference
https://github.com/mez-0/InMemoryNET/blob/main/InMemoryNET/InMemoryNET/CLR.hpp
https://github.com/HavocFramework/Havoc/blob/31db84b432d57d7f5d234791455b18260f00cd40/Teamserver/data/implants/Demon/Source/Core/Command.c
https://gist.github.com/Arno0x/386ebfebd78ee4f0cbbbb2a7c4405f74#file-loaddotnetassemblyfrommemory-cpp
Leave a Reply