Post

Windows AppLocker Driver Elevation of Privilege (CVE-2024-21338)

Introduction

On February 13th, 2024, during Patch Tuesday, Microsoft Security Response Center (MSRC) disclosed an Elevation of Privilege vulnerability that was exploited in the wild by the Lazarus hacking group. The group used this previously unknown vulnerability (zero-day) in their attack to gain kernel access for their user-mode rootkit. As far as zero-days go, CVE-2024-21338 is relatively straightforward to both understand and exploit. The vulnerability resides within the IOCTL (Input and Output Control) dispatcher in appid.sys, which is the central driver behind AppLocker, the application whitelisting technology built into Windows.

Patch Diffing

By using BinDiff, we can compare the two versions of the driver, before and after the patch, to determine the fix and its exact location, providing clues and a good starting point for analysis.

The BinDiff output clearly shows that there was only one small change in AipDeviceIoControlDispatch, where a PreviousMode check was added to the IOCTL request:

Untitled BinDiff Comparison

Analysis

The first step is to communicate with the driver to trigger its vulnerability. To communicate with the driver, you typically need to find the Device Name, obtain a handle, and then send the appropriate IOCTL code to reach the vulnerability.

For this purpose, the driver was analyzed IoCreateDevice and the third argument of DeviceName is found to be \\Device\\AppID.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
NTSTATUS __stdcall DriverEntry(_DRIVER_OBJECT *DriverObject, PUNICODE_STRING RegistryPath)
{
  ...
  DeviceName.Buffer = L"\\Device\\AppID";
  WPP_MAIN_CB.NextDevice = 0i64;
  SymbolicLinkName.Buffer = L"\\??\\AppID";
  WPP_MAIN_CB.CurrentIrp = 0i64;
  WPP_MAIN_CB.DriverObject = (struct _DRIVER_OBJECT *)&WPP_ThisDir_CTLGUID_AppIDLog;
  memset(&ObjectAttributes, 0, 44);
  ...
  ConfigOptions = SrpInitialize(DriverObject);
  if ( ConfigOptions >= 0 )
  {
    ConfigOptions = IoCreateDevice(
                      DriverObject,
                      0,
                      &DeviceName,
                      0x22u,
                      0x100u,
                      0,
                      (PDEVICE_OBJECT *)&WPP_MAIN_CB.Queue.Wcb.NumberOfChannels);
    if ( ConfigOptions < 0 )
    {
      v11 = WPP_GLOBAL_Control;
      if ( WPP_GLOBAL_Control == (PDEVICE_OBJECT)&WPP_GLOBAL_Control || (HIDWORD(WPP_GLOBAL_Control->Timer) & 1) == 0 )
        goto LABEL_38;
      v12 = 11i64;
      goto LABEL_37;
    }
    ConfigOptions = ObSetSecurityObjectByPointer(
                      *(_QWORD *)&WPP_MAIN_CB.Queue.Wcb.NumberOfChannels,
                      4i64,
                      &unk_FFFFF8006B218640);
    if ( ConfigOptions < 0 )
    {
      v11 = WPP_GLOBAL_Control;
      if ( WPP_GLOBAL_Control == (PDEVICE_OBJECT)&WPP_GLOBAL_Control || (HIDWORD(WPP_GLOBAL_Control->Timer) & 1) == 0 )
        goto LABEL_38;
      v12 = 12i64;
      goto LABEL_37;
    }
    ConfigOptions = IoCreateSymbolicLink(&SymbolicLinkName, &DeviceName);
    if ( ConfigOptions < 0 )
    {
      v11 = WPP_GLOBAL_Control;
      if ( WPP_GLOBAL_Control == (PDEVICE_OBJECT)&WPP_GLOBAL_Control || (HIDWORD(WPP_GLOBAL_Control->Timer) & 1) == 0 )
        goto LABEL_38;
      v12 = 13i64;
      goto LABEL_37;
    }
    LODWORD(WPP_MAIN_CB.Queue.Wcb.DeviceRoutine) = 1;
    DriverObject->DriverUnload = (PDRIVER_UNLOAD)AipUnload;
    ObjectAttributes.Length = 48;
    DriverObject->MajorFunction[0] = (PDRIVER_DISPATCH)&AipCreateDispatch;
    ObjectAttributes.RootDirectory = 0i64;
    DriverObject->MajorFunction[2] = (PDRIVER_DISPATCH)&AipCloseDispatch;
    ObjectAttributes.Attributes = 512;
    DriverObject->MajorFunction[14] = (PDRIVER_DISPATCH)AipDeviceIoControlDispatch;
    ObjectAttributes.ObjectName = 0i64;
    DriverObject->MajorFunction[18] = (PDRIVER_DISPATCH)&AipCleanupDispatch;
    ...
  return ConfigOptions;
}

Untitled IOCTL

Breaking down the 0x22A018 control code and extracting the RequiredAccess field reveals that a handle with write access is required to call it. Inspecting the device’s ACL (Access Control List; see the screenshot below), there are entries for local serviceadministrators, and appidsvc. While the entry for administrators does not grant write access, the entry for local service does.

Untitled

As the local service account has reduced privileges compared to administrators, this also gives the vulnerability a somewhat higher impact than standard admin-to-kernel. This might be the reason Microsoft characterized the CVE as Privileges Required: Low, taking into account that local service processes do not always necessarily have to run at higher integrity levels.

Root Cause Analysis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
NTSTATUS __fastcall AppHashComputeImageHashInternal(
        __int64 user_buffer,
        __int64 (__fastcall **user_controlled_ptr)(__int64, __int128 *),
        unsigned int a3,
        __int64 a4)
{
  ...
  v39 = user_controlled_ptr;
  *(_QWORD *)v32 = a4;
  v40 = user_buffer;
  v29 = 0;
  v37 = 0i64;
  v38 = 0i64;
  v5 = 0;
  v43 = 0i64;
  v6 = 0;
  v30 = 0;
  v33 = 0;
  v34 = 0;
  LODWORD(v35) = 0;
  *(_QWORD *)v41 = 0i64;
  v42 = 0i64;
  if ( !a3 )
    return 0;
  status = (*user_controlled_ptr)(user_buffer, &v42); // Vulnerability
  if ( status < 0 )
    return status;
  ...
}

As we can see, the fact that we can control a function pointer and the first argument passed to the function from user-mode already provides a significant advantage, especially in terms of flexibility.

Untitled

A WinDbg session with the triggered vulnerability, traced to the arbitrary callback invocation. Note that the attacker controls both the function pointer to be called (0x4141414141414141 in this session) and the data pointed to by the first argument (0xdeadbeefdeadbeef).

If exploitation sounds trivial, note that there are some constraints on what pointers this vulnerability allows an attacker to call. Of course, in the presence of SMEP (Supervisor Mode Execution Prevention), the attacker cannot just supply a user-mode shellcode pointer. What’s more, the callback invocation is an indirect call that may be safeguarded by kCFG (Kernel Control Flow Guard), requiring that the supplied kernel pointers represent valid kCFG call targets. In practice, this does not prevent exploitation, as the attacker can just find some kCFG-compliant gadget function that would turn this into another primitive, such as a (limited) read/write. There are also a few other constraints on the IOCTL input buffer that must be solved in order to reach the vulnerable callback invocation.

The constraints are the following:

  1. For Win10 we must create a struct of size 0x18.
  2. For Win11 we must create a struct of size 0x20.
  3. The first member must point to the first argument passed to the callback function.
  4. The second member must be pointing to valid file object otherwise we will cause bugcheck when calling FsRtlSetKernelEaFile.
  5. The third member in the struct must point to a function pointer.
  6. On Win11 there is additional member which can null.

What is CFG/kCFG and How it Works?

Control Flow Guard (CFG), and its implementation in the kernel known as kCFG, is Microsoft’s version of Control Flow Integrity (CFI). CFG works by performing checks on indirect function calls made inside of modules and applications compiled with CFG. Note, that in order for kCFG to be enabled, VBS (Virtualization Based Security) needs to be enabled.

Indirect calls that are protected by CFG are validated using a bitmap, with a set of bits indicating if a target is “valid” or if the target is “invalid.” A target is considered “valid” if it represents the starting location of a function within a module loaded in the process. This means that the bitmap represents the entire process address space. Each module that is compiled with CFG has its own set of bits in the bitmap, based on where it was loaded in memory. As described in the ASLR section, Windows only randomizes the address space per-boot, so this bitmap is typically mostly shared among all processes, which saves significant amounts of memory.

Since by design, CFG/kCFG only validates if a function begins at the location indicated by the bitmap — not that a function is what it claims to be. If an attacker or researcher could locate additional functions marked as valid in the CFG/kCFG bitmap, it may be possible to overwrite a function’s pointer with another function’s pointer to “proxy” code execution. This could lead to, for example, a type-confusion attack, where a different, unexpected, function is now running with the parameters/objects of the original expected function.

As mentioned earlier, kCFG is only enabled when VBS is enabled. One interesting characteristic of kCFG is that even when VBS is not enabled, kCFG dispatch functions and routines are still present and function calls are still passed through them. With or without VBS enabled, kCFG performs a bitwise check on the “upper” bits of a virtual address to determine if an address is sign-extended (also known as a kernel-mode address). If a user-mode address is detected, regardless of HVCI being enabled, kCFG will cause a bug check of KERNEL_SECURITY_CHECK_FAILURE.

Valid CFG Targets

To find interesting CFG-compliant functions, we need to parse the GFIDS section of ntoskrnl to identify all functions that could potentially be abused in our exploit. For this purpose, the tool used was dumpbin with the /LOADCONFIG flag to obtain addresses of CFG functions. Subsequently, I wrote a quick IDA Python script to map addresses to function names, which I then began to analyze. A particularly handy plugin for IDA in identifying promising function candidates was FindFunc, which I used to further narrow down potential candidates.

Untitled

Given the large number of valid CFG functions in ntoskrnl, I was able to find many interesting functions that could have been used to achieve a read/write primitive. The function that I found the most interesting was ExpProfileDelete due to being simple yet effective function:

1
2
3
4
5
6
7
8
9
10
11
12
void __fastcall ExpProfileDelete(__int64 a1)
{
  if ( *(_QWORD *)(a1 + 0x30) ) <--- Most of the time this check will fail which wouldn't cause a BSOD
  {
    KeStopProfile(*(_QWORD *)(a1 + 0x28));
    MmUnmapLockedPages(*(PVOID *)(a1 + 48), *(PMDL *)(a1 + 0x38));
    MmUnlockPages(*(PMDL *)(a1 + 0x38));
    ExFreePoolWithTag(*(PVOID *)(a1 + 0x28), 0);
  }
  if ( *(_QWORD *)a1 )
    ObfDereferenceObjectWithTag(*(PVOID *)a1, 0x66507845u); <--- Here we past the PreviousMode pointer
}

This function is actually available in Windows Research Kernel (WRK) project which can be easily found on github:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
VOID ExpProfileDelete(IN PVOID Object)
/*
Routine Description:
    This routine is called by the object management procedures whenever the last reference to a profile object has been removed.
    This routine stops profiling, returns locked buffers and pages, dereferences the specified process and returns.
Arguments:
    Object - a pointer to the body of the profile object.
*/
{
    PEPROFILE Profile;
    BOOLEAN   State;
    PEPROCESS ProcessAddress;

    Profile = (PEPROFILE)Object;
    if (Profile->LockedBufferAddress != NULL) {
        // Stop profiling and unlock the buffers and deallocate pool.
        State = KeStopProfile(Profile->ProfileObject);
        ASSERT(State != FALSE);
        MmUnmapLockedPages(Profile->LockedBufferAddress, Profile->Mdl);
        MmUnlockPages(Profile->Mdl);
        ExFreePool(Profile->ProfileObject);
    }

    if (Profile->Process != NULL) {
        ProcessAddress = CONTAINING_RECORD(Profile->Process, EPROCESS, Pcb);
        ObDereferenceObject((PVOID)ProcessAddress);
    }
}

When ObfDereferenceObject is called on PreviousMode address of the current thread, we will cause a decrement on the PreviousMode of the main thread setting it from 1 to 0. At this point we can call NtReadVirtualMemory and NtWriteVirtualMemory on kernel addresses from user-mode.

By using this function as a call target I was successful in achieving working exploit for both Windows 10 and Windows 11 with HVCI enabled.

Proof Of Concept

Untitled

The full exploit code is in my github repo.

Summary

This vulnerability was trivial to exploit due to the few constraints present, which were not too difficult to bypass. The overall exploitation process proved interesting in terms of flexibility, as there are many different ways to achieve a full read/write primitive.

Reference

This post is licensed under CC BY 4.0 by the author.