Discovering and exploiting McAfee COM-objects (CVE-2021-23874)
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:
- McAfee Total Protection 16.0 R28;
- 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;
- OS Windows (any version, but I used 2004 x64);
- WinDbg;
- 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:
- COM-objects are installed into the system by this particular Product;
- COM-objects are launched out-of-process (OOP) in the context of a privileged user (in this case “NT Authority\System”);
- 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:
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:
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:
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:
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:
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:
And decode the default access rights:
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:
We cannot create an object because the interface is not supported. Which one? IClassFactory. CreateInstanceAsObject internally uses CoCreateInstance, which encapsulates the following code:
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:
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:
IMcClassFactory interface looks interesting. We can quickly see what it is by analyzing the ProxyStub:
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:
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:
Good. We created a factory instance and got a raw pointer to it. This pointer is pretty useless in powershell:
But it is important for us that we have started the server that hosts the COM-object. And now we can investigate the process:
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:
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:
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:
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:
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:
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):
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 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:
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:
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:
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:
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:
- Attach WinDbg to McAWFwk.exe process and set a breakpoint on the instruction after returning from the McCreateInstance function;
- Write and execute client code that will call the CoManageOEMFactory::InternalCreateObject method;
- 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:
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:
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:
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:
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:
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):
- Validate the module using a function ValidateModule exported from the library vtploader.dll
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:
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:
0x07: Exploitation
Summarizing all the steps together, it turns out that for successful exploitation we need to do the following:
- 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);
- Masquarade PEB to bypass ProxyStub check with MasqueradeImagePath;
- 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);
- 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”);
- 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:
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.