Fuzzing the Shield: CVE-2022–24548

Author: Daejin Lee, Seunghoe Kim, Donguk Kim, Eugene Jang

Basic Information

  • Tested OS: Windows 10 1809, 2009 x64, Windows 11 x64
  • Severity: Important
  • Target module version: <1.1.19100.5(mpengine.dll)
  • First updated version: 1.1.19100.5(mpengine.dll)
  • This vulnerability allows remote attackers to trigger heap out-of-bounds read on default installation of Windows Defender.
  • We tested this vulnerability with full pageheap enabled.

Introduction

Antiviruses act as a last mitigation for regular users and a challenge for attackers. It can provide cloud, emulator, signature based mitigation systems for malware detection. To bypass such detection mechanisms, attackers can apply various heuristic tricks like binary packing, custom obfuscation, etc. However, these tricks are limited in that they only provide temporary bypass or are OS-dependent. A more potent threat is to bypass the antivirus itself by using a vulnerability in the antivirus software. To mitigate and remediate these cases, such vulnerabilities should be fixed before it is weaponized. So, we decided to venture into one of the most widely used antiviruses: Windows Defender.

In this post, we analyze Windows Defender and the root cause of the bug that we found through fuzz testing.

Windows Defender

Figure 1. Defender main process, MsMpEng.exe

Windows Defender is an important and critical feature of Windows OS. Today’s Windows OS runs Windows Defender by default. It scans files, registries, and memory contents by using various components of its scan library including signature checker, runtime emulator, and unpacker in real time. Many components means that there are many different attack vectors. A noteworthy characteristic for security researchers is that it runs on the system integrity level for both on Windows 10 and 11. And since this is a default feature on Windows OS, it will work even if a 3rd party antivirus process is terminated (for instance, due to a bug on the antivirus). This means that the Defender is the last hurdle for the attacker.

Figure 2. From The process of calling rsignal from client to MsMpEng.exe

The main process of Windows Defender is MsMpEng.exe. The process is in charge of every component of Defender, so it has various types of functionalities consisting of scanning, updating, self-protection, etc. Scan request from user client to the Defender process can be shown as Figure 2. To communicate with the main process, a user-mode client can use the exported API in mpclient.dll. Once a user requests scanning through high level APIs like MpScanStart which is in mpclient.dll, an RPC request is sent to the RPC server registered by mpsvc.dll. Afterwards, the RPC request is processed in the handler, which uses the rsignal function of mpengine.dll to process the function requested by the client.

__int64 __fastcall DispatchICall(__int64 a1, int SignalNumber, __int64 a3, unsigned int a4)
{
...
// argument verification
v9 = sub_75C84EB78(SignalNumber, a3, a3, &v28);
v10 = v9;
if ( v9 >= 0 )
{
v11 = (void **)off_75CAD6110;
if ( (*((_BYTE *)off_75CAD6110 + 28) & 4) != 0 )
{
...
if ( SignalNumber == 0x4026 )
{
v16 = *(_DWORD *)(a3 + 8);
if ( (v16 & 4) != 0 )
{
if ( v11 != &off_75CAD6110 && (*((_BYTE *)v11 + 28) & 4) != 0 )
{
v17 = (__int64)sub_75C9BB638(v16);
sub_75C83EE14(*(_QWORD *)(v18 + 16), 46, (unsigned int)&stru_75CA561C8, v20, v17, v19);
v11 = (void **)off_75CAD6110;
}
for ( i = 0; ; ++i )
{
v22 = *(_DWORD *)(a3 + 24);
if ( i >= v22 )
break;
if ( v11 != &off_75CAD6110 && (*((_BYTE *)v11 + 28) & 4) != 0 )
{
sub_75C83ECB4(
(TRACEHANDLE)v11[2],
v22,
*(_QWORD *)(32i64 * i + *(_QWORD *)(a3 + 32)),
*(_QWORD *)(32i64 * i + *(_QWORD *)(a3 + 32) + 8));
v11 = (void **)off_75CAD6110;
}
}
}
else if ( v11 != &off_75CAD6110 && (*((_BYTE *)v11 + 28) & 4) != 0 )
{
v23 = (__int64)sub_75C9BB638(v16);
sub_75C83ED98(*(_QWORD *)(v24 + 16), 48, (unsigned int)&stru_75CA561C8, v25, v23);
}
}
}
// Call rsignal
v10 = _guard_dispatch_icall_fptr(a1 + 160, (unsigned int)SignalNumber, a3, a4);
...
return v10;
}

As you can see in mpsvc.dll binary, the handler function corresponding to the signal number is called. The signal number is set by a user-mode process.

Known components which had some bugs before are ASprotect unpacker, x86 emulator, js engine, etc. But, some of those components have been removed. To figure out the other scanning features in Defender, we used code coverage information and old version symbol.

Fuzzing Harness

Turning a scan process into a fuzz testing harness code means we should find the processing component in target binary and minimize it. Before we get into it, we found a previous research called loadlibrary by taviso which contains a harness code that is made of ported windows dynamic link libraries to linux. It targets x86 mpengine.dll with signal number RSIG_SCAN_STREAMBUFFER which is 0x403d in hex. The harness code doesn’t work on the latest mpengine.dll, so we tried to fix it by reversing mpengine and mpsvc. However, it only worked with simple binaries like EICAR, and timed out on more complex files. Therefore, we decided to analyze the scan routine of Windows Defender.

The core scanning library for Defender is mpengine.dll. There are lots of components in it, but the latest version of symbol is not available. With these two sources (loadlibrary and an old version of symbol) we started analysis on the scanning process.

1: kd> dt nt!_eprocess
+0x000 Pcb : _KPROCESS
+0x438 ProcessLock : _EX_PUSH_LOCK
+0x440 UniqueProcessId : Ptr64 Void
...
+0x87a Protection : _PS_PROTECTION
...
+0xa10 DynamicEnforcedCetCompatibleRanges : _PS_DYNAMIC_ENFORCED_ADDRESS_RANGES

1: kd> !process 0 0 msmpeng.exe
PROCESS ffffbc89120f1340
SessionId: 0 Cid: 0da8 Peb: 95dfc02000 ParentCid: 0258
DirBase: 20fad5000 ObjectTable: ffffe30bcdf90780 HandleCount: 618.
Image: MsMpEng.exe

1: kd> dt nt!_eprocess protection ffffbc89120f1340
+0x87a Protection : _PS_PROTECTION
1: kd> dx -id 0,0,ffffbc89120f1340 -r1 (*((ntkrnlmp!_PS_PROTECTION *)0xffffbc89120f1bba))
(*((ntkrnlmp!_PS_PROTECTION *)0xffffbc89120f1bba)) [Type: _PS_PROTECTION]
[+0x000] Level : 0x31 [Type: unsigned char]
[+0x000 ( 2: 0)] Type : 0x1 [Type: unsigned char] // PsProtectedTypeProtectedLight
[+0x000 ( 3: 3)] Audit : 0x0 [Type: unsigned char]
[+0x000 ( 7: 4)] Signer : 0x3 [Type: unsigned char]

At this point, we had to analyze the Defender dynamically to figure out which arguments were used and the types of those arguments. Here, we faced two problems.

First is the Protected Process Light (PPL). MsMpEng.exe is a protected process which is prohibited to attach a debugger to. To disable this, it’s necessary to modify MsMpEng.exe’s EPROCESS structure member ‘Protection’ which is a structured as _PS_PROTECTION. Member ‘Type’ inside it stands for whether it is protected or not, which we set it to zero. By doing so, we can attach to the target process and check some loaded modules generally.

__int64 __fastcall MpObPreOperationCallback(__int64 a1, __int64 a2)
{
POBJECT_TYPE *v2; // rcx

if ( *(_QWORD *)(a2 + 8) )
{
v2 = *(POBJECT_TYPE **)(a2 + 16);
if ( v2 == ExDesktopObjectType )
{
MpObHandleOpenDesktopCallback(a2);
}
else if ( v2 == PsProcessType )
{
MpObHandleOpenProcessCallback(a2);
}
}
return 0i64;
}

__int64 __fastcall MpObAddCallback(_QWORD *a1)
{
...
if ( MpData )
{
if ( *(_QWORD *)(MpData + 48) )
{
*a1 = 0i64;
v4 = L"328010";
if ( !*(_BYTE *)(v3 + 2097) )
v4 = L"328000";
RtlInitUnicodeString(&DestinationString, v4);
LODWORD(v14) = v14 | 3;
v13 = PsProcessType;
v7 = 256
// PsProcessType
// MpObPreOperationCallback
*((_QWORD *)&v14 + 1) = MpObPreOperationCallback;
if ( (*(_DWORD *)(MpData + 688) & 0x10) != 0 )
{
LODWORD(v17) = v17 | 3;
v16 = ExDesktopObjectType;
v8 = 2;
// ExDesktopObjectType
// MpObPreOperationCallback
*((_QWORD *)&v17 + 1) = MpObPreOperationCallback;
}
else
{
v8 = 1;
}
v11 = 0i64;
v12 = &v13;
v10 = DestinationString;
// Call ObRegisterCallbacks
ret = (*(__int64 (__fastcall **)(__int16 *, __int64 *))(MpData + 48))(&v7, &v19);
...
}

Second one is the Wdfilter callbacks that are applied to the Defender process. Even if the PPL is disabled, Wdfilter does not allow fetching the fully permissioned handle. This means, users cannot attach to the MsMpEng.exe process or perform code hook until the Wdfilter callbacks are unregistered. Wdfilter registers two types of MpObPreOperationCallback through ObRegisterCallback function to protect the Defender process. Once the callbacks are registered, process handle requests such as OpenProcess even with full permission can only return handle with partial permission. To obtain a full handle permission of the Defender process, we replaced the callback functions with our dummy function.

Figure 3. Overall fuzzing process using defender harness code

After solving the problems described above, we could debug the MsMpEng.exe main process. Next step was identify argument information that the defender uses. To do this, we analyzed which values the defender scanner passes as arguments to mpsvc.dll and mpengine.dll. We couldn’t figure out the exact meaning of every argument and struct type, but were able to create a fuzzing harness with the same code coverage as the Defender Scanner.

We first tried to fuzz mpengine with this harness in x86 with page heap enabled. However, mpengine library allocates a large amount of memory which is more than x86 permits. So, we move on to the x64 environment. Through the same process as when debugging defender x86, we had successfully ported the x86 environment harness code to the x64. Overall process of the harness can be shown as Figure 3. With some carefully selected seed files by observing coverage feedback, we get into the fuzzing process. The first step is booting up the mpengine. The booting process is operated only once per fuzzing round. Once the booting process succeeds, the fuzzer executes the “scan function” in mpengine.dll on iteratively mutated inputs while tracking the coverage.

We used Jackalope which is a TinyInst based coverage guided fuzzer with supporting edge, block, and compare coverage for fuzzing process. Our fuzzing environment was Windows 10 x64 VM with 16-cores and 64GB RAM memory. We ran the fuzzer for a week and found two bugs.

Root-Cause Analysis

The bugs could be triggered by scanning malicious 16-bits DOS executable binary. A root-cause of the bug is described below.

__int64 __fastcall fsemu_goodmask(const char *a1, const char *a2)
{
...
v4 = 0;
v5 = 0;
v6 = 1;
do
{
v7 = a2[v4];
switch ( v7 )
{
case 0:
return 0i64;
...
case '*':
++v4;
// oob read crash, v5 is 16bit signed integer
for ( i = a1[v5]; i; i = a1[++v5] )
{
if ( i == '.' )
break;
v13 = a2[v4];
v14 = i - 32;
if ( (unsigned __int8)(i - 97) > 0x19u )
v14 = i;
v15 = v13 - 32;
if ( (unsigned __int8)(v13 - 97) > 0x19u )
v15 = a2[v4];
if ( v14 == v15 )
break;
}
case '?':
goto LABEL_25;
default:
...
if ( v10 == v9 )
{
LABEL_25:
++v4;
}
else if ( v8 != 36 )
{
return 0i64;
}
++v5;
break;
}
}
while ( a1[v5] );
...
return v6

The fsemu_goodmask which is a file system masking function processes our input and triggers an out-of-bound read. In this function, the heap memory a1 which is allocated from HeapAlloc in “malloc_base” with size 0x1b7000 is the target vulnerable memory. The root-cause of this vulnerability is that the a1 is allocated but not initialized. Because the a1 variable is not initialized, full pageheap fills a1 variable with data 0x0c and this results in looping on fsemu_goodmask with increasing v5 which is an 16-bit signed integer variable. So, increasing v5 many times makes a1 access out of bound memory.

__int64 __fastcall fsemu_fcreate(struct t_fsemu_fsys *a1, const char *a2, char a3)
{
...
v3 = *((unsigned __int16 *)a1 + 4);
if ( (unsigned __int16)v3 >= 0xFFu )
return -1i64;
v8 = -1i64;
do
++v8;
while ( a2[v8] );
if ( (unsigned __int16)v8 > 0x7Fu )
// limit length of filename to 127
LOWORD(v8) = 127;
v9 = CRCFilePath(127i64, a2, (unsigned __int16)v8);
...
return v13;
}

BUG 1) When the emulator creates a file, there is only one limit for a filename length. The file should not be created with a null filename, however we can create a file with name of null(\0) in the emulator.

BUG 2) When the emulator processes input file mask in fsemu_goodmask, there is no filename length check to verify whether increasing index is applicable or not.

Summary) By using “BUG 1”, we can create a file with name of “null” in the emulator. To make a triggerable file, the file mask should meet the following two conditions.

  1. The mask should start with string “?*”
  2. The mask must not be matched with any filenames in the emulated filesystem

After that, we can call file syscall which loops all available files in the emulator. When the file syscall finds the filename with null that we created before, out-of-bounds memory access is triggered.

After we observed a root cause of the bug, we could make an assembly code that can be run on the emulator to trigger it. Finally, we minimized the original poc file from 21k to 563 bytes.

Patch Diff

--- a/18900.txt
+++ b/19100.txt
@@ -11,26 +11,27 @@ __int64 __fastcall fsemu_goodmask(const char *a1, const char *a2)
...
+ if ( !*a1 ) // filename
+ return 0i64;
idx = 0;
gidx = 0;
+ if ( !*a2 ) // file name mask
+ return 0i64;
v6 = 1;
...
+ while ( 1 )
{
chr = a2[idx];
switch ( chr )
{
- case '\0':
- return 0i64;
--- a/18900.txt
+++ b/19100.txt
@@ -2,7 +2,7 @@ __int64 __fastcall fsemu_fcreate(struct t_fsemu_fsys *a1, const char *a2, char a
{
...
v3 = *(unsigned __int16 *)(a1 + 8);
- if ( (unsigned __int16)v3 >= 0xFFu )
+ if ( (unsigned __int16)v3 >= 0xFFu || !*a2 ) // filename argument check
return -1i64;
v7 = -1i64;
do

To verify the patched code, we diffed mpengine.dll version 1.1.18900.3 with 1.1.19100.5. Main differences between them were a simple check of a filename argument in fsemu_fcreate and null character check in fsemu_goodmask function. Since they don’t accept a null filename, it’s impossible to trigger an out-of-bounds bug.

PoC Demo

Timeline

  • 02/20/2022 — Vulnerability reported to MSRC
  • 04/12/2022 — Patch released

References

Homepage: https://s2w.inc
Facebook: https://www.facebook.com/S2WLAB
Twitter: https://twitter.com/S2W_Official

Fuzzing the Shield: CVE-2022–24548 was originally published in S2W BLOG on Medium, where people are continuing the conversation by highlighting and responding to this story.

Article Link: Fuzzing the Shield: CVE-2022–24548 | by S2W | S2W BLOG | Dec, 2022 | Medium