STUBborn: Activate and call DCOM objects without proxy

In the last years, the Local RPC (LRPC) & ALPC have been the subject of scrutiny by some Windows internal enthusiasts and vulnerability researchers.

In this article, we will go a step further to explore what can be done about LocalServer DCOM objects, how to instantiate them and directly connect to their interfaces without using the COM proxy clients normally rely on!

This will give us an excuse to explore some COM internals, understand part of the combase DLL & write more fun python code !

1 The Goal

In this article, our goal will be to interact directly with a DCOM object in a remote process of the local computer by using a custom RPC client.

Where does this challenge come from? When working on our Windows forensic collect agent (french link)1 I sometimes encounter new forensics artifacts or information that may be a challenge to retrieve. In this case, I use it as an excuse to dive in some Windows Internals and explore new things.

I once encountered a COM server able to give me some sweet forensics information and whose proxy was only available as a 64-bit DLL, my goal at this time was to be able to query a specific method of a COM interface from a 32-bit process. Our forensic agent being a unique 32-bit process that crosses the heaven gate when needed.

Without the proxy DLL, COM is missing something

The final goal being to use it in production, I added another requirement: to let the real COM machinery do the works when possible to limit the changes required to handle Windows version from Windows Server 2003 to Windows 11.

This article relies on some basic knowledge of COM2; understanding the concepts of IID, CLSID, CoCreateInstance, IUnknown and IUnknown::QueryInterface would enhance comprehension. As always, I can only recommend having a look at James Forshaw’s COM in Sixty Seconds!3 presentation (or at least the first half).

1.1 Setup

1.1.1 IExaDemo

To replicate this need, we will use a toy COM LocalServer that I implemented and open-sourced here: https://github.com/ExaTrack/COM_IExaDemo. The complete information about compiling and installing both binaries (IExaDemo_server_64.exe & IExaDemo_proxy_64.dll) is in the repos README.md.

In our case we will compile both the COM server and proxy DLL for 64-bit and try to access it from a 32-bit Python interpreter.

The main interface we want to interact with is IExaDemo whose IDL is given here:

[
  uuid (45786100-1111-2222-3333-445500000001),
  version(1.0),
] interface IExaDemo : IUnknown
{
    HRESULT add(
        [in] unsigned int x,
        [in] unsigned int y,
        [out] unsigned int *res
    );

    HRESULT print(
        [in][string] wchar_t* msg);

};
// IDL description of the server CLSID
[
    uuid(45786100-4343-4343-4343-434343434343),
    version(1.0)
]
coclass ExaDemoSrv
{
    interface IExaDemo;
}

So, without the proxy DLL, we will try to add two numbers and get the result back. If you are curious about the add() implementation, the code is here.

In our setup, the CLSID of our server is 45786100-4343-4343-4343-434343434343 and the IID we want to interact with is 45786100-1111-2222-3333-445500000001 aka IID_IExaDemo.

1.1.2 PythonForWindows

For this project, I will exclusively use Python3.11 and PythonForWindows, a library of my creation that I use for my works on Windows. Most of my discoveries have already been committed to the project, so some explanations and code will directly reference it.

A simple python client, using the proxy DLL and the full COM machinery is available in the COM_IExaDemo repos as client.py.

The important bits are explained here:

import ctypes

import windows.com
import windows.generated_def as gdef


# General PFW definition for the COM class, normally generated from the IDL c output
class IExaDemo(gdef.COMInterface):
    IID = gdef.generate_IID(0x45786100, 0x1111, 0x2222, 0x33, 0x33, 0x44, 0x55, 0x00, 0x00, 0x00, 0x01, name="IExaDemo",
                            strid="45786100-1111-2222-3333-445500000001")
    [ctypes interface definition]


EXA_DEMO_SRV_CLSID = "45786100-4343-4343-4343-434343434343"

windows.com.init()  # CoInitialize

iunk = gdef.IUnknown()
print("CreateInstance {0}".format(EXA_DEMO_SRV_CLSID))
# Wrapper around CoCreateInstance which is more permissiv with GUID & strings
windows.com.create_instance(EXA_DEMO_SRV_CLSID, iunk, gdef.IUnknown.IID)
print("    OK: Got an IUnknown: {0}".format(iunk))

print("QueryInterface: IExaDemo.IID: {0}".format(IExaDemo.IID))
# A wrapper around IUnknown.QueryInterface that can be read as
#   iunk.QueryInterface(IExaDemo.IID, IExaDemo())
iexademo = iunk.query(IExaDemo)
print("    OK: {0}".format(iexademo))
print("Adding stuff:")
result = gdef.DWORD()
iexademo.add(0x41414141, 0x01010101, result)
print("    iexademo.add(0x41414141, 0x01010101) == {0:#x}".format(result.value))

The output of this script would be

PS> py -3.11 .\client.py
CreateInstance 45786100-4343-4343-4343-434343434343
    OK: Got an IUnknown: <IUnknown at 0x1dc36053750>
QueryInterface: IExaDemo.IID: 45786100-1111-2222-3333-445500000001
    OK: <IExaDemo at 0x1dc360539d0>
Adding stuff:
    iexademo.add(0x41414141, 0x01010101) == 0x42424242

Whereas the ExaDemo_server_64.exe console output should end with:

CALL:IExaDemoImplem_QueryInterface
[IExaDemoImplem_QueryInterface]  * riid: {45786100-1111-2222-3333-445500000001} !
[IExaDemoImplem_QueryInterface]  * Asking for IID_IExaDemo: I CAN DO THAT !
[...]
CALL:IExaDemoImplem_add
[IExaDemoImplem_add] 0x41414141 + 0x1010101 = 0x42424242

1.2 The problem

First, let’s take a look at how a typical COM client behaves when a proxy DLL is missing. To do so, we will execute client.py with a 32-bit python, bitness in which the proxy DLL is not installed.

PS> py -3.11-32 .\client.py
CreateInstance 45786100-4343-4343-4343-434343434343
    OK: Got an IUnknown: <IUnknown at 0x1b12440>
QueryInterface: IExaDemo.IID: 45786100-1111-2222-3333-445500000001
Traceback (most recent call last):
  File "COM_IExaDemo\client.py", line 34, in <module>
    iexademo = iunk.query(IExaDemo)
               ^^^^^^^^^^^^^^^^^^^^
  File "pythonforwindows\windows\generated_def\interfaces.py", line 62, in query
    self.QueryInterface(interface_iid, target)
  File "pythonforwindows\windows\generated_def\interfaces.py", line 28, in _default_errcheck
    ctypes._check_HRESULT(result)
OSError: [WinError -2147221164] Class not registered

The client fails, and what’s more interesting is that the fail does not occur at the instance creation of the IUnknown but only when we query for an interface handled by our proxy: IID_IExaDemo.

In WinDBG we can see that the error occurs in the following stack:

0:000> k
 # ChildEBP RetAddr
...
02 (Inline) --------     combase!CStdMarshal::GetPSFactory+0xa9 [onecore\com\combase\dcomrem\marshal.cxx @ 6664]
03 00beecd8 772f369b     combase!CStdMarshal::CreateProxy+0x1da [onecore\com\combase\dcomrem\marshal.cxx @ 6745]
...
09 (Inline) --------     combase!CStdMarshal::QueryRemoteInterfaces+0x71 [onecore\com\combase\dcomrem\marshal.cxx @ 5759]
0b 00beeef8 73834018     combase!CStdIdentity::CInternalUnk::QueryInterface+0x1f0 [onecore\com\combase\dcomrem\stdid.cxx @ 428]

This demonstrates that our proxy is actually searched for and loaded when querying a specific interface, rather than at instantiation time.

It means that we may be able to delegate some of the heavy lifting to the COM runtime. What we can do is let COM perform the instance creation for us, and then connect to the COM server and its created object.

With the setup done, and the problem defined, let’s begin our exploration.

2 STUBBorn

2.1 State of the art

At this time, a lot have been already done about RPC, ORPC & DCOM.

The first place to look for information is the MS-DCOM open specification4 which is a BIG document with a lot of very precise information about DCOM.

I can also cite this Airbus article5 as a reliable source of information about the OXIDResolver from a network point of view. This gave us a preview of what to expect.

Next, I obviously need to cite the various works of James Forshaw. I have in mind this article about Relaying DCOM Authentication6 and the andbox-attacksurface-analysis-tools GitHub repos7. This gives us more information about what to expect in terms of OBJREF and interface marshaling.

Of course, it’s hard to make any Windows Internal’s state of the art without citing Alex Ionescu, this time for the hazmat5 GitHub repos8 which provides various information about the RPC interfaces offered by RPCSS and the ILocalObjectExporter.

Lastly, I can cite my previous work on RPC&ALPC9 and the RPC client integrated in PythonForWindows10 that was improved for this research.

2.2 What are we looking for ?

Based on the state of the art, we can already establish a shopping list of what we need to find in order to manually interact with the remote IExaDemo and IExaDemo_server_64.exe.

We need to find an RPC endpoint in IExaDemo_server_64.exe, based on previous research10 we can assess that the endpoint will be an ALPC Port. We could list the handles of the process, but that would be cheating 😁. We need to upgrade our knowledge of RPC to ORPC (Object Remote Procedure Call). The Microsoft documentation about ORPC Calls11 also tells us we will need an IPID 12 that represents a given interface for a remote object.

Shopping List:

2.3 What we won’t do

The first step of my research was to analyze the ALPC-RPC interactions of a working client and the various ALPC-RPC endpoints it interacts with during instantiation, interface queries & ORPC calls.

By analyzing the calls to NtAlpcCreatePort and NtAlpcSendWaitReceive with a custom debugger, the following image can be drawn:

[ALPC-CONNECT] <\RPC Control\epmapper>
[RPC-BIND]: \RPC Control\epmapper (<RPC_IF_ID "E60C73E6-88F9-11CF-9AF1-0020AF6E72F4" (2, 0)>) (name=ILocalObjectExporter)
...
[RPC-CALL]: \RPC Control\epmapper (<RPC_IF_ID "E60C73E6-88F9-11CF-9AF1-0020AF6E72F4" (2, 0)>) (method 0) (name=Connect)
...
[RPC-CALL]: \RPC Control\epmapper (<RPC_IF_ID "E60C73E6-88F9-11CF-9AF1-0020AF6E72F4" (2, 0)>) (method 6) (name=ServerAllocateOXIDAndOIDs)
[RPC-BIND]: \RPC Control\epmapper (<RPC_IF_ID "00000136-0000-0000-C000-000000000046" (0, 0)>) (name=ISCMLocalActivator)
[RPC-CALL]: \RPC Control\epmapper (<RPC_IF_ID "00000136-0000-0000-C000-000000000046" (0, 0)>) (method 4) (name=CreateInstance)
[ALPC-CONNECT] <\RPC Control\OLE1C6126F89B815E927A6AF617D45E>
[RPC-BIND]: \RPC Control\OLE1C6126F89B815E927A6AF617D45E (<RPC_IF_ID "00000134-0000-0000-C000-000000000046" (0, 0)>) (name=)

On this information only, we can confirm:

All this, give us exactly what we WON'T DO :

Although this would be a promising research angle and would likely work at the end, I have multiple reasons to approach things differently. First of all, the prototype depicted by hazmat514 for ILocalObjectExporter.Connect shows that the crafting of the [in] parameters and the parsing of [out] it would be tedious. The parameters let us also guess that this interface has most likely evolved multiple times since Windows XP.

This last point is of importance, because my final goal is to be able to use STUBborn in our forensic collect agent (french link)1 to target some DCOM endpoints with some valuable forensic information. So I want to have a code that will work across the widest range of Windows versions with minimal modifications. Thus this rejoin our previously stated goal of letting the COM runtime do the heavy lifting of object creation for us.

Lastly, this is a good reason to explore the inner workings of combase.dll!

2.4 Combase.dll

combase.dll is the DLL implementing a big part of the COM logic and whose content and capabilities are quite interesting to explore. As far as I know, this DLL is loaded in any COM client or server, especially due to the fact that it implements important functions such as CoCreateInstance or CoRegisterClassObject. In older version of Windows, this logic could be found in ole32.dll.

2.4.1 gInternalClassObjects

A well-known behavior of COM is that the COM servers are registered in the registry and that CLSID are looked up in HKEY_CLASSES_ROOT\CLSID for the server path. A less well-known behavior is that combase.dll implements some class objects as hardcoded CLSID that bypass the registry and are directly implemented in combase.

This is represented as an array of (CLSID, CreateInstance_Method, Flags) which can be found in combase!gInternalClassObjects:

CLSID CreateInstance function
combase!CLSID_StdEvent combase!CStdEventCF_CreateInstance
combase!CLSID_ManualResetEvent combase!CManualResetEventCF_CreateInstance
combase!CLSID_SynchronizeContainer combase!CSynchronizeContainerCF_CreateInstance
combase!CLSID_StdGlobalInterfaceTable combase!CGIPTableCF_CreateInstance
combase!CLSID_DCOMAccessControl combase!CAccessControlCF_CreateInstance
combase!CLSID_ErrorObject combase!CErrorObjectCF_CreateInstance
combase!GUID_00000346_0000_0000_c000_000000000046 combase!CComCatalogCF_CreateInstance
combase!CLSID_RpcHelper combase!CRpcHelperCF_CreateInstance
combase!CLSID_ObjectContext combase!CObjectContextCF_CreateInstance
combase!CLSID_InProcFreeMarshaler combase!CFreeThreadedMarshalerCF_CreateInstance
combase!CLSID_ActivationPropertiesIn combase!CActivationPropertiesInCF_CreateInstance
combase!CLSID_ActivationPropertiesOut combase!CActivationPropertiesOutCF_CreateInstance
combase!CLSID_InprocActpropsUnmarshaller combase!CInprocActpropsUnmarshallerCF_CreateInstance
combase!CLSID_ComActivator combase!CComActivatorCF_CreateInstance
combase!CLSID_AddrControl combase!CAddrControlCF_CreateInstance
combase!CLSID_LocalMachineNames combase!CLocalMachineNamesCF_CreateInstance
combase!CLSID_GlobalOptions combase!CGlobalOptionsCF_CreateInstance
combase!CLSID_ContextSwitcher combase!CContextSwitcherCF_CreateInstance
combase!CLSID_RestrictedErrorObject combase!CRestrictedErrorObjectCF_CreateInstance
combase!CLSID_RegisterSuspendNotify combase!CSuspendMonitorCF_CreateInstance
combase!CLSID_MachineGlobalObjectTable combase!CMgotCF_CreateInstance
combase!CLSID_ActivationCapabilities combase!CActivationCapabilitiesCF_CreateInstance

This gives us a list of CLSID to explore, instantiate and test. Some of these are already used and known, for example, the CLSID_ComActivator1718 that is used under the hood of combase!CoCreateInstance.

The question is, which class do we want to explore for our objective? By exploring around object creation and ALPC transmission of data over ntdll!NtAlpcSendWaitReceivePort we can encounter the following type of call stack:

:000> kc
 # Call Site
00 ntdll!NtAlpcSendWaitReceivePort
[...]
07 combase!ServerAllocateOXIDAndOIDs
08 combase!CRpcResolver::ServerRegisterOXID
[...]
11 combase!CRpcResolver::CreateInstance
12 combase!CClientContextActivator::CreateInstance
13 combase!ActivationPropertiesIn::DelegateCreateInstance
14 combase!ICoCreateInstanceEx
15 combase!CComActivator::DoCreateInstance
16 combase!CoCreateInstanceEx
17 combase!CoCreateInstance

The object ActivationPropertiesIn, present under the known CComActivator, seems to participate to the instance creation and is accessible via the gInternalClassObjects thus seems a very good candidate for further exploration.

Another good indicator is the MS-DCOM IRemoteSCMActivator:: RemoteGetClassObject page19 that indicates the function takes an ActivationPropertiesIn as a parameter. Promising!

2.4.2 ActivationPropertiesIn

The CLSID_ActivationPropertiesIn indeed allow us to instantiate an object in combase that present (at least) the following interfaces:

Each interface allows to initialize some important part of the ActivationPropertiesIn structure to trigger a custom CreateInstance.

This step represents the minimal code identified to have a successful CoCreateInstance emulation.

The IComClassInfo that need to be passed to IInitActivationPropertiesIn.SetClassInfo can be obtained via two methods:

The question that may be asked is: what is the advantage of using a ActivationPropertiesIn for instance creation compared to CoCreateInstance? The response is the return value, this method returns an ActivationPropertiesOut which is a trove of information!

2.4.3 ActivationPropertiesOut

The object returned by IPrivActivationPropertiesIn.DelegateCreateInstance() is an ActivationPropertiesOut which present (at least) the following interfaces:

The first interesting function is IScmReplyInfo.GetResolverInfo() that allows us to retrieve a structure containing information about the remote COM server in a PRIV_RESOLVER_INFO.

2.4.3.1 PRIV_RESOLVER_INFO

As far as I know, this structure is the only thing that changed in different Windows version. The old version PRIV_RESOLVER_INFO can be traced back from Windows XP up to Windows Server 2016 (at least on some install ISO). The new version of PRIV_RESOLVER_INFO has been found on my test computer (10.0.22631.4317) as well as on an ISO install of Windows Server 2019.

If someone finds the exact update/version in which the definition change, I would be glad to get the information! For the rest of the article, I will use the latest version.

This structure contains a lot of useful information to accomplish our task, let’s have a look at what IScmReplyInfo.GetResolverInfo() returns us.

>>> resolver_info
<windows.generated_def.winstructs._PRIV_RESOLVER_INFO object at 0x000001F2BA31AFD0>
>>> windows.utils.sprint(resolver_info)
struct.OxidServer -> 0x47b7459170ebf6dd
struct.pServerORBindings -> NULL
struct.OxidInfo.dwTid -> 0x2f94
struct.OxidInfo.dwPid -> 0x56fc
struct.OxidInfo.dwAuthnHint -> 0x5
struct.OxidInfo.dcomVersion.MajorVersion -> 0x5
struct.OxidInfo.dcomVersion.MinorVersion -> 0x7
struct.OxidInfo.containerVersion.version -> 0x3
struct.OxidInfo.containerVersion.capabilityFlags -> 0x0
struct.OxidInfo.containerVersion.extensions -> NULL
struct.OxidInfo.ipidRemUnknown -> <GUID "00009400-56FC-2F94-4A97-D41AAB84F489">
struct.OxidInfo.dwFlags -> 0x4000000
struct.OxidInfo.psa<deref>.wNumEntries -> 0x3d
struct.OxidInfo.psa<deref>.wSecurityOffset -> 0x2b
struct.OxidInfo.psa<deref>.aStringArray -> <windows.generated_def.winstructs.c_ushort_Array_1 object at 0x000002215903F050>
struct.OxidInfo.guidProcessIdentifier -> <GUID "36A580E5-366E-4E9D-81E7-3FAC865D240A">
struct.OxidInfo.processHostId -> 0x0
struct.OxidInfo.clientDependencyBehavior -> 0x0
struct.OxidInfo.packageFullName -> NULL
struct.OxidInfo.userSid -> NULL
struct.OxidInfo.appcontainerSid -> NULL
struct.OxidInfo.primaryOxid -> 0xc81a618f9842fdf3
struct.OxidInfo.primaryIpidRemUnknown -> <GUID "00004C01-56FC-FFFF-2994-EED6240E08FE">
struct.LocalMidOfRemote -> 0x57d71877bf3b4fe2
struct.FoundInROT -> 0x0

The main information is priv_resolver_info.OxidInfo.psa which is a pointer to a tagDUALSTRINGARRAY20 and necessitate some parsing to interpret.

>>> resolver_info.OxidInfo.psa[0].bidings
['ncalrpc:[OLE85D5A9520394209954DA2E2595EF]']

This gives us the ALPC Port exposed by our COM server IExaDemo_server_64.exe! The real path of the port can be inferred as \RPC Control\OLE85D5A9520394209954DA2E2595EF.

With this information we can directly connect to it via ALPC and start direct interaction:

>>> client = windows.rpc.RPCClient(r"\RPC Control\OLE85D5A9520394209954DA2E2595EF")
>>> iid = client.bind("45786100-1111-2222-3333-445500000001", (0, 0))
>>> client.call(iid, 0, b"Hello !")
Traceback (most recent call last):
[...]
ValueError: RPC Response error 2147549457 (RPC_E_INVALID_HEADER(0x80010111))

One step done, let’s update the shopping list and continue to try to understand how to make direct call!

Shopping List:

2.4.3.2 IPrivActivationPropertiesOut

The other interesting interface offered by our ActivationPropertiesOut is IPrivActivationPropertiesOut. More precisely the IPrivActivationPropertiesOut.GetMarshalledResults() method. This method allows to retrieve the interfaces pointers instantiated during IPrivActivationPropertiesIn.DelegateCreateInstance() as MInterfacePointer21.

The documentation tells us that MInterfacePointer21 are just a way to described a varying size array containing an OBJREF22, probably for NDR encoding. OBJREF22 are a well-known structure for people working on COM/DCOM and have been used and described by James Forshaw in Windows Exploitation Tricks: Relaying DCOM Authentication6 for example.

But what contains our OBJREF in our case?

# Call to IPrivActivationPropertiesOut.GetMarshalledResults
>>> propout_as_priv.GetMarshalledResults(nb_interface, iids, results, interfaces)
0
# Explore the interface
>>> interfaces[0].contents.objref.flags
1
>>> interfaces[0].contents.objref.flags == gdef.OBJREF_STANDARD
True
>>> interfaces[0].contents.objref.std
<windows.generated_def.winstructs.tagSTDOBJREF object at 0x000002B3C0723E50>
>>> windows.utils.sprint(interfaces[0].contents.objref.std)
struct.flags -> 0x0
struct.cPublicRefs -> 0x5
struct.oxid -> 0x6a74afc5319c3f21
struct.oid -> 0x9883dc2f214bf1a4
struct.ipid -> <GUID "00001006-4A14-4DF4-173F-4799F71C9689">

What we retrieve is an IPID 12 for our interface. It represents the interface in the remote COM server and is used by the client to make ORPC Calls11. This is the missing piece that will allow us to perform a direct call to our object.

Shopping List:

2.5 LORPC

Armed with the following elements, we have most of the tools and information needed to directly talk to IExaDemo_server_64.exe:

Most of the information needed to transform an RPC client to a ORPC one is found in the MS-DCOM: ORPC Calls11 & MS-DCOM: ORPC Invocations23 pages.

As seen before, we can connect to the ALPC Port and bind to the IID we want like a normal Local RPC call.

The only not documented things to add are:

The improvements made to PythonForWindows LRPC client can be found here.

Note that the layout of combase!_LOCALTHIS is also subject to multiple changes accross versions. Currently only the last version of the structure is present in PythonForWindows

Based on that we can write the following code to ask our IExaDemo to add 0x41414141 + 0x01010101:

>>> client = windows.rpc.RPCClient(target_alpc_server)
>>> iid = client.bind("45786100-1111-2222-3333-445500000001", (0, 0))
>>> ipid = interfaces[0][0].objref.std.ipid
>>> data = client.call(iid, 3, b"\x41\x41\x41\x41\x01\x01\x01\x01", ipid=ipid)
>>> data
b'\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00BBBB\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

The call worked, and we can see we are near the end of the tunnel as the result 0x42424242 AKA BBBB can be seen in the response buffer. In the meantime, IExaDemo_server_64.exe printed [IExaDemoImplem_add] 0x41414141 + 0x1010101 = 0x42424242 which is also a good indicator.

Just like the ORPC Calls, the response contains a ORPCTHAT and LOCALTHAT, some light parsing and we have our result:

>>> addstream = ndr.NdrStream(addrep)
>>> orpcthat = gdef.ORPCTHAT32.from_buffer_copy(bytearray(addstream.read(ctypes.sizeof(gdef.ORPCTHAT32))))
>>> localthat = gdef.LOCALTHAT32.from_buffer_copy(bytearray(addstream.read(ctypes.sizeof(gdef.LOCALTHAT32))))
>>> result = addstream.partial_unpack("<I")[0]
>>> print(hex(result))
0x42424242
>>> hex(result)
'0x42424242'

2.6 STUBborn_client.py

The IExaDemo repository include a full demonstration of this technique in stubborn_client.py24

The code performs the following actions:

With our goal in mind, we can also check that our 32-bit python (where the proxy DLL is not registered) can successfully call our interface.

# Real COM client.py still does not work :(

PS COM_IExaDemo> py -3.11-32 .\client.py
CreateInstance 45786100-4343-4343-4343-434343434343
    OK: Got an IUnknown: <IUnknown at 0x20d2440>
QueryInterface: IExaDemo.IID: 45786100-1111-2222-3333-445500000001
Traceback (most recent call last):
  File "client.py", line 34, in <module>
    iexademo = iunk.query(IExaDemo)
               ^^^^^^^^^^^^^^^^^^^^
  File "pythonforwindows\windows\generated_def\interfaces.py", line 62, in query
    self.QueryInterface(interface_iid, target)
  File "rojets\pythonforwindows\windows\generated_def\interfaces.py", line 28, in _default_errcheck
    ctypes._check_HRESULT(result)
OSError: [WinError -2147221164] Class not registered

# Stubborn client successfuly makes the call!

PS COM_IExaDemo> py -3.11-32 .\stubborn_client.py
Addition is OK : 0x42424242 !
Proxy DLL was never loaded !

With this, we have everything we need to target random DCOM servers with a custom ORPC Client!

3 Going further?

To complete the “COM tour”, I also explored some more well-known mechanism with this new method.

3.1 GetClassObject

If you want to interact with the IClassFactory.GetClassObject() method and manually implement CreateInstance25, the IPrivActivationPropertiesIn also implements DelegateGetClassObject(). In this case you can ask for an IID_IClassFactory and retrieve the ALPC Endpoint & IPID as done previously.

You can use then use the ORPC client to call IClassFactory.CreateInstance() manually and parse the results. Contrary to the real COM function, the remote version of IClassFactory.CreateInstance() only takes an IID as parameters.

The response will contain a MInterfacePointer with your newly created object!

remfactory = client.bind(gdef.IClassFactory.IID, (0, 0))
# 3 -> CreateInstance
params = bytearray(IID_IExaDemo)  # The remote version of CreateInstance() only take an IID
createi_resp = client.call(remfactory, 3, params, ipid=ifactory_objref.std.ipid)
createi_stream = ndr.NdrStream(createi_resp)
xorpcthat = gdef.ORPCTHAT32.from_buffer_copy(createi_stream.read(ctypes.sizeof(gdef.ORPCTHAT32)))
xlocalthat = gdef.LOCALTHAT32.from_buffer_copy(createi_stream.read(ctypes.sizeof(gdef.LOCALTHAT32)))
createi_stream.read(4)  # Manual NDR Parsing: UNIQU
createi_stream.read(4)  # Manual NDR Parsing: Conformant size
tmpbuffer = ctypes.create_string_buffer(createi_stream.data)
mifptr = gdef.MInterfacePointer.from_buffer(tmpbuffer)  # Parse tmpbuffer as a MInterfacePointer and extract the objref
obj_ipid = mifptr.objref.std.ipid

3.2 RemQueryInterface

You can also make a call to QueryInterface on remote interface. For this, the normal ORPC call with a method 0 cannot be used.

The IRemUnknown26 interface and RemQueryInterface 27 function must be used:

Examples with very bad NDR parsing code:

# iunk_ipid is a IPID on a IUnknown of our ExaDemoSrv Object.
remunk = client.bind(gdef.IRemUnknown.IID, (0, 0))

target_id = target_id = IID_IExaDemo
params = bytearray(iunk_ipid)[:] + struct.pack("<I", 12) + struct.pack("<I", 1) + struct.pack("<I", 1) + bytearray(target_id)
rem_queryi_response = client.call(remunk, 3, params, ipid=ipidRemUnknown)  # Works !

# Parse reponse to RemoteQueryInterface
stream = ndr.NdrStream(rem_queryi_response)
orpcthat = gdef.ORPCTHAT32.from_buffer_copy(stream.read(ctypes.sizeof(gdef.ORPCTHAT32)))
localthat = gdef.LOCALTHAT32.from_buffer_copy(stream.read(ctypes.sizeof(gdef.LOCALTHAT32)))
sream = stream.read(8)
assert sream == b"\x00\x00\x02\x00\x01\x00\x00\x00", repr(sream)
reminterface = gdef.REMQIRESULT.from_buffer_copy(stream.data)
new_ipid = reminterface.std.ipid

This code also shows some nice potential for further investigations, as a call client.call(remunk, 3, params, ipid=ipid) with another IPID (like iunk_ipid) will crash the server with a NULL DEREF ;)

4 Conclusion

By exploring combase.dll, understanding the internal COM classes it exposes and playing with them we found a way to access some information COM normally abstract and try to hide from us. With this information and the MS-DCOM open specification4 we were able to instantiate a COM object and interact with remote COM interface while bypassing parts of the COM runtime logic.

This code has been tested on a Windows 10.0.22631.4317, a POC have been adapted on Windows 7 (6.1.7601.0) by using _PRIV_RESOLVER_INFO_LEGACY although changes in the layout of the RPC-CALL buffer (LOCALTHIS) were also necessary.

This technique and the capabilities it offers may open new avenues in the exploration of Windows Internals to better understand things, develop new tools and find fun bugs.

For ExaTrack, it will allow us to improve our forensic collection agent and give us more chance to catch the bad guys on Windows ;)

I will gladly accept any feedback you have on the article and the code shared in https://github.com/ExaTrack/COM_IExaDemo and https://github.com/hakril/PythonForWindows. If you have any question or remarks, feel free to contact us !

Footnotes & references


  1. https://exatrack.com/recherche_compromission.html ↩︎ ↩︎

  2. https://learn.microsoft.com/en-us/windows/win32/com/com-technical-overview ↩︎

  3. https://www.youtube.com/watch?v=dfMuzAZRGm4 ↩︎

  4. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/4a893f3d-bd29-48cd-9f43-d9777a4415b0 ↩︎ ↩︎ ↩︎

  5. https://www.cyber.airbus.com/the-oxid-resolver-part-1-remote-enumeration-of-network-interfaces-without-any-authentication/ ↩︎

  6. https://googleprojectzero.blogspot.com/2021/10/windows-exploitation-tricks-relaying.html ↩︎ ↩︎

  7. https://github.com/googleprojectzero/sandbox-attacksurface-analysis-tools ↩︎

  8. https://github.com/ionescu007/hazmat5/tree/main ↩︎

  9. https://hakril.net/slides/A_view_into_ALPC_RPC_pacsec_2017.pdf ↩︎

  10. https://github.com/hakril/PythonForWindows?tab=readme-ov-file#alpc-rpc ↩︎ ↩︎

  11. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/db1d5ce1-a783-4f3d-854c-dc44308e78fb ↩︎ ↩︎ ↩︎

  12. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/ba4c4d80-ef81-49b4-848f-9714d72b5c01#gt_74540339-daab-46ea-a8f9-fe8fca3b150c ↩︎ ↩︎

  13. https://github.com/ionescu007/hazmat5/blob/main/lclor.idl#L186 ↩︎

  14. https://gist.github.com/enigma0x3/2e549345e7f0ac88fad130e2444bb702#file-rpc_dump_rs5-txt-L1107 ↩︎ ↩︎

  15. https://github.com/samba-team/samba/blob/master/librpc/idl/dcom.idl#L133 ↩︎

  16. https://github.com/tongzx/nt5src/blob/daad8a087a4e75422ec96b7911f1df4669989611/Source/XPSP1/NT/com/ole32/idl/internal/objsrv.idl#L81 ↩︎

  17. : https://cicada-8.medium.com/process-injection-is-dead-long-live-ihxhelppaneserver-af8f20431b5d ↩︎

  18. https://github.com/antonioCoco/RemotePotato0/blob/main/RemotePotato0.cpp#L268 ↩︎

  19. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/c5917c4f-aaf5-46de-8667-bad7e495abf9 ↩︎

  20. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/8ad7d21d-5c34-4649-9bc7-5be6fe568245 ↩︎

  21. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/21781a97-cb45-4655-82b0-02c4a1584603 ↩︎ ↩︎

  22. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/fe6c5e46-adf8-4e34-a8de-3f756c875f31 ↩︎ ↩︎

  23. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/98c08086-d94a-443c-b7c6-167e82652885 ↩︎

  24. https://github.com/ExaTrack/COM_IExaDemo/blob/master/stubborn_client.py ↩︎

  25. https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cocreateinstance#remarks ↩︎

  26. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/7f621d16-8448-4f9a-9567-793845db2bc7 ↩︎

  27. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/11fd5e3a-f5ef-41cc-b943-45217efdb054 ↩︎

  28. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/1d6a8a54-b115-4148-815a-af0258931948 ↩︎