The Windows Registry Adventure #8: Practical exploitation of hive memory corruption
At a glance
- Severity
- Low
- Used in attacks
- No flaws named
- Vendors and products
- Windows
- Reported by
- 1 outlet
Posted by Mateusz Jurczyk, Google Project Zero
In the previous blog post, we focused on the general security analysis of the registry and how to effectively approach finding vulnerabilities in it. Here, we will direct our attention to the exploitation of hive-based memory corruption bugs, i.e., those that allow an attacker to overwrite data within an active hive mapping in memory. This is a class of issues characteristic of the Windows registry, but universal enough that the techniques described here are applicable to 17 of my past vulnerabilities, as well as likely any similar bugs in the future. As we know, hives exhibit a very special behavior in terms of low-level memory management (how and where they are mapped in memory), handling of allocated and freed memory chunks by a custom allocator, and the nature of data stored there. All this makes exploiting this type of vulnerability especially interesting from the offensive security perspective, which is why I would like to describe it here in detail.
Similar to any other type of memory corruption, the vast majority of hive memory corruption issues can be classified into two groups: spatial violations (such as buffer overflows):
and temporal violations, such as use-after-free conditions:
In this write up, we will aim to select the most promising vulnerability candidate and then create a step-by-step exploit for it that will elevate the privileges of a regular user in the system, from Medium IL to system-level privileges. Our target will be Windows 11, and an additional requirement will be to successfully bypass all modern security mitigations. I have previously presented on this topic at OffensiveCon 2024 with a presentation titled "Practical Exploitation of Registry Vulnerabilities in the Windows Kernel", and this blog post can be considered a supplement and expansion of the information shown there. Those deeply interested in the subject are encouraged to review the slides and recording available from that presentation.
Where to start: high-level overview of potential options
Let's start with a recap of some key points. As you may recall, the Windows registry cell allocator (i.e., the internal HvAllocateCell, HvReallocateCell, and HvFreeCell functions) operates in a way that is very favorable for exploitation. Firstly, it completely lacks any safeguards against memory corruption, and secondly, it has no element of randomness, making its behavior entirely predictable. Consequently, there is no need to employ any "hive spraying" or other similar techniques known from typical heap exploitation – if we manage to achieve the desired cell layout on a test machine, it will be reproducible on other computers without any additional steps. A potential exception could be carrying out attacks on global, shared hives within HKLM and HKU, as we don't know their initial state, and some randomness may arise from operations performed concurrently by other applications. Nevertheless, even this shouldn't pose a particularly significant challenge. We can safely assume that arranging the memory layout of a hive is straightforward, and if we have some memory corruption capability within it, we will eventually be able to overwrite any type of cell given some patience and experimentation.
The exploitation of classic memory corruption bugs typically involves the following steps:
- Initial memory corruption primitive
- ???
- ???
- ???
- Profit (in the form of arbitrary code execution, privilege escalation, etc.)
The task of the exploit developer is to fill in the gaps in this list, devising the intermediate steps leading to the desired goal. There are usually several such intermediate steps because, given the current state of security and mitigations, vulnerabilities rarely lead directly from memory corruption to code execution in a single step. Instead, a strategy of progressively developing stronger and stronger primitives is employed, where the final chain might look like this, for instance:
In this model, the second/third steps are achieved by finding another interesting object, arranging for it to be allocated near the overwritten buffer, and then corrupting it in such a way as to create a new primitive. However, in the case of hives, our options in this regard seem limited: we assume that we can fully control the representation of any cell in the hive, but the problem is that there is no immediately interesting data in them from an exploitation point of view. For example, the regf format does not contain any data that directly influences control flow (e.g., function pointers), nor any other addresses in virtual memory that could be overwritten in some clever way to improve the original primitive. The diagram below depicts our current situation:
Does this mean that hive memory corruption is non-exploitable, and the only thing it allows for is data corruption in an isolated hive memory view? Not quite. In the following subsections, we will carefully consider various ideas of how taking control of the internal hive data can have a broader impact on the overall security of the system. Then, we will try to determine which of the available approaches is best suited for use in a real-world exploit.
Intra-hive corruption
Let's start by investigating whether overwriting internal hive data is as impractical as it might initially seem.
Performing hive-only attacks in privileged system hives
To be clear, it's not completely accurate to say that hives don't contain any data worth overwriting. If you think about it, it's quite the opposite – the registry stores a vast amount of system configuration, information about registered services, user passwords, and so on. The only issue is that all this critical data is located in specific hives, namely those mounted under HKEY_LOCAL_MACHINE, and some in HKEY_USERS (e.g., HKU\.Default, which corresponds to the private hive of the System user). To be able to perform a successful attack and elevate privileges by corrupting only regf format data (without accessing other kernel memory or achieving arbitrary code execution), two conditions must be met:
- The vulnerability must be triggerable solely through API/system calls and must not require binary control over the hive, as we obviously don't have that over any system hive.
- The target hive must contain at least one key with permissive enough access rights that allow unprivileged users to create values (KEY_SET_VALUE permission) and/or new subkeys (KEY_CREATE_SUB_KEY). Some other access rights might also be necessary, depending on the prerequisites of the specific bug.
Of the two points above, the first is definitely more difficult to satisfy. Many hive memory corruption bugs result from a strange, unforeseen state in the hive structures that can only be generated "offline", starting with full control over the given file. API-only vulnerabilities seem to be relatively rare: for instance, of my 17 hive-based memory corruption cases, less than half (specifically 8 of them) could theoretically be triggered solely by operations on an existing hive. Furthermore, a closer look reveals that some of them do not meet other conditions needed to target system hives (e.g., they only affect differencing hives), or are highly impractical, e.g., require the allocation of more than 500 GB of memory, or take many hours to trigger. In reality, out of the wide range of vulnerabilities, there are really only two that would be well suited for directly attacking a system hive: CVE-2023-23420 (discussed in the "Operating on subkeys of transactionally renamed keys" section of the report) and CVE-2023-23423 (discussed in "Freeing a shallow copy of a key node with CmpFreeKeyByCell").
Regarding the second issue – the availability of writable keys – the situation is much better for the attacker. There are three reasons for this:
- To successfully carry out a data-only attack on a system key, we are usually not limited to one specific hive, but can choose any that suits us. Exploiting hive corruption in most, if not all, hives mounted under HKLM would enable an attacker to elevate privileges.
- The Windows kernel internally implements the key opening process by first doing a full path lookup in the registry tree, and only then checking the required user permissions. The access check is performed solely on the security descriptor of the specific key, without considering its ancestors. This means that setting overly permissive security settings for a key automatically makes it vulnerable to attacks, as according to this logic, it receives no additional protection from its ancestor keys, even if they have much stricter access controls.
- There are a large number of user-writable keys in the HKLM\SOFTWARE and HKLM\SYSTEM hives. They do not exist in HKLM\BCD00000000, HKLM\SAM, or HKLM\SECURITY, but as I mentioned above, only one such key is sufficient for successful exploitation.
To find specific examples of such publicly accessible keys, it is necessary to write custom tooling. This tooling should first recursively list all existing keys within the low-level \Registry\Machine and \Registry\User paths, while operating with the highest possible privileges, ideally as the System user. This will ensure that the process can see all the keys in the registry tree – even those hidden behind restricted parents. It is not worth trying to enumerate the subkeys of \Registry\A, as any references to it are unconditionally blocked by the Windows kernel. Similarly, \Registry\WC can likely be skipped unless one is interested in attacking differencing hives used by containerized applications. Once we have a complete list of all the keys, the next step is to verify which of them are writable by unprivileged users. This can be accomplished either by reading their security descriptors (using RegGetKeySecurity) and manually checking their access rights (using AccessCheck), or by delegating this task entirely to the kernel and simply trying to open every key with the desired rights while operating with regular user privileges. In either case, we should be ultimately able to obtain a list of potential keys that can be used to corrupt a system hive.
Based on my testing, there are approximately 1678 keys within HKLM that grant subkey creation rights to normal users on a current Windows 11 system. Out of these, 1660 are located in HKLM\SOFTWARE, and 18 are in HKLM\SYSTEM. Some examples include:
HKLM\SOFTWARE\Microsoft\CoreShell
HKLM\SOFTWARE\Microsoft\DRM
HKLM\SOFTWARE\Microsoft\Input\Locales (and some of its subkeys)
HKLM\SOFTWARE\Microsoft\Input\Settings (and some of its subkeys)
HKLM\SOFTWARE\Microsoft\Shell\Oobe
HKLM\SOFTWARE\Microsoft\Shell\Session
HKLM\SOFTWARE\Microsoft\Tracing (and some of its subkeys)
HKLM\SOFTWARE\Microsoft\Windows\UpdateApi
HKLM\SOFTWARE\Microsoft\WindowsUpdate\UX
HKLM\SOFTWARE\WOW6432Node\Microsoft\DRM
HKLM\SOFTWARE\WOW6432Node\Microsoft\Tracing
HKLM\SYSTEM\Software\Microsoft\TIP (and some of its subkeys)
HKLM\SYSTEM\ControlSet001\Control\Cryptography\WebSignIn\Navigation
HKLM\SYSTEM\ControlSet001\Control\MUI\StringCacheSettings
HKLM\SYSTEM\ControlSet001\Control\USB\AutomaticSurpriseRemoval
HKLM\SYSTEM\ControlSet001\Services\BTAGService\Parameters\Settings
As we can see, there are quite a few possibilities. The second key on the list, HKLM\SOFTWARE\Microsoft\DRM, has been somewhat popular in the past, as it was previously used by James Forshaw to demonstrate two vulnerabilities he discovered in 2019–2020 (CVE-2019-0881, CVE-2020-1377). Subsequently, I also used it as a way to trigger certain behaviors related to registry virtualization (CVE-2023-21675, CVE-2023-21748, CVE-2023-35357), and as a potential avenue to fill the SOFTWARE hive to its capacity, thereby causing an OOM condition as part of exploiting another bug (CVE-2023-32019). The main advantage of this key is that it exists in all modern versions of the system (since at least Windows 7), and it grants broad rights to all users (the Everyone group, also known as World, or S-1-1-0). The other keys mentioned above also allow regular users write operations, but they often do so through other, potentially more restricted groups such as Interactive (S-1-5-4), Users (S-1-5-32-545), or Authenticated Users (S-1-5-11), which may be something to keep in mind.
Apart from global system hives, I also discovered the curious case of the HKCU\Software\Microsoft\Input\TypingInsights key being present in every user's hive, which permits read and write access to all other users in the system. I reported it to Microsoft in December 2023 (link to report), but it was deemed low severity and hasn't been fixed so far. This decision is somewhat understandable, as the behavior doesn't have direct, serious consequences for system security, but it still can work as a useful exploitation technique. Since any user can open a key for writing in the user hive of any other user, they gain the ability to:
- Fill the entire 2 GiB space of that hive, resulting in a DoS condition (the user and their applications cannot write to HKCU) and potentially enabling exploitation of bugs related to mishandling OOM conditions within the hive.
- Write not just to the "TypingInsights" key in the HKCU itself, but also to any of the corresponding keys in the differencing hives overlaid on top of it. This provides an opportunity to attack applications running within app/server silos with that user's permissions.
- Perform hive-based memory corruption attacks not only on system hives, but also on the hives of specific users, allowing for a more lateral privilege escalation scenario.
As demonstrated, even a seemingly minor weakness in the security descriptor of a single registry key can have significant consequences for system security.
In summary, attacking system hives with hive memory corruption is certainly possible, but requires finding a very good vulnerability that can be triggered on existing keys, without the need to load a custom hive. This is a good starting point, but perhaps we can find a more universal technique.
Abusing regf inconsistency to trigger kernel pool corruption
While hive mappings in memory are isolated and self-contained to some extent, they do not exist in a vacuum. The Windows kernel allocates and manages many additional registry-related objects within the kernel pool space, as discussed in blog post #6. These objects serve as optimization through data caching, and help implement certain functionalities that cannot be achieved solely through operations on the hive space (e.g., transactions, layered keys). Some of these objects are long-lived and persist in memory as long as the hive is mounted. Other buffers are allocated and immediately freed within the same syscall, serving only as temporary data storage. The memory safety of all these objects is closely tied to the consistency of the corresponding data within the hive mapping. After the kernel meticulously verifies the hive validity in CmCheckRegistry and related functions, it assumes that the registry hive's data maintains consistency with its own structure and associated auxiliary structures.
For a potential attacker, this means that hive memory corruption can be potentially escalated to some forms of pool corruption. This provides a much broader spectrum of options for exploitation, as there are a variety of pool allocations used by various parts of the kernel. In fact, I even took advantage of this behavior in my reports to Microsoft: in every case of a use-after-free on a security descriptor, I would enable Special Pool and trigger a reference to the cached copy of that descriptor on the pools through the _CM_KEY_CONTROL_BLOCK.CachedSecurity field. I did this because it is much easier to generate a reliably reproducible crash by accessing a freed allocation on the pool than when accessing a freed but still mapped cell in the hive.
However, this is certainly not the only way to cause pool memory corruption by modifying the internal data of the regf format. Another idea would be, for example, to create a very long "big data" value in the hive (over ~16 KiB in a hive with version ≥ 1.4) and then cause _CM_KEY_VALUE.DataLength to be inconsistent with the _CM_BIG_DATA.Count field, which denotes the number of 16-kilobyte chunks in the backing buffer. If we look at the implementation of the internal CmpGetValueData function, it is easy to see that it allocates a paged pool buffer based on the former value, and then copies data to it based on the latter one. Therefore, if we set _CM_KEY_VALUE.DataLength to a number less than 16344 × (_CM_BIG_DATA.Count - 1), then the next time the value's data is requested, a linear pool buffer overflow will occur.
This type of primitive is promising, as it opens the door to targeting a much wider range of objects in memory than was previously possible. The next step would likely involve finding a suitable object to place immediately after the overwritten buffer (e.g., pipe attributes, as mentioned in this article from 2020), and then corrupting it to achieve a more powerful primitive like arbitrary kernel read/write. In short, such an attack would boil down to a fairly generic exploitation of pool-based memory corruption, a topic widely discussed in existing resources. We won't explore this further here, and instead encourage interested readers to investigate it on their own.
Inter-hive memory corruption
So far in our analysis, we have assumed that with a hive-based memory corruption bug, we can only modify data within the specific hive we are operating on. In practice, however, this is not necessarily the case, because there might be other data located in the immediate vicinity of our bin's mapping in memory. If that happens, it might be possible to seamlessly cross the boundary between the original hive and some more interesting objects at higher memory addresses using a linear buffer overflow. In the following sections, we will look at two such scenarios: one where the mapping of the attacked hive is in the user-mode space of the "Registry" process, and one where it resides in the kernel address space.
Other hive mappings in the user space of the Registry process
Mapping the section views of hives in the user space of the Registry process is the default behavior for the vast majority of the registry. The layout of individual mappings in memory can be easily observed from WinDbg. To do this, find the Registry process (usually the second in the system process list), switch to its context, and then issue the !vad command. An example of performing these operations is shown below.
0: kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
PROCESS ffffa58fa069f040
SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000
DirBase: 001ae002 ObjectTable: ffffe102d72678c0 HandleCount: 3077.
Image: System
PROCESS ffffa58fa074a080
SessionId: none Cid: 007c Peb: 00000000 ParentCid: 0004
DirBase: 1025ae002 ObjectTable: ffffe102d72d1d00 HandleCount: <Data Not Accessible>
Image: Registry
[...]
0: kd> .process ffffa58fa074a080
Implicit process is now ffffa58f`a074a080
WARNING: .cache forcedecodeuser is not enabled
0: kd> !vad
VAD Level Start End Commit
ffffa58fa207f740 5 152e7a20 152e7a2f 0 Mapped READONLY \Windows\System32\config\SAM
ffffa58fa207dbc0 4 152e7a30 152e7b2f 0 Mapped READONLY \Windows\System32\config\DEFAULT
ffffa58fa207dc60 5 152e7b30 152e7b3f 0 Mapped READONLY \Windows\System32\config\SECURITY
ffffa58fa207d940 3 152e7b40 152e7d3f 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa207dda0 5 152e7d40 152e7f3f 0 Mapped READONLY \Windows\System32\config\SOFTWARE
[...]
ffffa58fa207e840 5 152ec940 152ecb3f 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa207b780 3 152ecb40 152ecd3f 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa0f98ba0 5 152ecd40 152ecd4f 0 Mapped READONLY \EFI\Microsoft\Boot\BCD
ffffa58fa3af5440 4 152ecd50 152ecd8f 0 Mapped READONLY \Windows\ServiceProfiles\NetworkService\NTUSER.DAT
ffffa58fa3bfe9c0 5 152ecd90 152ecdcf 0 Mapped READONLY \Windows\ServiceProfiles\LocalService\NTUSER.DAT
ffffa58fa3ca3d20 1 152ecdd0 152ece4f 0 Mapped READONLY \Windows\System32\config\BBI
ffffa58fa2102790 6 152ece50 152ecf4f 0 Mapped READONLY \Users\user\NTUSER.DAT
ffffa58fa4145640 5 152ecf50 152ed14f 0 Mapped READONLY \Windows\System32\config\DRIVERS
ffffa58fa4145460 6 152ed150 152ed34f 0 Mapped READONLY \Windows\System32\config\DRIVERS
ffffa58fa412a520 4 152ed350 152ed44f 0 Mapped READONLY \Windows\System32\config\DRIVERS
ffffa58fa412c5a0 6 152ed450 152ed64f 0 Mapped READONLY \Users\user\AppData\Local\Microsoft\Windows\UsrClass.dat
ffffa58fa4e8bf60 5 152ed650 152ed84f 0 Mapped READONLY \Windows\appcompat\Programs\Amcache.hve
In the listing above, the "Start" and "End" columns show the starting and ending addresses of each mapping divided by the page size, which is 4 KiB. In practice, this means that the SAM hive is mapped at 0x152e7a20000 – 0x152e7a2ffff, the DEFAULT hive is mapped at 0x152e7a30000 – 0x152e7b2ffff, and so on. We can immediately see that all the hives are located very close to each other, with practically no gaps in between them.
However, this example does not directly demonstrate whether it's possible to place, for instance, the mapping of the SOFTWARE hive directly after the mapping of an app hive loaded by a normal user. The addresses of the system hives appear to be already determined, and there isn't much space between them to inject our own data. Fortunately, hives can grow dynamically, especially when you start writing long values to them. This leads to the creation of new bins and mapping them at new addresses in the Registry process's memory.
For testing purposes, I wrote a simple program that creates consecutive values of 0x3FD8 bytes within a given key. This triggers the allocation of new bins of exactly 0x4000 bytes: 0x3FD8 bytes of data plus 0x20 bytes for the _HBIN structure, 4 bytes for the cell size, and 4 bytes for padding. Next, I ran two instances of it in parallel on an app hive and HKLM\SOFTWARE, filling the former with the letter "A" and the latter with the letter "B". The result of the test was immediately visible in the memory layout:
0: kd> !vad
VAD Level Start End Commit
ffffa58fa67b44c0 8 15280000 152801ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa67b5b40 7 15280200 152803ff 0 Mapped READONLY \Users\user\Desktop\test.dat
ffffa58fa67b46a0 8 15280400 152805ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa67b6540 6 15280600 152807ff 0 Mapped READONLY \Users\user\Desktop\test.dat
ffffa58fa67b5dc0 8 15280800 152809ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa67b4560 7 15280a00 15280bff 0 Mapped READONLY \Users\user\Desktop\test.dat
ffffa58fa67b6900 8 15280c00 15280dff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa67b5280 5 15280e00 15280fff 0 Mapped READONLY \Users\user\Desktop\test.dat
ffffa58fa67b5e60 8 15281000 152811ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa67b7800 7 15281200 152813ff 0 Mapped READONLY \Users\user\Desktop\test.dat
ffffa58fa67b8de0 8 15281400 152815ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffa58fa67b8840 6 15281600 152817ff 0 Mapped READONLY \Users\user\Desktop\test.dat
ffffa58fa67b8980 8 15281800 152819ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
[...]
What we have here are interleaved mappings of trusted and untrusted hives, each 2 MiB in length and tightly packed with 512 bins of 16 KiB each. Importantly, there are no gaps between the end of one mapping and the start of another, which means that it is indeed possible to use memory corruption within one hive to influence the internal representation of another. Take, for example, the boundary between the test.dat and SOFTWARE hives at address 0x15280400000. If we dump the memory area encompassing a few dozen bytes before and after this page boundary, we get the following result:
0: kd> db 0x15280400000-30
00000152`803fffd0 41 41 41 41 41 41 41 41-41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
00000152`803fffe0 41 41 41 41 41 41 41 41-41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
00000152`803ffff0 41 41 41 41 41 41 41 41-41 41 41 41 00 00 00 00 AAAAAAAAAAAA....
00000152`80400000 68 62 69 6e 00 f0 bf 0c-00 40 00 00 00 00 00 00 hbin.....@......
00000152`80400010 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
00000152`80400020 20 c0 ff ff 42 42 42 42-42 42 42 42 42 42 42 42 ...BBBBBBBBBBBB
00000152`80400030 42 42 42 42 42 42 42 42-42 42 42 42 42 42 42 42 BBBBBBBBBBBBBBBB
00000152`80400040 42 42 42 42 42 42 42 42-42 42 42 42 42 42 42 42 BBBBBBBBBBBBBBBB
We can clearly see that the bytes belonging to both hives in question exist within a single, continuous memory area. This, in turn, means that memory corruption could indeed spread from one hive into the other. However, to successfully achieve this result, one would also need to ensure that the specific fragment of the target hive is marked as dirty. Otherwise, this memory page would be marked as PAGE_READONLY, which would lead to a system crash when attempting to write data, despite both regions being directly adjacent to each other.
After successfully corrupting data in a global, system hive, the remainder of the attack would likely involve either modifying a security descriptor to grant oneself write permissions to specific keys, or directly changing configuration data to enable the execution of one's own code with administrator privileges.
Attacking adjacent memory in pool-based hive mappings
Although hive file views are typically mapped in the user-mode space of the Registry process (which contains nothing else but these mappings), there are a few circumstances where this data is stored directly in kernel-mode pools. These cases are as follows:
- All volatile hives, which have no persistent representation as regf files on disk. Examples include the virtual hive rooted at \Registry, as well as the HKLM\HARDWARE hive.
- The entire HKLM\SYSTEM hive, including both its stable and volatile parts.
- All hives that have been recently created by calling one of the NtLoadKey* syscalls on a previously non-existent file, including newly created app hives.
- Volatile storage space of every active hive in the system.
The first point is not useful to a potential attacker because these types of hives do not grant unprivileged users write permissions. The second and third points are also quite limited, as they could only be exploited through memory corruption that doesn't require binary control over the input hive. However, the fourth point makes it possible to exploit vulnerabilities in any hive in the system, including app hives. This is because creating volatile keys does not require any special permissions compared to regular keys. Additionally, if we have a memory corruption primitive within one storage type, we can easily influence data within the other. For example, in the case of stable storage memory corruption, it is enough to craft a value for which the cell index _CM_KEY_VALUE.Data has the highest bit set, and thus points to the volatile space. From this point, we can arbitrarily modify regf structures located in that space, and directly read/write out-of-bounds pool memory by setting a sufficiently long value size (exceeding the bounds of the given bin). Such a situation is shown in the diagram below:
This behavior can be further verified on a specific example. Let's consider the HKCU hive for a user logged into a Windows 11 system – it will typically have some data stored in the volatile storage due to the existence of the "HKCU\Volatile Environment" key. Let's first find the hive in WinDbg using the !reg hivelist command:
0: kd> !reg hivelist
---------------------------------------------------------------------------------------------------------------------------------------------
| HiveAddr |Stable Length| Stable Map |Volatile Length| Volatile Map | BaseBlock | FileName
---------------------------------------------------------------------------------------------------------------------------------------------
[...]
| ffff82828fc1a000 | ee000 | ffff82828fc1a128 | 5000 | ffff82828fc1a3a0 | ffff82828f8cf000 | \??\C:\Users\user\ntuser.dat
[...]
As can be seen, the hive has a volatile space of 0x5000 bytes (5 memory pages). Let's try to find the second page of this hive region in memory by translating its corresponding cell index:
0: kd> !reg cellindex ffff82828fc1a000 80001000
Map = ffff82828fc1a3a0 Type = 1 Table = 0 Block = 1 Offset = 0
MapTable = ffff82828fe6a000
MapEntry = ffff82828fe6a018
BinAddress = ffff82828f096009, BlockOffset = 0000000000000000
BlockAddress = ffff82828f096000
pcell: ffff82828f096004
It is a kernel-mode address, as expected. We can dump its contents to verify that it indeed contains registry data:
0: kd> db ffff82828f096000
ffff8282`8f096000 68 62 69 6e 00 10 00 00-00 10 00 00 00 00 00 00 hbin............
ffff8282`8f096010 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
ffff8282`8f096020 38 ff ff ff 73 6b 00 00-20 10 00 80 20 10 00 80 8...sk.. ... ...
ffff8282`8f096030 01 00 00 00 b0 00 00 00-01 00 04 88 98 00 00 00 ................
ffff8282`8f096040 a4 00 00 00 00 00 00 00-14 00 00 00 02 00 84 00 ................
ffff8282`8f096050 05 00 00 00 00 03 24 00-3f 00 0f 00 01 05 00 00 ......$.?.......
ffff8282`8f096060 00 00 00 05 15 00 00 00-dc be 84 0b 6c 21 35 39 ............l!59
ffff8282`8f096070 b9 d0 84 88 ea 03 00 00-00 03 14 00 3f 00 0f 00 ............?...
Everything looks good. At the start of the page, there is a bin header, and at offset 0x20, we see the first cell corresponding to a security descriptor ('sk'). Now, let's see what the !pool command tells us about this address:
0: kd> !pool ffff82828f096000
Pool page ffff82828f096000 region is Paged pool
*ffff82828f096000 : large page allocation, tag is CM16, size is 0x1000 bytes
Pooltag CM16 : Internal Configuration manager allocations, Binary : nt!cm
We are dealing with a paged pool allocation of 0x1000 bytes requested by the Configuration Manager. And what is located right behind it?
0: kd> !pool ffff82828f096000+1000
Pool page ffff82828f097000 region is Paged pool
*ffff82828f097000 : large page allocation, tag is Obtb, size is 0x1000 bytes
Pooltag Obtb : object tables via EX handle.c, Binary : nt!ob
0: kd> !pool ffff82828f096000+2000
Pool page ffff82828f098000 region is Paged pool
*ffff82828f098000 : large page allocation, tag is Gpbm, size is 0x1000 bytes
Pooltag Gpbm : GDITAG_POOL_BITMAP_BITS, Binary : win32k.sys
The next two memory pages correspond to other, completely unrelated allocations on the pool: one associated with the NT Object Manager, and the other with the win32k.sys graphics driver. This clearly demonstrates that in the kernel space, areas containing volatile hive data are mixed with various other allocations used by other parts of the system. Moreover, this technique is attractive because it not only enables out-of-bound writes of controlled data, but also the ability to read this OOB data beforehand. Thanks to this, the exploit does not have to operate "blindly", but it can precisely verify whether the memory is arranged exactly as expected before proceeding with the next stage of the attack. With these kinds of capabilities, writing the rest of the exploit should be a matter of properly grooming the pool layout and finding some good candidate objects for corruption.
The ultimate primitive: out-of-bounds cell indexes
The situation is clearly not as hopeless as it might have seemed earlier, and there are quite a few ways to convert memory corruption in one's own hive space into taking control of other types of memory. All of them, however, have one minor flaw: they rely on prearranging a specific layout of objects in memory (e.g., hive mappings in the Registry process, or allocations on the paged pool), which means they cannot be said to be 100% stable or deterministic. The randomness of the memory layout carries the inherent risk that either the exploit simply won't work, or worse, it will crash the operating system in the process. For lack of better alternatives, these techniques would be sufficient, especially for demonstration purposes. However, I found a better method that guarantees 100% effectiveness by completely eliminating the element of randomness. I have hinted at or even directly mentioned this many times in previous blog posts in this series, and I am, of course, referring to out-of-bounds cell indexes.
As a quick reminder, cell indexes are the hive's equivalent of pointers: they are 32-bit values that allow allocated cells to reference each other. The translation of cell indexes into their corresponding virtual addresses is achieved using a special 3-level structure called a cell map, which resembles a CPU page table:
The C-like pseudocode of the internal HvpGetCellPaged function responsible for performing the cell map walk is presented below:
_CELL_DATA *HvpGetCellPaged(_HHIVE *Hive, HCELL_INDEX Index) {
_HMAP_ENTRY *Entry = &Hive->Storage[Index >> 31].Map
->Directory[(Index >> 21) & 0x3FF]
->Table[(Index >> 12) & 0x1FF];
return (Entry->PermanentBinAddress & (~0xF)) + Entry->BlockOffset + (Index & 0xFFF) + 4;
}
The structures corresponding to the individual levels of the cell map are _DUAL, _HMAP_DIRECTORY, _HMAP_TABLE and _HMAP_ENTRY, and they are accessible through the _CMHIVE.Hive.Storage field. From an exploitation perspective, two facts are crucial here. First, the HvpGetCellPaged function does not perform any bounds checks on the input index. Second, for hives smaller than 2 MiB, Windows applies an additional optimization called "small dir". In that case, instead of allocating the entire Directory array of 1024 elements and only using one of them, the kernel sets the _CMHIVE.Hive.Storage[...].Map pointer to the address of the _CMHIVE.Hive.Storage[...].SmallDir field, which simulates a single-element array. In this way, the number of logical cell map levels remains the same, but the system uses one less pool allocation to store them, saving about 8 KiB of memory per hive. This behavior is shown in the screenshot below:
What we have here is a hive that has a stable storage area of 0xEE000 bytes (952 KiB) and a volatile storage area of 0x5000 bytes (20 KiB). Both of these sizes are smaller than 2 MiB, and consequently, the "small dir" optimization is applied in both cases. As a result, the Map pointers (marked in orange) point directly to the SmallDir fields (marked in green).
This situation is interesting because if the kernel attempts to resolve an invalid cell index with a value of 0x200000 or greater (i.e., with the "Directory index" part being non-zero) in the context of such a hive, then the first step of the cell map walk will reference the out-of-bounds Guard, FreeDisplay, etc. fields as pointers. This situation is illustrated in the diagram below:
In other words, by fully controlling the 32-bit value of the cell index, we can make the translation logic jump through two pointers fetched from out-of-bounds memory, and then add a controlled 12-bit offset to the result. An additional consideration is that in the first step, we reference OOB indexes of an "array" located inside the larger _CMHIVE structure, which always has the same layout on a given Windows build. Therefore, by choosing a directory index that references a specific pointer in _CMHIVE, we can be sure that it will always work the same way on a given version of the system, regardless of any random factors.
On the other hand, a small inconvenience is that the _HMAP_ENTRY structure (i.e., the last level of the cell map) has the following layout:
0: kd> dt _HMAP_ENTRY
nt!_HMAP_ENTRY
+0x000 BlockOffset : Uint8B
+0x008 PermanentBinAddress : Uint8B
+0x010 MemAlloc : Uint4B
And the final returned value is the sum of the BlockOffset and PermanentBinAddress fields. Therefore, if one of these fields contains the address we want to reference, the other must be NULL, which may slightly narrow down our options.
If we were to create a graphical representation of the relationships between structures based on the pointers they contain, starting from _CMHIVE, it would look something like the following:
The diagram is not necessarily complete, but it shows an overview of some objects that can be reached from _CMHIVE with a maximum of two pointer dereferences. However, it is important to remember that not every edge in this graph will be traversable in practice. This is because of two reasons: first, due the layout of the _HMAP_ENTRY structure (i.e. 0x18-byte alignment and the need for a 0x0 value being adjacent to the given pointer), and second, due to the fact that not every pointer in these objects is always initialized. For example, the _CMHIVE.RootKcb field is only valid for app hives (but not for normal hives), while _CMHIVE.CmRm is only set for standard hives, as app hives never have KTM transaction support enabled. So, the idea provides some good foundation for our exploit, but it does require additional experimentation to get every technical detail right.
Moving on, the !reg cellindex command in WinDbg is perfect for testing out-of-bounds cell indexes, because it uses the exact same cell map walk logic as HvpGetCellPaged, and it doesn't perform any additional bounds checks either. So, let's stick with the HKCU hive we were working with earlier, and try to create a cell index that points back to its _CMHIVE structure. We'll use the _CMHIVE → _CM_RM → _CMHIVE path for this. The first decision we need to make is to choose the storage type for this index: stable (0) or volatile (1). In the case of HKCU, both storage types are non-empty and use the "small dir" optimization, so we can choose either one; let's say volatile. Next, we need to calculate the directory index, which will be equal to the difference between the offsets of the _CMHIVE.CmRm and _CMHIVE.Hive.Storage[1].SmallDir fields:
0: kd> dx (&((nt!_CMHIVE*)0xffff82828fc1a000)->Hive.Storage[1].SmallDir)
(&((nt!_CMHIVE*)0xffff82828fc1a000)->Hive.Storage[1].SmallDir) : 0xffff82828fc1a3a0 [Type: _HMAP_TABLE * *]
0xffff82828fe6a000 [Type: _HMAP_TABLE *]
0: kd> dx (&((nt!_CMHIVE*)0xffff82828fc1a000)->CmRm)
(&((nt!_CMHIVE*)0xffff82828fc1a000)->CmRm) : 0xffff82828fc1b038 [Type: _CM_RM * *]
0xffff82828fdcc8e0 [Type: _CM_RM *]
In this case, it is (0xffff82828fc1b038 - 0xffff82828fc1a3a0) ÷ 8 = 0x193. The next step is to calculate the table index, which will be the offset of the _CM_RM.CmHive field from the beginning of the structure, divided by the size of _HMAP_ENTRY (0x18).
0: kd> dx (&((nt!_CM_RM*)0xffff82828fdcc8e0)->CmHive)
(&((nt!_CM_RM*)0xffff82828fdcc8e0)->CmHive) : 0xffff82828fdcc930 [Type: _CMHIVE * *]
0xffff82828fc1a000 [Type: _CMHIVE *]
So, the calculation is (0xffff82828fdcc930 - 0xffff82828fdcc8e0) ÷ 0x18 = 3. Next, we can verify where the CmHive pointer falls within the _HMAP_ENTRY structure.
0: kd> dt _HMAP_ENTRY 0xffff82828fdcc8e0+3*0x18
nt!_HMAP_ENTRY
+0x000 BlockOffset : 0
+0x008 PermanentBinAddress : 0xffff8282`8fc1a000
+0x010 MemAlloc : 0
The _CM_RM.CmHive pointer aligns with the PermanentBinAddress field, which is good news. Additionally, the BlockOffset field is zero, which is also desirable. Internally, it corresponds to the ContainerSize field, which is zero'ed out if no KTM transactions have been performed on the hive during this session – this will suffice for our example.
We have now calculated three of the four cell index elements, and the last one is the offset, which we will set to zero, as we want to access the _CMHIVE structure from the very beginning. It is time to gather all this information in one place; we can build the final cell index using a simple Python function:
>>> def MakeCellIndex(storage, directory, table, offset):
... print("0x%x" % ((storage << 31) | (directory << 21) | (table << 12) | offset))
...
And then pass the values we have established so far:
>>> MakeCellIndex(1, 0x193, 3, 0)
0xb2603000
>>>
So the final out-of-bounds cell index pointing to the _CMHIVE structure of a given hive is 0xB2603000. It is now time to verify in WinDbg whether this magic index actually works as intended.
0: kd> !reg cellindex ffff82828fc1a000 b2603000
Map = ffff82828fc1a3a0 Type = 1 Table = 193 Block = 3 Offset = 0
MapTable = ffff82828fdcc8e0
MapEntry = ffff82828fdcc928
BinAddress = ffff82828fc1a000, BlockOffset = 0000000000000000
BlockAddress = ffff82828fc1a000
pcell: ffff82828fc1a004
Indeed, the _CMHIVE address passed as the input of the command was also printed in its output, which means that our technique works (the extra 0x4 in the output address is there to account for the cell size). If we were to insert this index into the _CM_KEY_VALUE.Data field, we would gain the ability to read from and write to the _CMHIVE structure in kernel memory through the registry value. This represents a very powerful capability in the hands of a local attacker.
Writing the exploit
At this stage, we already have a solid plan for how to leverage the initial primitive of hive memory corruption for further privilege escalation. It's time to choose a specific vulnerability and begin writing an actual exploit for it. This process is described in detail below.
Step 0: Choosing the vulnerability
Faced with approximately 17 vulnerabilities related to hive memory corruption, the immediate challenge is selecting one for a demonstration exploit. While any of these bugs could eventually be exploited with time and experimentation, they vary in difficulty. There is also an aesthetic consideration: for demonstration purposes, it would be ideal if the exploit's actions were visible within Regedit, which narrows our options. Nevertheless, with a significant selection still available, we should be able to identify a suitable candidate. Let's briefly examine two distinct possibilities.
CVE-2022-34707
The first vulnerability that always comes to my mind in the context of the registry is CVE-2022-34707. This is partly because it was the first bug I manually discovered as part of this research, but mainly because it is incredibly convenient to exploit. The essence of this bug is that it was possible to load a hive with a security descriptor containing a refcount very close to the maximum 32-bit value (e.g., 0xFFFFFFFF), and then overflow it by creating a few more keys that used it. This resulted in a very powerful UAF primitive, as the incorrectly freed cell could be subsequently filled with new objects and then freed again any number of times. In this way, it was possible to achieve type confusion of several different types of objects, e.g., by reusing the same cell subsequently as a security descriptor → value node → value data backing cell, we could easily gain control over the _CM_KEY_VALUE structure, allowing us to continue the attack using out-of-bounds cell indexes.
Due to its characteristics, this bug was also the first vulnerability in this research for which I wrote a full-fledged exploit. Many of the techniques I describe here were discovered while working on this bug. Furthermore, the screenshot showing the privilege escalation at the end of blog post #1 illustrates the successful exploitation of CVE-2022-34707. However, in the context of this blog post, it has one fundamental flaw: to set the initial refcount to a value close to overflowing the 32-bit range, it is necessary to manually craft the input regf file. This means that the target can only be an app hive, and thus we wouldn't be able to directly observe the exploitation in the Registry Editor. This would greatly reduce my ability to visually demonstrate the exploit, which is what ultimately led me to look for a better bug.
CVE-2023-23420
This brings us to the second vulnerability, CVE-2023-23420. This is also a UAF condition within the hive, but it concerns a key node cell instead of a security descriptor cell. It was caused by certain issues in the transactional key rename operation. These problems were so deep and affected such fundamental aspects of the registry that this and the related vulnerabilities CVE-2023-23421, CVE-2023-23422 and CVE-2023-23423 were fixed by completely removing support for transacted key rename operations.
In terms of exploitation, this bug is particularly unique because it can be triggered using only API/system calls, making it possible to corrupt any hive the attacker has write access to. This makes it an ideal candidate for writing an exploit whose operation is visible to the naked eye using standard Windows registry utilities, so that's what we'll do. Although the details of massaging the hive layout into the desired state may be slightly more difficult here than with CVE-2022-34707, it's nothing we can't handle. So let's get to work!
Step 1: Abusing the UAF to establish dynamically-controlled value cells
Let's start by clarifying that our attack will target the HKCU hive, and more specifically its volatile storage space. This will hopefully make the exploit a bit more reliable, as the volatile space resets each time the hive is reloaded, and there generally isn't much activity occurring there. The exploitation process begins with a key node use-after-free, and our goal is to take full control over the _CM_KEY_VALUE representation of two registry values by the end of the first stage (why two – we'll get to that in a moment). Once we achieve this goal, we will be able to arbitrarily set the _CM_KEY_VALUE.Data field, and thus gain read/write access to any chosen out-of-bounds cell index. There are many different approaches to how to achieve this, but in my proof-of-concept, I started with the following data layout:
At the top of the hierarchy is the HKCU\Exploit key, which is the root of the entire exploit subtree. Its only role is to work as a container for all the other keys and values we create. Below it, we have the "TmpKeyName" key, which is important for two reasons: first, it stores four values that will be used at a later stage to fill freed cells with controlled data (but are currently empty). Second, this is the key on which we will perform the "rename" operation, which is the basis of the CVE-2023-23420 vulnerability. Below it are two more keys, "SubKey1" and "SubKey2", which are also needed in the exploitation process for transactional deletion, each through a different view of their parent.
Once we have this data layout arranged in the hive, we can proceed to trigger the memory corruption. We can do it exactly as described in the original report in section "Operating on subkeys of transactionally renamed keys", and demonstrated in the corresponding InconsistentSubkeyList.cpp source code. In short, it involves the following steps:
- Creating a lightweight transaction by calling the NtCreateRegistryTransaction syscall.
- Opening two different handles to the HKCU\Exploit\TmpKeyName key within our newly created transaction.
- Performing a transactional rename operation on one of these handles, changing the name to "Scratchpad".
- Transactionally deleting the "SubKey1" and "SubKey2" keys, each through a different parent handle (one renamed, the other not).
- Committing the entire transaction by calling the NtCommitRegistryTransaction syscall.
After successfully executing these operations on a vulnerable system, the layout of our objects within the hive should change accordingly:
We see that the "TmpKeyName" key has been renamed to "Scratchpad", and both its subkeys have been released, but the freed cell of the second subkey still appears on its parent's subkey list. At this point, we want to use the four values of the "Scratchpad" key to create our own fake data structure. According to it, the freed subkey will still appear as existing, and contain two values named "KernelAddr" and "KernelData". Each of the "Container" values is responsible for imitating one type of object, and the most crucial role is played by the "FakeKeyContainer" value. Its backing buffer must perfectly align with the memory previously associated with the "SubKey1" key node. The diagram below illustrates the desired outcome:
All the highlighted cells contain attacker-controlled data, which represent valid regf structures describing the HKCU\Exploit\Scratchpad\FakeKey key and its two values. Once this data layout is achieved, it becomes possible to open a handle to the "FakeKey" using standard APIs such as RegOpenKeyEx, and then operate on arbitrary cell indexes through its values. In reality, the process of crafting these objects after triggering the UAF is slightly more complicated than just setting data for four different values and requires the following steps:
Reproduced in full under licence from Project Zero. © Project Zero. Written by noreply@blogger.com (Google Project Zero).
Coverage
One outlet has carried this so far.
2025-05-28 18:09 UTC
Related stories
- Attackers Exploit Issabel Framework Flaw Enabling Unauthenticated OS Command Execution
The Hacker News · 2026-09-16
- PHP Webshell Campaign Targets WordPress Through Critical WooCommerce Plugin Bug
Infosecurity Magazine · 2026-09-16
- Attacker Hijacks AI Coding Assistant Session, Spreads Shai-Hulud Across About 100 Repositories
The Hacker News · 2026-09-16
- Parallels Desktop Flaw Lets Non-Admin Mac Users Gain Root, but Intel Macs Can't Install Fix
The Hacker News · 2026-09-16
- Google Patches Pixel Modem Flaw Amid Signs of Limited Targeted Exploitation
The Hacker News · 2026-09-16 · exploited