Detecting and blocking unknown KnownDlls

blog-security-detection-720x420.png

This is the second in a two-part series discussing a still-unpatched userland Windows privilege escalation. The exploit enables attackers to perform highly privileged actions that typically require a kernel driver.

Part 1 of this blog series showed how to block these attacks via ACL hardening. If you haven’t already, please read the first part of this series, because it lays an important foundation for this article. Interested readers can also check out the excellent Unknown Known DLLs ... and other Code Integrity Trust Violations for a deeper understanding of code integrity and protected processes.

In part 2 of this series, I’ll show how to detect these attacks in real time, gaining greater visibility into your environment.

Exploit refresher

At a high level, the exploit, previously described as “Exploiting Arbitrary Object Directory Creation for Local Elevation of Privilege,” is a cache poisoning attack where an attacker can add a DLL to the KnownDlls cache — a list of pre-verified Windows DLLs. KnownDlls is only writable by WinTcb processes, which is the highest form of Protected Process Light (PPL), but a bug in the implementation of the DefineDosDevice API allows attackers to trick CSRSS, a WinTcb process, into creating a cache entry on their behalf.

DLLs in the KnownDlls cache are trusted by the Windows loader, so no additional security checks are performed when they are loaded, even inside PPL processes. After poisoning the cache, the attacker launches a PPL process which will load their DLL and execute its payload.

Because this exploit enables attackers to inject a DLL of their choosing into a WinTcb PPL process, they can perform any action with WinTcb privileges. Microsoft has indicated that they are not interested in servicing this vulnerability. It is confirmed to work on Windows 11 21H2 version 10.0.22000.194.

Protected process DLL loading

To understand how Windows identifies which processes are allowed to run as PPL, let’s look at the certificate which was used to sign services.exe. It contains an Object Identifier (OID) that entitles it to run as a WinTcb PPL:

knowndll

WinTcb Enhanced Key Usage OID

Once a PPL process launches, it can normally only load DLLs with an equivalent or higher signature level. This means that a WinTcb PPL process can only load WinTcb-signed (and above) DLLs. This prevents DLL search order hijacking and related attacks, which would otherwise be a trivial bypass to the PPL mechanism.

At a low level, DLL mapping (e.g. via LoadLibrary or during process initialization) typically occurs in in three steps:

  1. If the DLL is a KnownDLL, use the prepared KnownDLL section object and go to step 3.
  2. If the DLL is not a KnownDLL:
    1. Create a file handle to the DLL you want to load (NtCreateFile / NtOpenFile)
    2. Create a SEC_IMAGE section object from that file handle (NtCreateSection)
  3. Map a view of the section object into your address space (NtMapViewOfSection)

As part of the PPL implementation, Microsoft put executable image (EXE/DLL) verification in the section object creation step above (step 2, subsection 2). Attempts by PPL processes to create an image section using improperly-signed DLLs will result in a STATUS_INVALID_IMAGE_HASH error, like the following:

ppl-processing-attempting-unload

PPL process attempting to load an unsigned DLL via LoadLibrary

The aforementioned cache poisoning attack is possible because KnownDlls contains prepared section objects. DLLs in the KnownDlls cache are assumed to already have been checked for valid signatures, and the cache is protected by a WinTcb trust label to prevent tampering. Hence for KnownDlls, the loader goes straight to step 3, skipping over the signature check in step 2, subsection 2.

Spot the code integrity violation

Even though Windows doesn’t check signatures during NtMapViewOfSection, that doesn’t mean we can’t. Drivers can register a callback using PsSetLoadImageNotifyRoutine that will be invoked every time Windows maps an executable image into memory. This callback is provided with an IMAGE_INFO structure describing the image being loaded:

typedef struct _IMAGE_INFO {
  union {
    ULONG Properties;
    struct {
      ULONG ImageAddressingMode : 8;
      ULONG SystemModeImage : 1;
      ULONG ImageMappedToAllPids : 1;
      ULONG ExtendedInfoPresent : 1;
      ULONG MachineTypeMismatch : 1;
      ULONG ImageSignatureLevel : 4;
      ULONG ImageSignatureType : 3;
      ULONG ImagePartialMap : 1;
      ULONG Reserved : 12;
    };
  };
  PVOID  ImageBase;
  ULONG  ImageSelector;
  SIZE_T ImageSize;
  ULONG  ImageSectionNumber;
} IMAGE_INFO, *PIMAGE_INFO;Read more

Of particular interest here are the ImageSignatureType and ImageSignatureLevel fields. We can use them to identify unsigned and improperly-signed DLLs.

Let’s look at properly-signed kernel32.dll:

2: kd> dx FullImageName
FullImageName                 : 0xffff900895c53620 : "\Device\HarddiskVolume3\Windows\System32\kernel32.dll" [Type: _UNICODE_STRING *]
    [<Raw View>]     [Type: _UNICODE_STRING]
2: kd> dx ImageInfo->ImageSignatureType
ImageInfo->ImageSignatureType : 0x1 [Type: unsigned long]
2: kd> dx ImageInfo->ImageSignatureLevel
ImageInfo->ImageSignatureLevel : 0xc [Type: unsigned long]

We're hiring

Work for a global, distributed team where finding someone like you is just a Zoom meeting away. Flexible work with impact? Development opportunities from the start?