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.
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.
Thus for successful exploitation arbitrary file/directory create (CVE-2023-1586) we need to do next steps:
Create directory .\Switch and a test EICAR virus .\Switch\{GUID}.dll;
Wait for the test virus will be quarantined;
Create an oplock on C:\$AV_ASW\$VAULT\vault.db;
Bypass self-defense and call Proc82 of RPC-interface [aswAavm] to restore file;
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";
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:
Copy AswRestoreFileExploit.dll to target machine where Avast Free Anti-Virus is already installed;
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:
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.
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).
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.
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.
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.
Putting all the steps together, for successful exploitation arbitrary file/directory delete (CVE-2023-1585) we need to do the following:
Create directory ".\Switch" with restrictive DACL (deny FILE_ADD_FILE ACE for SYSTEM) and test EICAR virus ".\Switch\{GUID}.dll";
Create oplock on "C:\ProgramData\Avast Software\Avast\log\Cleaner.log" and wait for test virus will be quarantined;
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";
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.
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:
Using CVE-2023-1585 delete target file, the contents of directory "C:\ProgramData\Avast Software\Avast\fw" and then delete directory itself;
Bypass self-defense and call Proc3 of RPC-interface "aswChestβ to crash and restart main service (CVE-2023-1587);
Make sure directory "C:\ProgramData\Avast Software\Avast\fw" is now Everyone full accessible;
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"
Call Proc58 of RPC-interface "[aswAavm]" to trigger execution privileged CopyFile() in "C:\ProgramData\Avast Software\Avast\fw" directory;
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:
Copy AswQuarantineFileExploit.dll to target virtual machine where Avast Free Anti-Virus is already installed;
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:
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.
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:
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:
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).
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:
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:
Send the file we want to delete for scanning;
Inject the code into the sandboxed process of the AV engine aswEngSrv.exe and βsayβ that the file is malicious;
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:
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:
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:
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):
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:
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.
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:
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:
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 Ormandipatched
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:
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:
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:
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:
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:
The process must be spawned from the binary file "C:\Program Files\AVAST Software\Avast\wsc_proxy.exe";
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:
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:
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:
As standard user inject into the aswEngSrv.exe process;
Inside sandbox create a Task Scheduler task to run your code under the full βNT AUTHORITY\SYSTEMβ account and trigger the launch;
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;
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:
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:
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.
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:
Assign and manage process trust tags (on creating process, on making suspicious actions);
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:
Ob-Callbacks - prevent opening for write
process, thread;
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:
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:
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):
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:
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:
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:
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:
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:
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:
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;
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;
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;
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:
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.
Find a bug in the Avast logic for determining prohibited operations (for example, find a process from the list of exceptions and mimic it);
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:
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:
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:
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;
Copy module to inject to C:\Users\User\AppData\Local.dll;
Using the handle copying bug, get all access handle to process AvastUI.exe;
Find the address of kernel32!LoadLibraryA (for this, thanks to KnownDlls, you donβt even need to read the memory, although we can);
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;
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):
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:
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:
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.
And if we then try to inject again into the main process, then we will succeed and no signature checks will be performed:
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:
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):
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:
A larger attack surface is opened on AV interfaces - only trusted processes have access to many of them;
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;
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.
In February McAffee fixed 2 vulnerabilities (CVE-2021-23874 and CVE-2021-23875)
in their flagship consumer anti-virus (AV) product McAfee Total Protection. These issues were local privilige escalations and CVE-2021-23874
was present in McAfeeβs COM-object. As it seems to me the topic of hunting bugs in COM-objects isnβt very well covered on the Internet. So this
post should fill this gap and show an approach to finding COM-objectβs bugs with an example CVE-2021-23874. On the other hand, the post can be
considered as a real world walkthrough with OleViewDotNet (OVDN).
0x01: Prerequisites
To successfully reproduce the steps described in the following sections, you need:
McAfee Total Protection 16.0 R28;
OVDN commit 55b5cb0 (and later). An up-to-dated version is necessary, since it fixes bugs that are needed
for correct work of used cmdlets, and these fixes havenβt been included in the
v1.11 release yet;
OS Windows (any version, but I used 2004 x64);
WinDbg;
IDA Free or any other powerful disassembler.
0x02: Attack Surface Enumeration
If we are hunting for LPE in COM-objects of a specific Product and in this case it is McAfee Total Protection, then we are interested in
objects with 3 following characteristics:
COM-objects are installed into the system by this particular Product;
COM-objects are launched out-of-process (OOP) in the context of a privileged user (in this case βNT Authority\Systemβ);
We have access to the COM-object interface from our privilege level.
All 3 characteristics are mandatory, so letβs go in order.
An obvious and pretty simple approach to find the COM-objects installed by product is to take the first snapshot before installation, then
install the product, take the second snapshot after installation and compare with each other. This can be done using
ASA, but we will do it with OVDN, since it is more scriptable, fast and easy for further
research.
To collect an initial snapshot of installed COM-objects we need to run powershell with the specified bitness (in this case x86), import OVDN
and type the following commands:
The powershellβs bitness is important because of the way the OVDN works: for example, x64 version can collect COM-objects information only from
*\SOFTWARE\Classes, and x86 - only from *\SOFTWARE\WOW6432Node\Classes. At the same time, x64 version can parse both x64 and
WoW64-processes, and x86 version - only WoW64-processes. Thus, there is no single rule of when and what OVDN of a specific bitness can do, but
I can give simple advice to use 32-bit OVDN for 32-bit COM-entries, 64-bit OVDN - for 64-bit entries. And for security research use both versions.
The above commands collect information about registered COM-objects and serialize it to the file ComDb_old.db. Next, we need to install the product.
In this case, it is McAfee Total Protection 16.0 R28. And after a successful installation, we collect the database of registered COM-objects again
and find the differences with the snapshot collected in the previous step:
Now we have a list of changes in variable $comDiff and we want to filter them to see OOP COM-objects running under the βNT Authority\Systemβ account
and accessible from our privilege level:
When in second command we test for accessible COM-objects, we must use the -Principal parameter to replace SELF SID with appropriate SID under
which the COM-object will run. As we can see from the command output, there are no McAfeeβs COM-objects in the system accessible from our privilege
level. And here, in theory, the research could end but if we remember that access in terms of the cmdlet Select-ComAccess means to have
rights to launch and access COM-object, then we can try to see objects accessible only for launch:
Now we see a list of more COM-objects, among which there are objects that clearly belong to the product McAfee Total Protection. Still, we can launch some
instances of COM-objects of interest to us. Letβs take one of them, for example with AppId 77b97c6a-cd4e-452c-8d99-08a92f1d8c83, and figure out why there
is no full access rights, but there is launch access rights:
The COM-object CoManageOem Class with AppId name McAWFwk uses the default security descriptor. So letβs decode the default launch rights in human-readable form:
And decode the default access rights:
All right, COM-objectβs security descriptor confirms the results obtained from the Select-ComAccess cmdlet.
0x03: COM-object Access Rights Check
In the previous section we saw that we can start the COM-server and get an instance of the implemented COM-object. But then we will not have access rights
to call its methods. Obviously, this is not very promising for vulnerability hunting initial data, but still we will try to get a pointer to a COM-object
instance:
We cannot create an object because the interface is not supported. Which one?
IClassFactory. CreateInstanceAsObject internally uses
CoCreateInstance, which encapsulates the following code:
And the error is thrown because, as weβll see this a little further, the factory doesnβt implement the IClassFactory interface.
Then letβs try to look the interfaces that the COM-object implements:
Nothing. Here is the same problem as in the previous case. Internally OVDN, to get a list of supported interfaces, creates an object using CoCreateInstance,
and then calls QueryInterface for a set of known
interfaces, then for all interfaces registered in HKCR\Interface, and then using the
IInspectable interface. But since for a successful call to
CoCreateInstance it is necessary that the factory implements the IClassFactory interface, it is impossible to create an object and therefore it is impossible
to query it for the implementation of other interfaces.
Letβs try to look the interfaces that the COM-object factory implements:
IMcClassFactory interface looks interesting. We can quickly see what it is by analyzing the ProxyStub:
From powershell we can create a factory object and get a pointer to it, thus starting the COM-server:
The error occurs because the code inside New-ComObjectFactory is trying to wrap an object in a callable wrapper that implements the IClassFactory interface,
but this COM-object doesnβt implement it (as we already know). Letβs try to create object without a wrapper:
Good. We created a factory instance and got a raw pointer to it. This pointer is pretty useless in powershell:
But it is important for us that we have started the server that hosts the COM-object. And now we can investigate the process:
The COM-object is hosted in the service McAWFwk, respectively, in the process with the name McAWFwk.exe. And we can see once again (now dynamically), if we
have access to the COM-object in the process McAWFwk.exe. For COM-process parsing we use cmdlet Get-ComProcess and for access checking - already known
Select-ComAccess:
Select-ComAccess returned the COM-process object, which means that we have access to it from our privilege level. And we can see that COM-object has no access
control. But why? We saw in the previous section the prohibitive access rights.
0x04: Bug
In order to understand what is going on, it is enough to attach using a debugger (in this case WinDbg) to the McAWFwk service at its start and set a breakpoint
to the beginning of the function CoInitializeSecurity. Having
done this, letβs see the parameters passed to the function:
The displayed stack is a little bit wrong, but the last frames are correct and thatβs enough for us. It is important that the pSecDesc parameter is nullptr and
dwCapabilities is also 0. What this means can be found on msdn, but I like the explanation from the book
βInside COM+: Base Servicesβ:
If neither the EOAC_APPID nor EOAC_ACCESS_CONTROL flag is set in the dwCapabilities parameter, CoInitializeSecurity interprets pSecDesc as a pointer to a Win32
security descriptor structure that is used for access checking. If pSecDesc is NULL, no ACL checking is performed.
I.e. the COM-object has a safe default DACL in the registry, which does not allow us to access the object from our privilege level. But at startup the COM-object
overrides it and makes itself available to the attacker. It is interesting that this attack surface is absent in static analysis, but appears in dynamic.
Obviously, we get an attack surface that was not foreseen at the design stage. Therefore it becomes very promising to hunting bugs in this component.
0x05: COM-object Implementation RE
The next important question is the functionality that this COM-object implements and exposes. The only way to research this is reverse engineering (RE). And the starting point will be to
find out the address of the vtable of the COM-object factory:
Next we go to the disassembler (in this case IDA) and see the table of virtual methods of the COM-object factory at address McAWFwk+0x56F78:
Obviously, we are interested in Proc3. Based on the logic of the factory this function will allow you to create an object - the method presented in the vtable after
QueryInterface, AddRef and Release. Hereβs a simplified listing of Proc3, which I named CoManageOEMFactory::InternalCreateObjectWrapper:
The method CoManageOEMFactory::InternalCreateObjectWrapper checks that the call comes from a valid module and delegates the work to Proc4 from CoManageOemFactory vtable.
The parameters are passed as-is. Since the COM-object is OOP, our code does not in any way affect the validity of the module from which InternalCreateObjectWrapper is called,
and therefore the ValidateModule check will always be successful and will return 0, which will prevent us from getting the ACCESS_DENIED error.
Letβs look at the listing of Proc4 (or as I named it CoManageOEMFactory::InternalCreateObject):
As we can see in the above listing, the method calls the McCreateInstance function with the arguments GUID e66d03f6-c1cf-4d8c-997c-fae8763375f6 and IID
9b6c414a-799d-4506-87d1-6eb78d0a3580. Next in the pManageOem argument we get a pointer to the COM-object from which the user-specified interface is queried. Letβs see
what happens in the McCreateInstance function:
McCreateInstance receives a pointer to the IMcClassFactory factory interface of the object, the CLSID of which was passed as an argument, and then, using this factory,
creates an object and returns an interface pointer of the specified type to the object. In fact, McCreateInstance is semantically identical to CoCreateInstance, with the
difference that the latter uses the IClassFactory interface to create an object, and the former uses IMcClassFactory.
Now it is clear that the method CoManageOEMFactory::InternalCreateObjectWrapper creates within itself an object with CLSID e66d03f6-c1cf-4d8c-997c-fae8763375f6 that
implements the IMcClassFactory factory, then queries the specified interface and returns it to the client. Letβs see what kind of object is being created:
Again, we cannot get a list of interfaces that the COM-object implements, since its factory doesnβt implement IClassFactory interface. Then letβs see the definition of the
interface 9b6c414a-799d-4506-87d1-6eb78d0a3580 that is queried from the COM-object in the method CoManageOEMFactory::InternalCreateObjectWrapper:
For the interface IManageOem, there is a ProxyStub Dynamic-Link Library (DLL), which can be decompiled, and a TypeLib, from which information can be extracted. We use a TypeLib because it contains more
information:
The output contains many different types, structures and interface definitions from TypeLib, but for us the only interesting thing is the definition of interface IManageOem:
The interface IManageOem contains many attractive methods, but only the most promising are shown in the listing above. To find out the address of the function that
implements the specific interface method, we must take the following steps:
Attach WinDbg to McAWFwk.exe process and set a breakpoint on the instruction after returning from the McCreateInstance function;
Write and execute client code that will call the CoManageOEMFactory::InternalCreateObject method;
Dump the returned in step 1 memory and find the address of the function by index.
To find the instruction on which to set a breakpoint, we need to disassemble the method CoManageOEMFactory::InternalCreateObject implemented in McAWFwk.exe binary:
Instruction test rcx, rcx at address McAWFwk + 0xc2f1 checks the value of the pointer pManageOem returned from the function McCreateInstance. So, after the
successful completion of the function McCreateInstance, the register rcx contains the address of the object, at offset 0 in which address of the first virtual table
is located.
Client code that calls the method CoManageOEMFactory::InternalCreateObject is shown below:
The code is self-explained and I think it doesnβt need any comments. But as a result of the execution of the above code, the program ends with the following error:
βException: InternalCreateObject failed. Error: -2147024891β. Decimal number -2147024891 converts to the more familiar hexadecimal number 0x8007005 (access denied).
But where did error come from? Weβve already seen that COM-object permissions allow us to have access to objectβs methods. After a bit of debugging I found that the error
returns ProxyStub DLL loaded in clientβs application. The code preceding the sending request to create an object is similar to the following:
Check is client-side and itβs obvious that it can be bypassed, but since at the moment the primary task is to examine the methods provided by the COM-object, now we will bypass the
validation using the debugger capabilities, and a full bypass will be presented in the next section.
Now when we can set a breakpoint, when the object is already completely constructed and can trigger its creation, it remains to dump its virtual function table. After hitting
a breakpoint it will look like this:
The interface IManageOem inherits from IDispatch interface. The interface IDispatch defines 7 methods,
so it is obvious that the method RunProgram will be the 7th (numbered from 0) in virtual function table, but in practice, this method was only 14th, with an address McDspWrp+0x2c168.
I donβt know why this mismatch is, but my guess is that the cmdlet Get-ComTypeLibAssembly isnβt parsing the TypeLib correctly.
Now letβs look at the decompiled method IManageOem::RunProgram that implements ManageOem Class COM-object:
The above code takes attacker-controlled exePath and cmdLine and creates the child process without impersonation, from
msdn:
The new process runs in the security context of the calling process
Thus, it is obvious that by calling this method a low-privileged user can execute an arbitrary file in the System context (since McAWFwk is a service) and escalate privileges.
Another interesting point is the code on line 20 that looks like a stack buffer overflow vulnerable. Letβs remember that the parameters are attacker-controlled, stack buffer CommandLine
has a fixed size of 1040 widechars and wsprintfW writes these strings to the buffer. And if the attacker
sends to the input a string longer than 1040 characters, then it is logical to expect that the return address will be overwritten. But this is not the case, since in the wsprintfW description
is mentioned that βmaximum size of the buffer is 1,024 bytesβ and internally the function really does not write beyond 1024, but characters, not bytes.
As a result, we can launch and access the methods of the COM-object CoManageOem Class. This object implements the interface IMcClassFactory and in the method
IMcClassFactory::InternalCreateObject returns an COM-object ManageOem Class, that implements the interface IManageOem. Exposed method IManageOem::RunProgram makes it easy to escalate
privileges and run an arbitrary process in context βNT Authority\Systemβ. There remains only one problem - self-defense implemented in the ProxyStub, and bypassing this mechanism will be
discussed in the next section.
0x06: Self-Defense Bypass
As we saw in the previous section self-defense for COM-object implemented in ProxyStub DLL that is loaded (by design for marshalling parameters) into the address space of the client
(attacker-controlled) process. So obviously we can just overwrite our own code to ignore the error returned from the validation function (I named it ValidateModule in the screenshot above).
But this approach is not very robust, as the module may be recompiled in further versions of the product, offsets and instructions may change. And I donβt want to support all the older and
newer versions. So we must choose a more elegant solution - find a weakness in the code logic.
The validation implemented in the ValidateModule function performs the following two steps:
Gets the path to the module from which the proxy is called using a code like (error handling omitted for simplicity):
Validate the module using a function ValidateModule exported from the library vtploader.dll
We can spoof the path to the module from which the call originates, or we can craft the module to pass the check implemented in vtploader!ValidateModule. It is clear that the former is simpler and requires
only a modification of the structure in PEB.
Here is the corresponding C++ code to modify the path to the main (our proof-of-concept (PoC) calls the proxy from the main module, so thatβs enough ) binary in PEB::Ldr::InMemoryOrderModuleList:
Thus, in order to bypass self-defense, it is necessary to call the above function MasqueradeImagePath with path to any McAfee signed binary as argument before the first COM proxy call is made:
0x07: Exploitation
Summarizing all the steps together, it turns out that for successful exploitation we need to do the following:
Instantiate CoManageOem Class COM-object in McAWFwk service, get a marshalled pointer to it and query IMcClassFactory interface to factory with
::CoGetClassObject(77b97c6a-cd4e-452c-8d99-08a92f1d8c83, β¦, fd542581-722e-45be-bed4-62a1be46af03, &pMcClassFactory);
Masquarade PEB to bypass ProxyStub check with MasqueradeImagePath;
Create incapsulated COM-object ManageOem Class, get a marshalled pointer to it and query IManageOem interface to object with
pMcClassFactory->InternalCreateObject(9b6c414a-799d-4506-87d1-6eb78d0a3580, &pManageOem);
Call IManageOem::RunProgram to run shell bind TCP listener on localhost:12345 with powershell.exe powercat.ps1 with pManageOem->RunProgram(βpowershell.exeβ, β. .\powercat.ps1;powercat -l -p 12345 -epβ);
Connect to listener and execute shell commands as SYSTEM with . .\powercat.ps1;powercat -c 127.0.0.1 -p 12345.
Here is a shortened version of the code for exploiting the vulnerability, you can see full version of the PoC on the github:
And below is demo of the PoC:
Note: Recently AV have been detecting βpowercatβ and quarantining it. So for the demonstration purposes, the script must be added to the exclusions, and to
work in real life, the payload must be changed to something slightly less famous.
0x08: Conclusion
As you can see, the reported vulnerability is quite simple, but not obvious in terms of its search, discovery and exploitation. And to simplify the task of searching
for vulnerabilities in COM-objects, a modern, powerful and flexible tooling comes to the rescue - OVDN. I hope this post will help you learn OVDN and start using it.
In addition, you can notice that the vulnerability wouldnβt have been found if we had stopped at a static analysis of the attack surface. Therefore itβs always
important to check your expectations, based on static attack surface analysis, with a dynamic test. Results will surprise you :)
0x09: Disclosure Timeline
2020-11-03
Initial report sent to McAfee.
2020-11-04
Initial response from McAfee stating theyβre being reviewed it.
2020-11-24
McAfee triaged the issue reported as a valid issue and is starting work on a fix.
2021-02-10
McAfee releases patched version of product and published the security bulletin.