Detecting and blocking unknown KnownDlls
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:
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:
- If the DLL is a KnownDLL, use the prepared KnownDLL section object and go to step 3.
- If the DLL is not a KnownDLL:
- Create a file handle to the DLL you want to load (NtCreateFile / NtOpenFile)
- Create a SEC_IMAGE section object from that file handle (NtCreateSection)
- 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:
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;
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]
An ImageSignatureType 0x1 corresponds to SeImageSignatureEmbedded, and ImageSignatureLevel 0xc corresponds to SE_SIGNING_LEVEL_WINDOWS. Combined, these flags indicate that the file has an embedded signature that is trusted to load into protected processes with a signing level of Windows and below. Further research: Since the Windows signing level is less trusted than the WinTcb signing level, would kernel32 (signing level Windows) be blocked from loading into services.exe (signing level WinTcb) if it was not in the KnownDlls cache?
Compare that to the concrt140.dll containing the PPLDump payload:
2: kd> dx FullImageName
FullImageName : 0xffff9008943c6620 : "\Device\HarddiskVolume3\Windows\System32\concrt140.dll" [Type: _UNICODE_STRING *]
[<Raw View>] [Type: _UNICODE_STRING]
2: kd> dx ImageInfo->ImageSignatureType
ImageInfo->ImageSignatureType : 0x0 [Type: unsigned long]
2: kd> dx ImageInfo->ImageSignatureLevel
ImageInfo->ImageSignatureLevel : 0x0 [Type: unsigned long]
Here, ImageSignatureType 0x0 corresponds to SeImageSignatureNone, and ImageSignatureLevel 0x0 corresponds to SE_SIGNING_LEVEL_UNCHECKED. These indicate that the file has no trusted signing level, and should not be permitted to load into any protected process.
CI Spotter demo
I’m releasing a PoC with this blog, CI Spotter, that demonstrates how to detect and stop these types of attacks. In the following demo, we can see PPLDump successfully dump lsass.exe. After CI Spotter is installed, PPLDump fails with STATUS_INVALID_SIGNATURE (0xc000a000). This is because CI Spotter terminated the offending process and set that status code.
CI Spotter’s source code can be found here.
Elastic’s got you covered
CI Spotter is a PoC demonstrating the concept. PoCs are nice, but really only useful to a small community. Let’s find this behavior in Elastic Security.
First, install Elastic Security and enable Endpoint Security. Then disable malware protection because it blocks PPLDump from executing. After using PPLDump, search the logs-* index pattern for:
dll.Ext.defense_evasions: "Process Tampering: Code integrity violation"
This will show the compromised services.exe process. From here, you can create a rule to be automatically notified of these events.
You may also notice an additional defense evasion listed in the screenshot above, Process Tampering: Image is locked for access. That’s because PPLDump attempts to be stealthy by using a technique called Phantom DLL hollowing, which we detect and report.
Conclusion
In this blog, I described the observable characteristics of Windows Protected Process Light (PPL) code integrity violations. I then demonstrated how to detect this behavior in real time using both a Windows driver, and Elastic Endpoint Security.
To find threats like process tampering in your environment, install the latest version of Elastic Security on Elastic Cloud, and be sure to take advantage of our quick start training to set yourself up for success. Happy hunting!