Project Zero
Simple macOS kernel extension fuzzing in userspace with IDA and TinyInst
Posted by Ivan Fratric, Google Project Zero
Recently, one of the projects I was involved in had to do with video decoding on Apple platforms, specifically AV1 decoding. On Apple devices that support AV1 video format (starting from Apple A17 iOS / M3 macOS), decoding is done in hardware. However, despite this, during decoding, a large part of the AV1 format parsing happens in software, inside the kernel, more specifically inside the AppleAVD kernel extension (or at least, that used to be the case in macOS 14/ iOS 17). As fuzzing is one of the techniques we employ regularly, the question of how to effectively fuzz this code inevitably came up.
It should be noted that I wasn’t the first person to look into the problem of Apple kernel extension fuzzing, so before going into the details of my approach, other projects in this space should be mentioned.
In the Fairplay research project, @pwn0rz utilized a custom loader to load the kernel extension into userspace. A coworker tried to run this code on the current AppleAVD extension, however it didn’t work for them (at least not out of the box) so we didn’t end up using it. It should be noted here that my approach also loads the kernel code into userspace, albeit in a more lightweight way.
In the Cinema time! presentation at Hexacon 2022, Andrey Labunets and Nikita Tarakanov presented their approach for fuzzing AppleAVD where the decompiled code was first extracted using IDA and then rebuilt. I used this approach in the past in some more constrained scenarios, however the decompiled code from IDA is not perfect and manual fixing was often required (such as, for example, when IDA would get the stack layout of a function incorrectly).
In the KextFuzz project, Tingting Yin with the co-authors statically instrumented kernel extensions by replacing pointer authentication instructions with a jump to a coverage-collecting trampoline, which results in a partial coverage.
Most recently, the Pishi project by Meysam Firouzi was released just before this research. The project statically instruments kernel extension code by using Ghidra to identify all basic blocks, and then replacing one instruction from each basic block with a branch to a dedicated trampoline. The trampoline records the coverage, executes the replaced instruction and jumps back to the address of the next instruction. This was reported to run on a real device.
Given the existence of these other projects, it is worth saying that my goal was not to create necessarily the “best” method for kernel extension fuzzing, but what for me was the simplest (if we don’t count the underlying complexity of the off-the shelf tools being used). In short, my approach, that will be discussed in detail in other sections, was
- Load AppleAVD extension or full kernelcache into IDA
- Rebase the module to an address that can be reliably allocated in userspace
- Export raw memory using an IDA Python script
- Load exported bytes using custom loader
- Use custom TinyInst module to hook and instrument the extension
- Use Jackalope for fuzzing
All the project code can be found here. Various components will be explained in more detail throughout the rest of the blog post.
Extracting kernel extension codeNormally, on macOS, kernel extensions are packaged inside “kernel collections” files that serve as containers for multiple extensions. At first OS boot (and whenever something is changed with regards to kernel extensions), the kernel extensions needed by the machine are repackaged into what is called the “kernel cache” (kernelcache file on the filesystem). Kernel extensions can be extracted from these caches and collections, but existing tooling can’t really produce individual .dylib files that can be loaded into userspace and run without issues.
However, reverse engineering tooling, specifically IDA Pro which I used in this research, comes with a surprisingly good loader for Apple kernel cache. I haven’t tried how other reverse engineering tools compare, but if they are comparable and someone would like to contribute to the project, I would gladly accept export scripts for these other tools.
So, instead of writing our own loader, we can simply piggyback on IDA’s. The idea is simple:
- we let IDA load the kernel extension we want (or even the entire kernelcache)
- we use IDA to rebase the code so it’s in memory range that is mappable in userspace (see image)
- using a simple IDA Python script, we export for each memory segment its start address, end address, protection flags and raw bytes
- optionally, we can also, using the same script, export all the symbol names and the corresponding addresses so we can later refer to symbols by name
The following image shows rebasing of the kernel extension. This functionality is accessible in IDA via Edit->Segments->Rebase program… menu. When choosing the new base address, it is convenient to only change the high bits which makes it easy to manually convert rebased to original addresses and vice versa when needed. In the example below the image base was changed from 0xFFFFFE000714C470 to 0xAB0714C470.
Figure 1: Rebasing the extension
The IDA script for exporting the data can be found here. You can run it using the following commands in IDA:
sys.path.append('/directory/containing/export/script')
import segexport
segexport.export('/path/to/output/file)
Loading and runningLoading the exported data should now be only the matter of memory mapping the correct addresses and copying the corresponding data from the exported file. You can see it in the load() function here.
However, since we are now loading and running kernel code in userspace, there will be functions that won’t run well or that we would want to change. One example for this are the kernel allocator functions that we’ll want to replace with the system malloc.
One way of replacing these functions would be to rewrite the prolog of each function we want to replace with a jump to its replacement. However, since we will later be using TinyInst to extract code coverage, there is a simpler way. We will simply write a breakpoint instruction to each function we want to replace. Since TinyInst is (among other things) a debugger, it will catch each of these breakpoints and, from the TinyInst process, we can replace the instruction pointer with the address of the corresponding replacement function. More details on this can be found in the next section.
Besides replacing the memory allocation functions, logging functions etc., we will also need to replace all functions that interact with the hardware that we can’t access from userspace (or, in our case, that isn’t even present on the machine). In the case of AV1 parsing code in the AppleAVD kernel extension, a function called AppleAVD::sendCommandToCommandGate gets called, which I assume is meant to communicate with the decoding hardware. Thus, as a part of the harness, this function was replaced with a function that always returns 0 (success).
The final code of the AV1 harness code can be found here. It can be compiled as
clang++ loader.cpp avdharness.cpp -oloader
and might need some additional entitlements to run which can be applied with
codesign --entitlements entitlements.txt -f -s - ./loader
Note that, in the harness code, although I tried to rely on symbol names instead of hardcoded offsets wherever possible, it still contains some struct offsets. This version of the harness was based on macos 14.5 kernel, which was the most recent OS version at the time the loader was written.
Writing a custom TinyInst moduleThis section explains the custom TinyInst module that accompanies the loader (and is required for the correct functioning of the loader). This code doesn’t contain anything specific for a particular kernel extension and thus can be reused as is. If you are not interested in how it works or writing custom TinyInst modules, then you can skip this section.
Firstly, since we will want to extract code coverage for the purposes of fuzzing, we will base our custom module on LiteCov, the “default” TinyInst module for code coverage:
class AVDInst : public LiteCov {
…
};
Secondly, we need a way for our custom loader to communicate with the TinyInst module
- It needs to tell TinyInst which functions in the kext should be replaced with which replacement functions.
- It needs to tell TinyInst where the kext was loaded so that TinyInst can instrument it.
While TinyInst provides an API for function hooking that we could use here, there is also a more direct (albeit, also more low-level) way. From our loader, we will simply call a function at some hardcoded non-mapped address. This will, once again, cause an exception that TinyInst (being a debugger) will catch, read the parameters from registers, do the required action and “return” (by replacing the instruction pointer with the value inside the link register). The loader uses the hardcoded address 0x747265706C616365 to register a replacement and 0x747265706C616366 to tell TinyInst about the address range to instrument:
#define TINYINST_REGISTER_REPLACEMENT 0x747265706C616365
#define TINYINST_CUSTOM_INSTRUMENT 0x747265706C616366
We can catch those in the exception handler of our custom module
bool AVDInst::OnException(Exception *exception_record) {
…
if(exception_address == TINYINST_REGISTER_REPLACEMENT) {
RegisterReplacementHook();
return true;
}
if(exception_address == TINYINST_CUSTOM_INSTRUMENT) {
InstrumentCustomRange();
return true;
}
…
}
and then read the parameters and do the required action
void AVDInst::RegisterReplacementHook() {
uint64_t original_address = GetRegister(X0);
uint64_t replacement_address = GetRegister(X1);
redirects[original_address] = replacement_address;
SetRegister(ARCH_PC, GetRegister(LR));
}
void AVDInst::InstrumentCustomRange() {
uint64_t min_address = GetRegister(X0);
uint64_t max_address = GetRegister(X1);
InstrumentAddressRange("__custom_range__", min_address, max_address);
SetRegister(ARCH_PC, GetRegister(LR));
}
Where InstrumentAddressRange is a recently added TinyInst function that will instrument all code between addresses given in its parameters. “__custom_range__” is simply a name that we give to this region of memory so we can differentiate between multiple instrumented modules (if there are more than one).
Next, TinInst needs to perform the actual function replacements. As explained above, this can be done in the exception handler of our module.
auto iter = redirects.find(exception_address);
if(iter != redirects.end()) {
// printf("Redirecting...\n");
SetRegister(ARCH_PC, iter->second);
return true;
}
This is mostly sufficient for running the kernel extension without instrumenting it (e.g. to collect coverage). However, if we also want to instrument the extension, then the process of instrumentation involves rewriting the extension code in another location and inserting e.g. additional instructions to record coverage. The consequence of this is that our breakpoint instructions (that we inserted for the purpose of redirects) will be rewritten at different addresses. We need to make TinyInst aware of this (as a side note, TinyInst Hook API does this under the hood, but it wasn’t used in this module). We can do this in the InstrumentInstruction function which gets called for every instruction as it’s being instrumented:
InstructionResult AVDInst::InstrumentInstruction(ModuleInfo *module,
Instruction& inst,
size_t bb_address,
size_t instruction_address)
{
auto iter = redirects.find(instruction_address);
if(iter != redirects.end()) {
instrumented_redirects[assembler_->Breakpoint(module)] = iter->second;
return INST_STOPBB;
}
return LiteCov::InstrumentInstruction(module, inst, bb_address, instruction_address);
}
The INST_STOPBB return value tells TinyInst to stop processing the current basic blocks. Since on breakpoints/redirects, we redirect the execution to another function, no other instructions from the same basic block ever get executed and are thus unneeded.
After this, we now know the addresses of breakpoints (and the corresponding replacements) in both instrumented and non-instrumented code. The final exception handler of our custom module looks like this:
bool AVDInst::OnException(Exception *exception_record) {
size_t exception_address;
if(exception_record->type == BREAKPOINT)
{
exception_address = (size_t)exception_record->ip;
} else if(exception_record->type == ACCESS_VIOLATION) {
exception_address = (size_t)exception_record->access_address;
} else {
return LiteCov::OnException(exception_record);
}
if(exception_address == TINYINST_REGISTER_REPLACEMENT) {
RegisterReplacementHook();
return true;
}
if(exception_address == TINYINST_CUSTOM_INSTRUMENT) {
InstrumentCustomRange();
return true;
}
auto iter = redirects.find(exception_address);
if(iter != redirects.end()) {
// printf("Redirecting...\n");
SetRegister(ARCH_PC, iter->second);
return true;
}
iter = instrumented_redirects.find(exception_address);
if(iter != instrumented_redirects.end()) {
// printf("Redirecting...\n");
SetRegister(ARCH_PC, iter->second);
return true;
}
return LiteCov::OnException(exception_record);
}
The entire code, with all the housekeeping functions can be found here.
Fuzzing and findingsOnce our custom module is ready, we still need to make sure TinyInst and Jackalope will use this module instead of the default LiteCov module. See the appropriate patches for TinyInst and Jackalope.
Our harness should now run correctly under TinyInst, both without and with coverage instrumentation:
./Jackalope/build/TinyInst/Release/litecov -- ./loader avd_rebased.dat -f <sample>
Where avd_rebased.dat contains the kernel extension code exported from IDA. We can also add the -trace_basic_blocks flag to trace basic blocks as they are being executed (primarily useful for debugging). We can also run a fuzzing session with Jackalope like this:
./Jackalope/build/Release/fuzzer -in in -out out -t 1000 -delivery shmem -target_module loader -target_method __Z4fuzzPc -nargs 1 -iterations 5000 -persist -loop -cmp_coverage -mute_child -nthreads 6 -- ./loader avd_rebased.dat -m @@
This tells jackalope to run in persistent mode (with the function “fuzz” being looped), with sample delivery over shared memory (-delivery shmem fuzzer flag and -m being implemented in the harness code).
Fuzzing is useful not only for finding bugs in the target, but in our case also for finding bugs in the harness, e.g. finding other kernel functions we need to replace in order for the target to work correctly.
After several iterations of fixups, the harness appeared to be working correctly. However, the fuzzer also caught some crashes that appeared to have been caused by genuine issues in the AV1 parsing code. I did a root cause analysis and reported the issues to Apple. The reports can be seen in the following entries in the Project Zero issue tracker:
- AppleAVD: Large OBU size in AV1_Syntax::Parse_Header reading to out-of-bounds reads
- AppleAVD: Issue with AV1_Syntax::f leading to out-of-bounds reads
- AppleAVD: Integer underflow in AV1_Syntax::Parse_Header leading to out-of-bounds reads
Unfortunately, at the time of reporting these issues I still didn’t have access to a machine with AV1 decoding capabilities. Thus, instead of full end-to-end PoCs, the issues were reported in the form of a full root cause analysis and a binary stream that causes a crash when used as a parameter to a particular decoding function. Eventually, we did get a Macbook with a M3 chip that supports AV1 hardware decoding and tried to reproduce the reported issues. Unsurprisingly, all three issues reproduced exactly the same on the real hardware as in the userspace harness.
ConclusionThe goal of this project was to create userspace kernel extension fuzzing tooling that was as simple as possible, and at least one of the reasons for this simplicity was that it could be easily adapted to other pieces of kernel code. The process is versatile enough that it allowed us to fuzz AV1 parsing code that normally requires hardware we didn’t even have at the time. While the three issues found during this research are not critical, they demonstrate the correctness of the approach and the potential for finding other issues.
From Naptime to Big Sleep: Using Large Language Models To Catch Vulnerabilities In Real-World Code
Posted by the Big Sleep team
Introduction
In our previous post, Project Naptime: Evaluating Offensive Security Capabilities of Large Language Models, we introduced our framework for large-language-model-assisted vulnerability research and demonstrated its potential by improving the state-of-the-art performance on Meta's CyberSecEval2 benchmarks. Since then, Naptime has evolved into Big Sleep, a collaboration between Google Project Zero and Google DeepMind.
Today, we're excited to share the first real-world vulnerability discovered by the Big Sleep agent: an exploitable stack buffer underflow in SQLite, a widely used open source database engine. We discovered the vulnerability and reported it to the developers in early October, who fixed it on the same day. Fortunately, we found this issue before it appeared in an official release, so SQLite users were not impacted.
We believe this is the first public example of an AI agent finding a previously unknown exploitable memory-safety issue in widely used real-world software. Earlier this year at the DARPA AIxCC event, Team Atlanta discovered a null-pointer dereference in SQLite, which inspired us to use it for our testing to see if we could find a more serious vulnerability.
We think that this work has tremendous defensive potential. Finding vulnerabilities in software before it's even released, means that there's no scope for attackers to compete: the vulnerabilities are fixed before attackers even have a chance to use them. Fuzzing has helped significantly, but we need an approach that can help defenders to find the bugs that are difficult (or impossible) to find by fuzzing, and we're hopeful that AI can narrow this gap. We think that this is a promising path towards finally turning the tables and achieving an asymmetric advantage for defenders.
The vulnerability itself is quite interesting, along with the fact that the existing testing infrastructure for SQLite (both through OSS-Fuzz, and the project's own infrastructure) did not find the issue, so we did some further investigation.
Methodology
A key motivating factor for Naptime and now for Big Sleep has been the continued in-the-wild discovery of exploits for variants of previously found and patched vulnerabilities. As this trend continues, it's clear that fuzzing is not succeeding at catching such variants, and that for attackers, manual variant analysis is a cost-effective approach.
We also feel that this variant-analysis task is a better fit for current LLMs than the more general open-ended vulnerability research problem. By providing a starting point – such as the details of a previously fixed vulnerability – we remove a lot of ambiguity from vulnerability research, and start from a concrete, well-founded theory: "This was a previous bug; there is probably another similar one somewhere".
Our project is still in the research stage, and we are currently using small programs with known vulnerabilities to evaluate progress. Recently, we decided to put our models and tooling to the test by running our first extensive, real-world variant analysis experiment on SQLite. We collected a number of recent commits to the SQLite repository, manually removing trivial and documentation-only changes. We then adjusted the prompt to provide the agent with both the commit message and a diff for the change, and asked the agent to review the current repository (at HEAD) for related issues that might not have been fixed.
Discovered Vulnerability
The vulnerability is an interesting one where a special sentinel value -1 is used in an (otherwise) index-typed field iColumn:
7476: struct sqlite3_index_constraint {
7477: int iColumn; /* Column constrained. -1 for ROWID */
7478: unsigned char op; /* Constraint operator */
7479: unsigned char usable; /* True if this constraint is usable */
7480: int iTermOffset; /* Used internally - xBestIndex should ignore */
7481: } *aConstraint; /* Table of WHERE clause constraints */
This pattern creates a potential edge-case that needs to be handled by all code that uses the field, since the expectation would be that a valid column index is non-negative.
The function seriesBestIndex failed to correctly handle this edge-case, resulting in a write into a stack buffer with a negative index when handling a query with a constraint on the rowid column. In the build that we provided to our agent, debug assertions were enabled, and this condition was checked by the assertion at line 706:
619 static int seriesBestIndex(
620 sqlite3_vtab *pVTab,
621 sqlite3_index_info *pIdxInfo
622 ){
...
630 int aIdx[7]; /* Constraints on start, stop, step, LIMIT, OFFSET,
631 ** and value. aIdx[5] covers value=, value>=, and
632 ** value>, aIdx[6] covers value<= and value< */
633 const struct sqlite3_index_constraint *pConstraint;
...
642 for(i=0; i<pIdxInfo->nConstraint; i++, pConstraint++){
643 int iCol; /* 0 for start, 1 for stop, 2 for step */
644 int iMask; /* bitmask for those column */
645 int op = pConstraint->op;
...
705 iCol = pConstraint->iColumn - SERIES_COLUMN_START;
706 assert( iCol>=0 && iCol<=2 );
707 iMask = 1 << iCol;
...
713 if( pConstraint->usable==0 ){
714 unusableMask |= iMask;
715 continue;
716 }else if( op==SQLITE_INDEX_CONSTRAINT_EQ ){
717 idxNum |= iMask;
718 aIdx[iCol] = i;
719 }
720 }
In a release build, however, this assertion is not present, and in our testing (this will vary depending on compiler and optimization level) the subsequent write at line 718 will write below the aIdx buffer, corrupting the least significant 32 bits of the pConstraint pointer, which will be dereferenced in the next iteration of the loop, leading to a likely exploitable condition.
However, given this explanation of the vulnerability – it's not trivial (at least for us) as human researchers to understand precisely how to trigger it – clearly a constraint on the ROWID column would be a good starting point, but more detailed reading of the code would certainly be required. The agent already seems to know a lot more about SQLite than we do, so it can cut some corners!
One common case of this is that the model would immediately use the generate_series virtual table when generating testcases. (We also saw cases where the model researched the available virtual tables first, but it's clearly able to apply pre-existing knowledge here).
Trajectory HighlightsIn this successful run based on Gemini 1.5 Pro, the seed commit was [1976c3f7]; which is a fairly large and non-obvious change. The bug found by our agent is only loosely related to the changes in the seed commit - this is not uncommon in manual variant analysis, understanding one bug in a codebase often leads a researcher to other problems.
Selected highlights are below, with our commentary in italics – all text in the ASSISTANT blocks comes directly from the agent.
The agent produces a reasonable assessment of the changes in the diff, and uses this to form a starting point for research:
ASSISTANT
The CL improves the handling of WHERE terms shared between OR branches in virtual table queries by introducing the termFromWhereClause function. This function retrieves a specific term from a WhereClause, considering nested clauses. Prior to this change, common terms were likely not handled correctly due to an incorrect mapping of term indices, potentially leading to inefficient query plans or incorrect results. The most likely vulnerability would involve incorrect handling of WHERE clause constraints within the allocateIndexInfo and whereLoopAddVirtualOne functions, where the CL has modified how constraint offsets are used with the new helper function. I'll first examine the allocateIndexInfo function.
. . .
After a several (omitted) steps exploring the relevant code, the agent has formed a more concrete hypothesis and attempts to check it by running a modified version of the testcase from the patch, but this doesn't work since the testcase included in the patch relies on an extension that is not available in our configuration:
TOOL(debugger_run)
The program exited without crashing or hitting the breakpoint. The exit code was 1.
The output on STDERR was:
Runtime error near line 2: no such module: tcl
Parse error near line 3: no such table: x1
ASSISTANT
The program terminated because the TCL module was not available. The tests rely on the TCL virtual table module, which needs to be enabled when compiling SQLite. The provided program input is based on the bestindexC.test file, which uses the TCL module to test virtual table best index selection.
To fix this, the program input needs to be changed to use a different virtual table that does not require the TCL module. However, the vulnerability is related to virtual table query planning. So we still need a virtual table to trigger the issue.
I will use the code_browser_source tool to search for examples of built-in virtual tables in SQLite. I'll then adapt the input to use one of these and set a breakpoint in whereLoopAddVirtualOne again.
. . .
The agent adapts to this setback gracefully, recognises the steps it needs to take to fix the testcase, and a few steps later has retrieved all of the information it needs to build a testcase that reaches whereLoopAddVirtualOne:
ASSISTANT
The sqlite3_create_module function registers virtual table modules. The shell.c file contains several instances of this, including the generate_series, fsdir, completion, and expert modules. The json table also looks interesting. I'll focus on generate_series for now as a starting point. I'll try to craft a query that uses this module and might trigger incorrect constraint handling.
. . .
Note above that during the original discovery and reproduction of the vulnerability, the agent was quite vague about the "incorrect constraint handling" that it's trying to trigger; and in this case the full trajectories read a little bit like a human researcher - starting out with one idea, thinking of a few other ones while investigating, and in the end producing a test case that reproduces a slightly different bug. In this case, when whereLoopAddVirtualOne is invoked on a generate_series table, we end up in seriesBestIndex, where the constraint is indeed handled incorrectly.
However, it's particularly interesting to see that once the program has crashed the model does a much better job of explaining things - while producing a root-cause analysis and a full crash report we can see the agent clearly crafting a testcase to trigger the specific, identified edge-case:
ASSISTANT
The relevant field is iColumn inside the sqlite3_index_constraint struct. This field represents the column number of the constraint. To cause the assertion failure, we need a constraint on a column with index greater than 3 or smaller than 1.
Let's craft an SQL query that involves constraints on these columns. The generate_series table has columns named value, start, step and stop. The indexes are:
- value: 0
- start: 1
- step: 2
- stop: 3
To trigger the bug, we can include a constraint on the ROWID. Constraints on the ROWID use iColumn = -1.
Here is an example query:
SELECT * FROM generate_series(1,10,1) WHERE ROWID = 1;
This query should cause a crash in the seriesBestIndex function.
. . .
At the end we get a clear and accurate summary of the issue that's almost ready to report directly!
TOOL(debugger_run)
The program crashed: SIGABRT at :0.
The output on STDERR was:
sqlite3: src/shell.c:6816: int seriesBestIndex(sqlite3_vtab *, sqlite3_index_info *): Assertion `iCol>=0 && iCol<=2' failed.
ASSISTANT(report_success)
The program crashes with the input SELECT * FROM generate_series(1,10,1) WHERE ROWID = 1; due to an assertion failure in function seriesBestIndex.
The assertion assert(iCol>=0 && iCol<=2) fails because the iColumn field in the sqlite3_index_info struct can contain values outside of the valid range for column indexes, such as -1 when a constraint involves the ROWID.
This leads to iCol being calculated as -2, causing the assertion to fail.
What about Fuzzing?Given the apparent simplicity of the reproduction case, an obvious question arises: why wasn’t this bug discovered earlier by traditional fuzzing? The “simple” answer lies in the configuration of the fuzzing harnesses. The harness used by OSS-Fuzz isn't built with the generate_series extension enabled, and the alternative fuzzingshell.c harness contained an older version of the seriesBestIndex function, unaffected by the bug. Although the SQLite AFL repo contains a configuration for fuzzing the same CLI binary that we provided to the Big Sleep agent, it appears not to be widely used.
To understand whether the bug is truly “shallow", we attempted to rediscover it through fuzzing. We followed the fuzzing instructions from the SQLite documentation and used the CLI target. We also verified that the fuzzing corpus contained the required generate_series and rowid keywords before launching an AFL run. However, the issue remained undiscovered after 150 CPU-hours of fuzzing.
We then tried to simplify the task for the fuzzer by, for example, adding the necessary keywords to AFL's SQL dictionary. However, it seems the bug can only be quickly found if the corpus contains an example very close to the crashing input, as code coverage doesn't appear to be a reliable guide for this particular issue.
Admittedly, AFL isn't the most suitable tool for a text-based format like SQL, where most inputs are syntactically invalid and will be rejected by the parser. Nevertheless, it's interesting to compare this result with Michal Zalewski’s blog post on fuzzing SQLite from 2015. Back then, AFL was quite effective at uncovering bugs in SQLite; after years of fuzzing, it seems the tool has reached a natural saturation point. While our results so far seem minor in comparison to the dramatic step-change in effectiveness that came with the release of AFL, it's interesting to see that it has its own strengths and might be able to effectively uncover a distinct set of vulnerabilities.
ConclusionFor the team this is a moment of validation and success - finding a vulnerability in a widely-used and well fuzzed open source project is an exciting result! When provided with the right tools, current LLMs can perform vulnerability research.
However, we want to reiterate that these are highly experimental results. The position of the Big Sleep team is that at present, it's likely that a target-specific fuzzer would be at least as effective (at finding vulnerabilities).
We hope that in the future this effort will lead to a significant advantage to defenders - with the potential not only to find crashing testcases, but also to provide high-quality root-cause analysis, triaging and fixing issues could be much cheaper and more effective in the future. We aim to continue sharing our research in this space, keeping the gap between the public state-of-the-art and private state-of-the-art as small as possible.
The Big Sleep team will continue to work in this space, advancing Project Zero's mission of making 0-day hard.
This isn't just a Project Zero effort any more, and everyone who has contributed to this effort is listed below (names in alphabetical order):
Miltos Allamanis, Martin Arjovsky, Charles Blundell, Lars Buesing, Mark Brand, Sergei Glazunov, Dominik Maier, Petros Maniatis, Guilherme Marinho, Henryk Michalewski, Koushik Sen, Charles Sutton, Vaibhav Tulsyan, Marco Vanotti, Theophane Weber, Dan Zheng
The Windows Registry Adventure #4: Hives and the registry layout
Posted by Mateusz Jurczyk, Google Project Zero
To a normal user or even a Win32 application developer, the registry layout may seem simple: there are five root keys that we know from Regedit (abbreviated as HKCR, HKLM, HKCU, HKU and HKCC), and each of them contains a nested tree structure that serves a specific role in the system. But as one tries to dig deeper and understand how the registry really works internally, things may get confusing really fast. What are hives? How do they map or relate to the top-level keys? Why are some HKEY root keys pointing inside of other root keys (e.g. HKCU being located under HKU)? These are all valid questions, but they are difficult to answer without fully understanding the interactions between the user-mode Registry API and the kernel-mode registry interface, so let's start there.
The high-level viewA simplified diagram of the execution flow taken when an application creates a registry key is shown below:
In this example, Application.exe is a desktop program calling the documented RegCreateKeyEx function, which is exported by KernelBase.dll. The KernelBase.dll library implements RegCreateKeyEx by translating the high-level API parameters passed by the caller (paths, flags, etc.) to internal ones understood by the kernel. It then invokes the NtCreateKey system call through a thin wrapper provided by ntdll.dll, and the execution finally reaches the Windows kernel, where all of the actual work on the internal registry representation is performed.
The declaration of the RegCreateKeyEx function is as follows:
LSTATUS RegCreateKeyExW(
[in] HKEY hKey,
[in] LPCWSTR lpSubKey,
DWORD Reserved,
[in, optional] LPWSTR lpClass,
[in] DWORD dwOptions,
[in] REGSAM samDesired,
[in, optional] const LPSECURITY_ATTRIBUTES lpSecurityAttributes,
[out] PHKEY phkResult,
[out, optional] LPDWORD lpdwDisposition
);
As the first two arguments imply, many registry operations (and especially key opening/creation) are performed on a pair of a base key handle and a relative key path. HKEY is a dedicated type for registry key handles, but it is functionally equivalent to the standard HANDLE type. It can either contain a regular handle to a key object (as managed by the NT Object Manager), or one of a few possible pseudo-handles described in the Predefined Keys list. They are defined in winreg.h with the following numeric values:
#define HKEY_CLASSES_ROOT (( HKEY ) (ULONG_PTR)((LONG)0x80000000) )
#define HKEY_CURRENT_USER (( HKEY ) (ULONG_PTR)((LONG)0x80000001) )
#define HKEY_LOCAL_MACHINE (( HKEY ) (ULONG_PTR)((LONG)0x80000002) )
#define HKEY_USERS (( HKEY ) (ULONG_PTR)((LONG)0x80000003) )
#define HKEY_PERFORMANCE_DATA (( HKEY ) (ULONG_PTR)((LONG)0x80000004) )
#define HKEY_PERFORMANCE_TEXT (( HKEY ) (ULONG_PTR)((LONG)0x80000050) )
#define HKEY_PERFORMANCE_NLSTEXT (( HKEY ) (ULONG_PTR)((LONG)0x80000060) )
#define HKEY_CURRENT_CONFIG (( HKEY ) (ULONG_PTR)((LONG)0x80000005) )
#define HKEY_DYN_DATA (( HKEY ) (ULONG_PTR)((LONG)0x80000006) )
#define HKEY_CURRENT_USER_LOCAL_SETTINGS (( HKEY ) (ULONG_PTR)((LONG)0x80000007) )
As we can see, they all have the highest bit set, which is normally reserved for kernel-mode handles. Thanks to this, the values can never collide with legitimate user-mode handles, and can be freely used as special pseudo-handles. It is the responsibility of the Registry API to translate these values into their corresponding low-level registry paths before calling into the kernel. In other words, predefined keys are a strictly user-mode concept, and the kernel itself has no awareness of them. If someone decided to write a program interacting with the registry directly through system calls rather than the API, they wouldn't have any use of the HKEY_* constants whatsoever.
This explains why the top-level keys don't necessarily represent mutually exclusive subtrees – none of them inherently represent any specific part of the registry, and their meaning is purely conventional. It is up to KernelBase.dll to decide how it handles each of these keys, and this is further highlighted by the existence of the RegOverridePredefKey function, which allows an application to remap them in the context of the local process. In literature, root keys are sometimes described as being "links" to specific registry paths, and while conceptually correct, this may confuse readers who know about the existence of symbolic links, which is a separate, unrelated mechanism. In fact, if we count all the different ways in which access to a registry key can be transparently redirected to another path (either in the user or kernel part of the interface), we end up with at least four:
- User-mode Registry API interpreting top-level keys and translating them into specific internal paths, as discussed above.
- Kernel-mode Configuration Manager following symbolic links in the registry, i.e. keys created with the REG_OPTION_CREATE_LINK flag and with a value named "SymbolicLinkValue" of type REG_LINK.
- Kernel-mode Configuration Manager applying Registry Virtualization to a specific legacy application, and thus redirecting reads/writes to/from subkeys of HKEY_LOCAL_MACHINE\Software to HKEY_USERS\<SID>_Classes\VirtualStore\Machine\Software.
- The user-mode Registry API and kernel-mode Configuration Manager working together to handle so-called "predefined-handle keys" (marked by flag 0x40 in CM_KEY_NODE.Flags). This is a special, undocumented type of key that works similarly to symbolic links, but instead of redirecting to an arbitrary registry path, they redirect to specific 32-bit predefined handles (i.e. one of the HKEY_* keys). They were historically a source of numerous security bugs, and were eventually deprecated in July/December 2023 to address Project Zero issues #2445 + #2452 (CVE-2023-35356) and #2492 (CVE-2023-35633).
This goes to show the extent of legacy/compatibility mechanisms maintained in Windows, and the degree of collaboration between the user-mode and kernel parts of the registry code. But coming back to the subject of top-level keys, it is worth having some general idea of their role in the system going forward. A number of existing resources do a great job of explaining exactly this (see my previous post for references), so I will only provide a very brief overview below. Overall, the most important takeaway is the fact that HKEY_LOCAL_MACHINE and HKEY_USERS are the two integral keys that can be used to access almost all data in the globally visible registry view. The other HKEY_* keys are derivatives of these two (with the exception of HKEY_PERFORMANCE_* which aren't even real keys), and exist mostly for convenience.
Top-level key
Internal registry path
Description
HKEY_CLASSES_ROOT
Merged view of:
- \Registry\Machine\Software\Classes
- \Registry\User\<SID>_Classes
The key contains file name extension associations and COM class registration information. The merging of the two keys is performed in KernelBase.dll, see OpenClassesRootInternal, BaseRegQueryAndMergeValues and other neighboring functions, as well as the HKEY_CLASSES_ROOT Key and Merged View of HKEY_CLASSES_ROOT official articles.
HKEY_CURRENT_USER
\Registry\User\<SID>
Stores configuration associated with the current user.
HKEY_LOCAL_MACHINE
\Registry\Machine
Stores global, system-wide configuration.
HKEY_USERS
\Registry\User
Stores information about all users currently logged in the system.
HKEY_PERFORMANCE_DATA
-
Not a real key: a pseudo-key that allows applications to query performance information through the Registry API.
HKEY_PERFORMANCE_TEXT
-
Not a real key: can be used to query performance information through the Registry API, described in US English.
HKEY_PERFORMANCE_NLSTEXT
-
Not a real key: can be used to query performance information through the Registry API, described in the local system language.
HKEY_CURRENT_CONFIG
\Registry\Machine\System\CurrentControlSet\Hardware Profiles\Current
Stores information about the current hardware profile.
HKEY_DYN_DATA
-
A deprecated equivalent of HKEY_PERFORMANCE_DATA which only existed in Windows 9x. It was designed to be a general store for dynamically generated information, such as performance counters or hardware configuration.
HKEY_CURRENT_USER_LOCAL_SETTINGS
\Registry\User\<SID>_Classes\Local Settings
Stores configuration associated with the current user that is local to the machine and not subject to the roaming profile.
In terms of security, the top-level keys and their subtrees are generally protected in line with common sense. Nearly all subkeys of HKEY_LOCAL_MACHINE (i.e. global system settings) are only writable by Administrators and the system itself. Some of the subtrees are readable by normal users (e.g. HKLM\Software or HKLM\System), while others don't allow any access to restricted accounts (e.g. HKLM\SAM, which stores user credentials). Furthermore, normal users have full access to their own user hives (HKU\<SID> and HKU\<SID>_Classes) and no access to other users' hives, while Administrators have unrestricted access to all of the user hives under HKU.
All in all, this is to ensure that each user has their own local hive to store data in, and can read non-sensitive system configuration, while Administrators have full control over everything in the registry so that they can administer the system effectively. With a basic understanding of the high-level structure of the registry, let's find out more about how it works under the hood.
The low-level viewIf the Windows kernel doesn't use predefined keys in the same way as the user-mode API does, then the question becomes – where does the registry tree start? You have probably already guessed the answer based on this and previous blog posts: it's the \Registry object in the global NT object namespace. Internally, it is a volatile key in a virtual "master" hive (pointed to by nt!CmpMasterHive), with both the key and the hive existing only in memory for organizational purposes. Their setup takes place early in the boot process in the internal CmInitSystem1 function, and the \Registry object can be seen in tools for exploring the object manager namespace, such as WinObj:
This is essentially the entry point to access any registry key in the system. Whenever the object manager is involved with an operation on a path starting with \Registry, it knows to pass execution to the CmpParseKey function, which takes over the operation, parses the remainder of the path, tries to open or create the given key and returns the object to the caller. Since \Registry is a normal key for most intents and purposes, we can query it in WinDbg:
kd> !reg querykey \Registry
Found KCB = ffff8f818bad92f0 :: \REGISTRY
Hive ffff8f818ba88000
KeyNode ffff8f818bada024
[SubKeyAddr] [SubKeyName]
ffff8f818bada244 A
ffff8f818bada16c MACHINE
ffff8f818bada1d4 USER
ffff8f818bada2c4 WC
Use '!reg keyinfo ffff8f818ba88000 <SubKeyAddr>' to dump the subkey details
[ValueType] [ValueName] [ValueData]
Key has no Values
As the output shows, the root key has four subkeys: A, MACHINE, USER and WC. Two of them are already known to us: "Machine" corresponds to HKEY_LOCAL_MACHINE, and "User" corresponds to HKEY_USERS. Furthermore, "A" serves as the root for all private hives loaded as application hives (using the RegLoadAppKey API). The key and any of its subkeys cannot be opened through their fully qualified path (\Registry\A\...) regardless of their security descriptor or the privileges of the caller, because CmpParseKey detects and denies such requests with the STATUS_ACCESS_DENIED error code. This is to guarantee that all app hives remain private and can only be accessed through the handles returned by RegLoadAppKey. Lastly, "WC" likely stands for "Windows Containers" and is the mount point for differencing hives loaded as part of Windows containerization support (introduced in Windows 10 1607). The key is world-readable and isn't subject to any special protections, but it is not mapped to any predefined keys either, so it is only accessible via system calls and not through the official API (with the exception of symbolic links).
A diagram of the low-level view of the registry and how the high-level predefined keys map to it is shown below:
Evidently, the internal registry layout is much more structured than its high-level counterpart, and follows a few basic rules:
- The first and second levels of the tree are predetermined and always the same.
- The third level consists solely of root keys of active hives loaded in the system.
- The fourth and further levels are nested subkeys of the hives.
On a typical installation of Windows, there are usually dozens of hives loaded in the system at any given time, storing a variety of configuration data. Having already used the term "hives" extensively in this and the previous blog posts, let's discuss in more detail what they are and how they work.
Hive filesWhat better way to describe a registry hive than to quote an official definition from a Microsoft article (Registry Hives):
A hive is a logical group of keys, subkeys, and values in the registry that has a set of supporting files loaded into memory when the operating system is started or a user logs in.
In other words, a hive is a standalone database encoded in the regf format that serves a specific purpose in the operating system (if you're curious about the origin of the name, check Why is a registry file called a "hive"?). Hives can be classified based on where they reside (on disk, in memory, or both):
- File-backed
- Unloaded: a hive that is stored persistently on disk but not actively loaded in the system.
- Loaded: a hive that is both stored on disk and currently used by Windows. The in-memory and on-disk representations of the database are continuously synchronized by the kernel to ensure its consistency in the event of a sudden power loss or other system failures.
- Volatile: an ephemeral hive that is not stored on disk and only lives in memory until the next system reboot. Examples include the master hive (corresponding to the \Registry global root) and the hardware hive mounted at \Registry\Machine\HARDWARE. They are not particularly important from a security perspective but their existence is worth noting for completeness.
A majority of hives are file-backed, because their purpose is precisely to store data across long periods of time and multiple reboots. Interestingly, whenever we observe a hive on disk (which may require the "Show hidden files, folders, and drives" option to be checked and "Hide protected operating system files" to be unchecked in Explorer), it is usually not a single file but a collection of files with a common name prefix. For instance, let's have a look at the NTUSER hive in a user's home directory in Windows 11:
All of these files are related to a single hive. Let's briefly go over each file type visible in the screenshot:
- .DAT – this is the core hive file and the only one that is strictly required. The .dat extension is frequently seen in the context of hive files (other less common variants: .hiv and .hve), but it is only customary and the kernel will happily load a hive from any file regardless of its name (for example, none of the system hive files in C:\Windows\system32\config have any extension at all). While the hive is active, the kernel locks the file, preventing it from being simultaneously operated on by any other program.
- .LOG1 and .LOG2 – log files maintained by the Configuration Manager to safeguard the low-level recoverability of the hive. When they are used, every write operation to the hive is first written to a log file, and later flushed to the hive file itself. For a deeper analysis of the logging mechanism, please refer to the Registry chapter in the Windows Internals book (specifically the "Stable storage" and "Incremental logging" sections). Although hive files are typically accompanied by .LOG1/.LOG2 files in most real-life scenarios, it is also possible to have a single .LOG file (by loading the hive with the REG_HIVE_SINGLE_LOG flag), or prevent the kernel from creating them completely by using the REG_OPEN_READ_ONLY or REG_IMMUTABLE flags.
- .regtrans-ms and .blf – additional log files related to the Kernel Transaction Manager and transactional registry. They store information about pending operations performed on transacted keys opened with the RegCreateKeyTransacted or RegOpenKeyTransacted API functions, and are used to roll-forward these operations in case of a sudden system reboot. They are created at the request of the Configuration Manager, but are internally managed by the Common Log File System driver. They are enabled for system hives (all hives under HKLM have their transaction logs collectively saved in C:\Windows\system32\config\TxR), and for user-specific hives (NTUSER.DAT and UsrClasses.dat), but they are always disabled for application hives (\Registry\A\...) and differencing hives (\Registry\WC\...). It is also possible to force the loading of a hive with disabled transactions by using the REG_HIVE_NO_RM flag.
In summary, a single hive may be associated with up to 10 files on disk. I won't go into the details of their binary formats as this will be discussed in future posts, but it's interesting to note that each file type has been affected by some security issues in the past (see examples for .DAT, .LOG1/.LOG2 and and .regtrans-ms/.blf). Let's now review some of the default hives that are commonly loaded in Windows, and how we can enumerate and examine them.
The hive list and memory mappingsInternally, the Windows kernel maintains a linked list of active hives in the system, starting with the global nt!CmpHiveListHead object. But what's interesting is that the information is also made globally visible through the registry itself: there is a special key at \Registry\Machine\System\CurrentControlSet\Control\hivelist where the kernel maintains a single string value per loaded hive, with its name indicating the registry mount point and its data specifying the low-level path on disk. All active file-backed hives in the system except application hives are listed there, and inspecting the list is as simple as finding the "hivelist" key in Regedit:
Here, we can see the standard system hives mounted under \Registry\Machine, several user hives under \Registry\User (some of them for system accounts and some for the current user), and a number of differencing hives at \Registry\WC. A similar, but much more detailed list of hives may be obtained by using the !reg hivelist command in WinDbg:
kd> !reg hivelist
-------------------------------------------------------------------------------------------------------------------------------------------------
| HiveAddr |Stable Length| Stable Map |Volatile Length| Volatile Map |MappedViews | FileName
-------------------------------------------------------------------------------------------------------------------------------------------------
| ffff8f818ba88000 | 2000 | ffff8f818ba88128 | 1000 | ffff8f818ba883a0 | ffff8f818bad5000 | <NONAME>
| ffff8f818ba62000 | d8c000 | ffff8f818badc000 | 41000 | ffff8f818ba623a0 | ffff8f818badb000 | SYSTEM
| ffff8f818bb87000 | 24000 | ffff8f818bb87128 | 10000 | ffff8f818bb873a0 | ffff8f818bb5a000 | <NONAME>
| ffff8f818c813000 | 4c4b000 | ffff8f818e482000 | 330000 | ffff8f8190b98000 | ffff8f818e470000 | emRoot\System32\Config\SOFTWARE
| ffff8f818e578000 | 8000 | ffff8f818e578128 | 0 | 0000000000000000 | ffff8f818e4f9000 | kVolume1\EFI\Microsoft\Boot\BCD
| ffff8f818c75b000 | 74000 | ffff8f818c75b128 | 1000 | ffff8f818c75b3a0 | ffff8f818e5d4000 | temRoot\System32\Config\DEFAULT
| ffff8f818e773000 | 9000 | ffff8f818e773128 | 1000 | ffff8f818e7733a0 | ffff8f818e9be000 | emRoot\System32\Config\SECURITY
| ffff8f818e9a8000 | d000 | ffff8f818e9a8128 | 0 | 0000000000000000 | ffff8f818ea2c000 | \SystemRoot\System32\Config\SAM
| ffff8f818ec68000 | 2f000 | ffff8f818ec68128 | 1000 | ffff8f818ec683a0 | ffff8f818ea54000 | files\NetworkService\NTUSER.DAT
| ffff8f818ee2e000 | 30000 | ffff8f818ee2e128 | 0 | 0000000000000000 | ffff8f818edf9000 | rofiles\LocalService\NTUSER.DAT
| ffff8f818ee63000 | 72000 | ffff8f818ee63128 | 0 | 0000000000000000 | ffff8f818ee48000 | \SystemRoot\System32\Config\BBI
| ffff8f8190370000 | 19b000 | ffff8f8190370128 | 4000 | ffff8f81903703a0 | ffff8f81903e7000 | \??\C:\Users\user\ntuser.dat
| ffff8f8190373000 | 2cf000 | ffff8f81903fb000 | 0 | 0000000000000000 | ffff8f81903eb000 | \Microsoft\Windows\UsrClass.dat
| ffff8f8191a2e000 | 7000 | ffff8f8191a2e128 | 0 | 0000000000000000 | ffff8f8191a8c000 | 5n1h2txyewy\ActivationStore.dat
| ffff8f8191a30000 | 1c000 | ffff8f8191a30128 | 0 | 0000000000000000 | ffff8f8191a93000 | 5n1h2txyewy\ActivationStore.dat
| ffff8f8191a32000 | 78000 | ffff8f8191a32128 | 0 | 0000000000000000 | ffff8f8191a9a000 | 5n1h2txyewy\ActivationStore.dat
| ffff8f8191bf7000 | 1f3000 | ffff8f8191bf7128 | 0 | 0000000000000000 | ffff8f8191bfb000 | \AppCompat\Programs\Amcache.hve
[...]
This output is missing registry paths, but provides us with the file names, and also contains addresses of the corresponding HHIVE/CMHIVE structures and information about the stable/volatile spaces of each hive. It also shows all active hives in the system, including app hives and volatile hives. For example, the first and third items on the list are the \Registry and \Registry\Machine\HARDWARE hives, respectively – since neither of them are file-backed, their FileName columns state "<NONAME>".
The final way to enumerate hives in the system is by listing section objects. In modern versions of Windows, most registry hives are mapped in the user address space of a special, thin process named Registry (you can find it in Task Manager), using sections. The two notable exceptions are the SYSTEM hive and any hives that don't exist on disk at the time of the loading (i.e. are created from scratch using the NtLoadKey* call). But for all other hives, we can have them printed out in WinDbg by finding the Registry process, switching to its context, and issuing the !vad command to display its associated virtual address descriptors (VADs):
kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
PROCESS ffffb3047dcf4040
SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000
DirBase: 001ae002 ObjectTable: ffff8f818ba85f00 HandleCount: 3193.
Image: System
PROCESS ffffb3047ddc0080
SessionId: none Cid: 0040 Peb: 00000000 ParentCid: 0004
DirBase: 02db7002 ObjectTable: ffff8f818ba02c00 HandleCount: 0.
Image: Registry
[...]
kd> .process ffffb3047ddc0080
Implicit process is now ffffb304`7ddc0080
WARNING: .cache forcedecodeuser is not enabled
kd> !vad
VAD Level Start End Commit
ffffb3047e3653e0 6 28700000 287001ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e364a80 5 28700200 287003ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e365020 6 28700400 287005ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e364760 4 28700600 287007ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e364e40 6 28700800 287009ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e364d00 5 28700a00 28700bff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e3646c0 6 28700c00 28700dff 2 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e364800 3 28700e00 28700fff 2 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e3648a0 6 28701000 287011ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e365160 5 28701200 287013ff 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3048058e450 6 28701400 2870147f 114 Mapped READONLY \Windows\System32\config\BBI
ffffb30480f68890 4 28701480 2870167f 19 Mapped READONLY \Users\user\NTUSER.DAT
ffffb30480f690b0 5 28701680 2870187f 5 Mapped READONLY \Users\user\AppData\Local\Microsoft\Windows\UsrClass.dat
ffffb30480f69e70 2 28701880 2870197f 2 Mapped READONLY \Users\user\AppData\Local\Microsoft\Windows\UsrClass.dat
[...]
ffffb3047e364580 6 2877fca0 2877fe9f 0 Mapped READONLY \Windows\System32\config\SOFTWARE
ffffb3047e364c60 5 2877fea0 2877feaf 8 Mapped READONLY \EFI\Microsoft\Boot\BCD
ffffb3047e366b00 6 2877feb0 2877ff2f 19 Mapped READONLY \Windows\System32\config\DEFAULT
ffffb3047e372ae0 3 2877ff30 2877ff3f 0 Mapped READONLY \Windows\System32\config\SECURITY
ffffb3047e3745c0 5 2877ff40 2877ff4f 0 Mapped READONLY \Windows\System32\config\SAM
[...]
There may be multiple VADs associated with a single hive, because hives are mapped in a fragmented manner and may dynamically grow over time. The address ranges specified by the "Start" and "End" columns correspond to the "pcell" addresses returned by the !reg cellindex WinDbg command when multiplied by 0x1000 (the page size). For example, the BCD hive is mapped between 0x2877fea0000 and 0x2877feaffff. The first page (the 4 KiB hive header) is typically not mapped, but we can see the raw data of the first bin residing at address 0x2877fea1000:
kd> db 0x2877fea1000
00000287`7fea1000 68 62 69 6e 00 00 00 00-00 10 00 00 00 00 00 00 hbin............
00000287`7fea1010 00 00 00 00 a5 7a 66 c9-8b 03 da 01 00 00 00 00 .....zf.........
00000287`7fea1020 a0 ff ff ff 6e 6b 2c 00-c1 e4 70 f6 e3 8b da 01 ....nk,...p.....
00000287`7fea1030 03 00 00 00 48 04 00 00-02 00 00 00 00 00 00 00 ....H...........
00000287`7fea1040 48 02 00 00 ff ff ff ff-00 00 00 00 ff ff ff ff H...............
00000287`7fea1050 68 01 00 00 ff ff ff ff-16 00 00 00 00 00 00 00 h...............
00000287`7fea1060 00 00 00 00 00 00 00 00-00 00 00 00 0c 00 00 00 ................
00000287`7fea1070 4e 65 77 53 74 6f 72 65-52 6f 6f 74 00 00 00 00 NewStoreRoot....
Default system hivesKnowing the basic structure of the registry tree and how we can enumerate hives, let's now briefly discuss the role and properties of the default hives that we'll find in a clean installation of Windows:
Registry path
Description
\Registry\A\{RANDOM GUID}
Application hives may contain any information the client program decides to save, but two of its primary uses are by Universal Windows Platform (UWP) applications to store information about their WinRT classes (in a file called ActivationStore.dat) and their private settings (backed by settings.dat). The lifespan of app hives is tied to their active references, so whenever all handles to the hive are closed (or the process terminates), the hive is automatically unloaded. The security rights set on the keys in app hives are generally irrelevant, because they cannot be opened by any process that doesn't have a valid handle to the hive to start with, thanks to the extra checks hardcoded in CmpParseKey.
\Registry\Machine\BCD00000000
The hive contains the Boot Configuration Database (BCD) that replaced the boot.ini file in Windows Vista. It is backed by a hidden \EFI\Microsoft\Boot\BCD file, and can be modified indirectly with the bcdedit command line utility. Full access is granted to administrators, and no access is granted to normal users.
\Registry\Machine\HARDWARE
The hive contains a specification of the system's legacy hardware (such as a keyboard or mouse). As previously mentioned, it is a volatile hive without a backing file, which is dynamically generated during boot in the internal CmpInitializeHardwareConfiguration function. It grants full access to administrators and read access to normal users.
\Registry\Machine\SAM
The hive contains information managed by the Security Account Manager (SAM), such as user passwords and group definitions. It is backed by the C:\Windows\system32\config\SAM file, and it is the only hive with lazy flushing disabled, meaning that it must be manually synchronized with the backing file by the SAM Server using syscalls like NtFlushKey and NtRestoreKey. It grants full access to the LocalSystem account and no access to any of the users, so even administrators cannot modify or browse it by default.
\Registry\Machine\SECURITY
The hive contains security-critical information such as security policies and user right assignments. It is backed by C:\Windows\system32\config\SECURITY, and similarly to SAM, it only grants access to the LocalSystem account.
\Registry\Machine\SOFTWARE
The hive is a general-purpose store for system-wide configuration of both Windows built-in components and third-party software. It is generally the only hive in HKLM where legitimate programs ever need to write to, and for this reason it is the only one subject to registry virtualization. It is backed by the C:\Windows\system32\config\SOFTWARE file, and a majority of its keys are user-readable and admin-writable. However, there are some exceptions to this rule: firstly, there are several default keys with more permissive rights (e.g. user-writable) and some default keys with more restrictive rights (e.g. only readable by specific users and not broadly accessible). Secondly, the responsibility to set adequate security of the keys is shared by all applications using the hive, but in practice, their behavior isn't always consistent with Windows (i.e. they may set more or less permissive rights than we would normally expect).
\Registry\Machine\SYSTEM
The hive is a store for system-wide configuration that is required to boot the system, such as information about device drivers and system services. It is backed by the C:\Windows\system32\config\SYSTEM file and it is first loaded in memory before the Windows kernel itself, by a copy of the Configuration Manager found in the winload.exe/winload.efi executables. It is also the only system hive mapped in kernel paged pool and not in the user-space of the special Registry process. Similarly to SOFTWARE, most of its keys are user-readable and admin-writable, but there are some notable exceptions to this rule.
\Registry\User\<SID>
The user hives are meant to store user-specific configuration; when a new user is created, the user hive starts off with a basic structure and grows larger as applications save their settings over time. They are typically backed by the NTUSER.DAT file in the user directory, but can also be overridden by NTUSER.MAN by taking advantage of the Mandatory User Profiles. Their security is arranged such that they are accessible by the associated user and administrators, but not other users in the system.
A special case of user hives are the hives corresponding to system-specific accounts which are mounted and globally visible under \Registry\User:
- \Registry\User\.DEFAULT – the user hive of the LocalSystem account, backed by C:\Windows\system32\config\DEFAULT. If you think this is a confusing name, I agree, and so does Raymond Chen in his The .Default user is not the default user blog post.
- \Registry\User\S-1-5-18 – this is the SID of the LocalSystem account, so the key is simply a symbolic link to \Registry\User\.DEFAULT.
- \Registry\User\S-1-5-19 – the user hive of the LocalService account, backed by C:\Windows\ServiceProfiles\LocalService\NTUSER.DAT.
- \Registry\User\S-1-5-20 – the user hive of the NetworkService account, backed by C:\Windows\ServiceProfiles\NetworkService\NTUSER.DAT.
\Registry\User\<SID>_Classes
The "classes" hive is the second one that gets automatically loaded once a user logs into the system. It is responsible for storing the user-specific file extension associations and COM class registrations, which are then merged with \Registry\Machine\Software\Classes by the Registry API and exposed as the HKEY_CLASSES_ROOT top-level key. It is additionally used by the registry virtualization mechanism, and is pointed to by the \Registry\User\<SID>\Software\Classes symbolic link. The hive is backed by C:\Users\<USER>\AppData\Local\Microsoft\Windows\UsrClass.dat, and has the same security properties as the regular user hive: it is only accessible by the owner user and administrators.
\Registry\WC\Silo{...}
Differencing hives are a new technology introduced in Windows 10 Anniversary Update to support registry containerization in various Windows technologies such as Helium containers (application silos, used e.g. by MSIX packaged apps) or Argon containers (server silos, used e.g. by Docker). They are managed by the AppInfo service through VRegDriver – a special module built into ntoskrnl.exe that is tightly integrated with the Configuration Manager and exposes an IOCTL interface to operate on delta hives. Many default Windows components and programs (e.g. the Widgets app, Paint, Notepad) run in the context of app silos, so there are typically a number of hives mounted under \Registry\WC at any given time. Other than that, there isn't much that can be said about them as a whole, as their backing files and security settings can vary greatly depending on multiple factors. These special hives will be discussed in more detail in future blog posts.
There are a few other system hives that can be found in C:\Windows\system32\config\ on modern versions of Windows that aren't discussed above:
- BBI – likely Background Broker Infrastructure, loaded during boot as an app hive by the Background Tasks Infrastructure Service (bisrv.dll).
- COMPONENTS – contains information associated with Windows Update and the Component Based Servicing (CBS) stack. It isn't always active, but instead, it is loaded and unloaded on demand whenever a component installation or update takes place.
- DRIVERS – driver information store, loaded by the kernel on demand at \Registry\Machine\DRIVERS.
- ELAM – Early Launch Anti-Malware, briefly loaded during boot at \Registry\Machine\ELAM.
Note that these hives exist, but they aren't particularly interesting in the context of this research. Now, equipped with the knowledge of how the default Windows hives function, let's see what operations can be performed on them as whole objects.
Hive operationsThe Windows kernel provides several system calls for operating on registry hives, which are summarized in the following table:
Syscall name(s)
Description
NtCompressKey
Compresses the specific hive in-place by defragmenting allocated cells.
NtLoadKey*
Loads an existing hive or creates a new one, and mounts it in the global tree view. Documented counterparts: RegLoadKey and RegLoadAppKey. The interface is internally used by the 'File > Load Hive...' option in Regedit.
NtReplaceKey
Replaces the backing file of a specific active hive with another file on the next system boot. Documented counterpart: RegReplaceKey.
NtRestoreKey
Copies data from a hive file to the global registry tree. This operation is similar to loading, but doesn't maintain any synchronization between the in-memory and on-disk representations after the hive is loaded (restored). Documented counterpart: RegRestoreKey. This interface is internally used by the 'File > Import...' option in Regedit.
NtSaveKey*, NtSaveMergedKeys
Saves the specific subtree to a file on disk in the regf format. Documented counterparts: RegSaveKey and RegSaveKeyEx. This interface is internally used by the 'File > Export...' option in Regedit. Interestingly, the sole purpose of NtSaveMergedKeys seems to be to provide a NtSaveKey-like functionality for HKEY_CLASSES_ROOT, which, as mentioned earlier, is a special merged view of \Registry\Machine\Software\Classes and \Registry\User\<SID>_Classes implemented in user-mode.
NtUnloadKey*
Unloads a registry hive and unmounts it from the global view. Documented counterpart: RegUnLoadKey. This interface is internally used by the 'File > Unload Hive...' option in Regedit.
The above services provide some valuable insight into what actions can be performed on hives. A careful reader will notice that this list is longer than the table of basic hive operations shown in blog post #2, which is because all of these syscalls (other than NtLoadKey* with the REG_APP_HIVE flag) require administrative privileges (SeBackupPrivilege or SeRestorePrivilege) and are not a widely accessible attack surface. Furthermore, most of them see little to no use in a typical system run time. The only notable exception is NtRestoreKey, which is part of the "RXact" user-mode transaction mechanism used for operating on the SAM hive by the SAM Service, as mentioned in the Windows Kernel registry quota exhaustion may lead to permanent corruption of the SAM database report (CVE-2024-26181).
From a strictly security perspective, the most critical hive-related operation is their loading. Let's dive deeper into how it works.
Loading hivesThere are currently three distinct ways in which registry hives are loaded in Windows:
- Internally by the kernel at boot time. This applies to most of the hives under \Registry\Machine, as well as \Registry\User\.DEFAULT. There is also an optional hive named "OSDATA" that gets loaded under \Registry\Machine\OSDATA if it exists at C:\Windows\system32\config\OSDATA. It is unclear what its role is, and I was unable to find much information during a quick web search. It seems to be used by user-mode libraries whenever RtlIsStateSeparationEnabled returns TRUE, which implies it may be somehow related to State Separation.
- Using one of the NtLoadKey-family system calls, which may be invoked from the kernel, a privileged user-mode service, or a normal user application. In the latter two cases, the request usually comes through a corresponding Registry API function such as RegLoadKey or (more likely) RegLoadAppKey.
- Through the IOCTL interface of the VRegDriver, in order to load a differencing hive under \Registry\WC. This path is taken exclusively by system services responsible for the initialization of application and server silos, such as the AppInfo service.
The first option isn't particularly important because it happens seamlessly before we can influence the system in any way, and we are simply left with the outcome (but if you're curious to learn more, here are some good starting points: CmInitSystem1, CmpInitializeSystemHive, NtInitializeRegistry, CmpInitializeSystemHivesLoad, CmpFinishSystemHivesLoad). Furthermore, option #3 is related to container support and thus mostly outside of the scope of this post, but we will briefly cover it later. Right now, let's focus on the standard interfaces that Windows exposes for loading hives (option #2), as this is where the magic happens.
The primary Registry API function for loading hives is RegLoadKey, whose Unicode version has the following declaration:
LSTATUS RegLoadKeyW(
[in] HKEY hKey,
[in, optional] LPCWSTR lpSubKey,
[in] LPCWSTR lpFile
);
It's quite clear that the function offers limited customization options, as it only takes a handle to the root key, the name of the subkey that will become the mount point, and the path of the hive file to load. In Windows Vista, a second API function called RegLoadAppKey was introduced, designed specifically to allow the caller to load application hives:
LSTATUS RegLoadAppKeyW(
[in] LPCWSTR lpFile,
[out] PHKEY phkResult,
[in] REGSAM samDesired,
[in] DWORD dwOptions,
DWORD Reserved
);
In this case, we lose the ability to specify the path of the hive mount point, which is irrelevant because app hives cannot be accessed through fully-qualified paths anyway. On the other hand, we gain some customizability as we can pass the REG_PROCESS_APPKEY flag via dwOptions, and there is also a Reserved parameter that may be used to further extend the functionality in the future.
Both of these documented functions internally rely on NtLoadKey-family system calls, such as NtLoadKey, NtLoadKey2, NtLoadKeyEx or NtLoadKey3 (admittedly quite an unconventional progression). New iterations have been historically added in subsequent versions of Windows to extend the functionality of the previous syscalls. In the case of NtLoadKeyEx, the definition of this singular system call even changed between Windows Server 2003 and Windows Vista to accommodate new options, which is unusual, as syscall definitions tend to be stable. These developments are illustrated in the image below:
Let's have a closer look at the prototype of NtLoadKeyEx, whose arguments most clearly reflect the evolution of the interface. While the latest NtLoadKey3 function introduced further advancements by making it possible to specify additional impersonation tokens, its definition references an undocumented structure and is thus less suitable as an example. If you're curious to learn more about this newest syscall, see James's Silent Exploit Mitigations for the 1% blog post. Meanwhile, NtLoadKeyEx is defined as follows:
NTSTATUS NtLoadKeyEx(
POBJECT_ATTRIBUTES TargetKey, // since NtLoadKey (Windows NT 3.1)
POBJECT_ATTRIBUTES SourceFile, // since NtLoadKey (Windows NT 3.1)
ULONG Flags, // since NtLoadKey2 (Windows NT 4.0)
HANDLE TrustClassKey, // since NtLoadKeyEx (Windows Server 2003)
HANDLE Event, // since NtLoadKeyEx (Windows Vista)
DWORD DesiredAccess, // since NtLoadKeyEx (Windows Vista)
PHANDLE RootHandle, // since NtLoadKeyEx (Windows Vista)
PIO_STATUS_BLOCK IoStatus // since NtLoadKeyEx (Windows Vista)
);
Here's an overview of the semantics of the parameters:
- TargetKey – specifies the new mount point of the hive, e.g. \Registry\User\<SID>. Corresponds to the combination of hKey/lpSubKey arguments in RegLoadKey.
- SourceFile – specifies the path of the hive on a file system in the internal NT format, e.g. \??\C:\Users\<USER>\NTUSER.DAT. Directly corresponds to the lpFile argument in RegLoadKey.
- Flags – specifies a set of options for loading the hive.
- TrustClassKey – specifies an existing hive within the same "trust class" as the loading hive. Trust classes, introduced in Windows Server 2003, define strict rules for which hives can link to each other, preventing symbolic link attacks (for example, disallowing links from HKCU to HKLM).
The last four arguments are only used for app hives or hives for which the caller explicitly requests an open handle (REG_APP_HIVE or REG_LOAD_HIVE_OPEN_HANDLE flags set):
- Event – specifies an event object that gets signaled when the hive is unloaded. Internally, it gets added to a dynamically allocated array pointed to by CMHIVE.UnloadEventArray. It is most meaningful for application hives, which get automatically unloaded when all references are closed, so the event makes it possible for applications to get notified about this fact and perform any follow up actions.
- DesiredAccess – specifies the access rights requested for the returned root key. It directly corresponds to the samDesired argument in RegLoadAppKey.
- RootHandle – a pointer to a variable that receives the handle to the root key of the hive. This is the same handle that gets returned by RegLoadAppKey.
- IoStatus – an I/O status block that used to be passed to NtCreateFile when opening the hive file. It is currently unused.
The most interesting parameter is probably Flags, which takes a combination of options for loading the hive, and is very illustrative of what is actually possible with the interface. The supported flags are defined in the Windows SDK, both in the user-mode (winnt.h) and kernel-mode headers (wdm.h). The flag namespace is actually shared between the NtLoadKey* syscalls and NtRestoreKey, so I crossed out the flags that are not relevant to loading (mask 0xB):
//
// Key restore & hive load flags
//
#define REG_WHOLE_HIVE_VOLATILE (0x00000001L) // Restore whole hive volatile
#define REG_REFRESH_HIVE (0x00000002L) // Unwind changes to last flush
#define REG_NO_LAZY_FLUSH (0x00000004L) // Never lazy flush this hive
#define REG_FORCE_RESTORE (0x00000008L) // Force the restore process even when we have open handles on subkeys
#define REG_APP_HIVE (0x00000010L) // Loads the hive visible to the calling process
#define REG_PROCESS_PRIVATE (0x00000020L) // Hive cannot be mounted by any other process while in use
#define REG_START_JOURNAL (0x00000040L) // Starts Hive Journal
#define REG_HIVE_EXACT_FILE_GROWTH (0x00000080L) // Grow hive file in exact 4k increments
#define REG_HIVE_NO_RM (0x00000100L) // No RM is started for this hive (no transactions)
#define REG_HIVE_SINGLE_LOG (0x00000200L) // Legacy single logging is used for this hive
#define REG_BOOT_HIVE (0x00000400L) // This hive might be used by the OS loader
#define REG_LOAD_HIVE_OPEN_HANDLE (0x00000800L) // Load the hive and return a handle to its root kcb
#define REG_FLUSH_HIVE_FILE_GROWTH (0x00001000L) // Flush changes to primary hive file size as part of all flushes
#define REG_OPEN_READ_ONLY (0x00002000L) // Open a hive's files in read-only mode
#define REG_IMMUTABLE (0x00004000L) // Load the hive, but don't allow any modification of it
#define REG_NO_IMPERSONATION_FALLBACK (0x00008000L) // Do not fall back to impersonating the caller if hive file access fails
As shown, there are a total of 13 available flags with clear names and comments explaining their purpose. However, not all 8192 combinations of them are valid. Rules and dependencies exist – for example, application hives (REG_APP_HIVE) cannot be loaded with lazy flushing disabled (REG_NO_LAZY_FLUSH). In the next sections, we will explore these limitations, covering the required client privileges, supported flags and allowed mount points.
Standard hive loadingThe most important distinction for the NtLoadKey* system calls is whether the hive is being loaded as an app hive (REG_APP_HIVE flag set) or not. If it's not, then it's considered as "standard" hive loading, the only type that existed prior to Windows Vista.
PrivilegesThe official documentation for RegLoadKey states that both SeRestorePrivilege and SeBackupPrivilege privileges are required to use the function. In practice, however, only SeRestorePrivilege is checked. This is still a privilege that is granted to administrators only, so loading any globally visible hives is not possible for a normal user. As evidence, attempting to use the 'File > Load Hive...' option in Regedit without administrative privileges results in an 'Insufficient privileges' error. This might also explain, at least in part, why the Configuration Manager may not be entirely robust against malformed hives. The original developers likely assumed the code would need to gracefully handle random data corruption, but not necessarily malicious files from untrusted sources.
It is also worth keeping in mind that while a local attacker cannot directly load a normal hive, they may be able to entice the Profile Service to load it on their behalf thanks to Mandatory User Profiles. This mechanism causes the system to prioritize the NTUSER.MAN hive file (if it exists in the user directory) over NTUSER.DAT, and loads it as the user hive when the user signs in. It is thus possible to plant a specially crafted hive under NTUSER.MAN, log out, and log back in to have it loaded by Windows. This comes with some limitations, as the hive needs to have a valid, basic structure expected of the HKCU, and the attacker must be able to create files in %USERPROFILE% and to log in and out (so it precludes certain sandbox escapes etc.). Nevertheless, it may be a valid attack vector, and it was in fact used to demonstrate the practical exploitability of issues #2047, #2048, #2297, #2366, #2419, #2445 and #2492. An even more extreme idea of this kind could involve two local, cooperating users who grant each other access to their respective NTUSER.DAT files and modify them while the other user is signed out (when the hive is not locked). However this is even less practical so we won't spend more time on this scenario.
Mount PointsOne of the core invariants of the Windows registry is that hives may only be mounted within the master hive (CmpMasterHive). This is enforced by CmpDoParseKey when trying to link the new hive into the global tree, and it means that hives cannot be loaded relative to other hives. If we look at the list of the top-level HKEY keys, the only two that represent the master hive are HKEY_LOCAL_MACHINE and HKEY_USERS. And indeed, the 'File > Load Hive...' option in Regedit is only enabled when the focus is on one of the two keys, otherwise it is grayed out:
As mentioned earlier, the current convention is that all hives in the system are typically mounted on the third level of the global registry tree, i.e. below one of \Registry\{A,MACHINE,USER,WC}. However, there is nothing preventing a privileged program from loading a hive directly under the \Registry root, too.
FlagsIn standard hive loading, there are almost no restrictions on the flags that can be used. The only requirement is that if the REG_FLUSH_HIVE_FILE_GROWTH flag is set, then both REG_HIVE_SINGLE_LOG and REG_BOOT_HIVE must be set, too.
UnloadingNormal hives stay loaded until they are manually unloaded or the system is shut down. The former can be achieved with the RegUnLoadKey API, or one of the underlying system calls: NtUnloadKey (since Windows NT 3.1), NtUnloadKeyEx (since Windows XP), or NtUnloadKey2 (since Windows Server 2003). Their prototypes are shown below:
NTSTATUS NtUnloadKey(
POBJECT_ATTRIBUTES TargetKey
);
NTSTATUS NtUnloadKeyEx(
POBJECT_ATTRIBUTES TargetKey,
HANDLE Event
);
NTSTATUS NtUnloadKey2(
POBJECT_ATTRIBUTES TargetKey,
ULONG Flags
);
The first syscall simply takes the path of the target key, the second adds an event that will be signaled when the hive is actually unloaded (in case any references to it are still open and the system call returns STATUS_PENDING), and the third one makes it possible to pass a REG_FORCE_UNLOAD (0x1) flag to force the unload even if any handles are open. All of them require SeRestorePrivilege to call successfully, so a local attacker has no way of using them directly.
Application hive loadingApplication hives are undoubtedly one of the most security-relevant aspects of the registry, as they allow normal users to load fully controlled binary hives in the system. They played a crucial role in my research, having been useful in demonstrating the practical exploitability of 15 vulnerabilities reported to Microsoft.
In technical terms, every hive that is loaded with the REG_APP_HIVE flag is an app hive, and they get special treatment from the kernel in several respects. Let's examine some of the apphive-specific behaviors in the sections below.
PrivilegesContrary to normal hives, loading application hives requires no specific privileges from the caller. The only precondition is that the client has access to at least one file that is encoded as a regf, and the kernel can also open the file with the security token of the process for reading, and potentially writing. This is a trivial prerequisite if the attacker starts with full control over a local user account in the system, but may be potentially more difficult in more constrained environments such as security sandboxes.
Another consideration specific to application hives are their security descriptors. The documentation of the RegLoadAppKey function states:
All keys inside the hive must have the same security descriptor, otherwise the function will fail. This security descriptor must grant the caller the access specified by the samDesired parameter or the function will fail. You cannot use the RegSetKeySecurity function on any key inside the hive.
This doesn't matter much from a security perspective, because if the caller is attempting to load the hive, they most likely already have write access to that file. So regardless of its contents, if the caller is determined to load the hive, they can just modify it accordingly (e.g. change the security descriptors). But it is interesting to note that the above statement is only partially correct: indeed, no new security descriptors can be added to an active app hive, either through a RegSetKeySecurity call or by creating a new key with a custom descriptor (e.g. with a RegCreateKeyEx call with a non-NULL lpSecurityAttributes parameter). However, it is not quite right that RegLoadAppKey will fail if there is more than one security descriptor: currently, there are no such restrictions enforced and app hives can be successfully loaded with any number of descriptors. Furthermore, only KEY_READ access is checked rather than the samDesired mask specified by the caller, and only against the root key instead of all keys in the hive (this ties to there potentially being more than one descriptor). This has been reported to Microsoft in Windows Kernel enforcement of registry app hive security is inconsistent with documentation, and has been acknowledged as a discrepancy between documentation and implementation, but it is unclear if/when any steps will be taken to address it.
Mount PointsApplication hives are subject to even stricter requirements with regards to mount points than normal hives: not only do they have to be mounted in the master hive, but it has to be specifically under \Registry\A. This "A" key is unique in that it and its subkeys are explicitly protected from opening by name. The check takes place in CmpParseKey, and the specific routine responsible for verifying the path is CmpDoesParseEnterRegistryA. Some of these rigorous checks were added and/or refined in response to James's issues #865 and #870 discovered in 2016. This means that the only way to obtain a handle to a key within \Registry\A is through the RootHandle argument to NtLoadKeyEx / NtLoadKey3, which ensures maximum privacy for the application hives. Since the names of the mount points are not used for anything, the only important property is that they don't collide with each other. This is guaranteed by KernelBase.dll in the internal BuildRootKeyName function, which picks the mount point name by generating a random 128-bit number and formatting it as a GUID string.
Another interesting feature of app hives is their ability to be shared between processes. If multiple processes load the same hive without the REG_PROCESS_APPKEY flag, they'll all access the same underlying data. In practice, if an app hive is first loaded at \Registry\A\Test1, and another program tries to load the same hive at \Registry\A\Test2, the kernel will effectively reuse the existing "Test1" hive, providing the caller with a handle to it. Because the mount point is irrelevant to the client after loading, this approach is both safe and efficient.
FlagsIn addition to the flag restrictions imposed on normal hives, application hives disallow REG_NO_LAZY_FLUSH (0x4), REG_START_JOURNAL (0x40) and REG_BOOT_HIVE (0x400), which means that REG_FLUSH_HIVE_FILE_GROWTH (0x1000) is also prohibited. This leaves 0xEBB0 as the supported set of flags.
It's also worth noting that even though it is allowed, REG_HIVE_NO_RM (0x100) doesn't do anything for app hives because KTM transactions are disabled for them anyway. Furthermore, there seems to be a minor bug where specifying both the REG_APP_HIVE (0x10) and REG_LOAD_HIVE_OPEN_HANDLE (0x800) flags will cause the hive to stay loaded indefinitely. This proved useful as an exploitation primitive in issue #2378, but in general it simply leads to a memory leak and locking the hive file until the next reboot.
UnloadingApplication hives don't need to be manually unloaded with any of the NtUnloadKey* system calls, but instead they are automatically unloaded when all handles to the hive's keys are closed. Internally, this is achieved by checking if the reference count of the root key's KCB has dropped to a near-zero value, and scheduling the CmpLateUnloadHiveWorker function to perform the actual cleanup.
Differencing hive loadingDifferencing hives are the third major category of hives and the most recent one, introduced in Windows 10 1607. They are similar to application hives in that they are invisible to the naked eye (you won't typically see them in Regedit), but they are also much more complex, both for loading and operating on. Let's see how they compare to the other types of hives we have discussed so far.
PrivilegesI have to start by saying that it is impossible to load a differencing hive with any of the standard NtLoadKey-family syscalls. These special hives need to be overlaid on top of other hives, and must also be configured as either writethrough or not. Instead of adding yet another system call or extending an existing one to accommodate these settings, Microsoft decided to take a different approach. This explains why none of the REG_* flags listed above reference differencing hives in any way.
In order to support registry containerization, the vendor added a driver called "VRegDriver" that is built into the ntoskrnl.exe executable image. It consists of two main parts: an IOCTL interface accessible at \Device\VRegDriver allowing communication with other system components, and a registry callback (VrpRegistryCallback) that implements the namespace redirection functionality. The generic IOCTL handler is VrpIoctlDeviceDispatch, and it supports nine different operations. One of them is 0x220008, handled by VrpHandleIoctlLoadDifferencingHive. This is the IOCTL used by the AppInfo service to load differencing hives on behalf of a starting Centennial application, and it does so by taking an undocumented structure on input, extracting the necessary information from it and calling directly into the internal CmLoadDifferencingKey routine to load the hive – the same one that NtLoadKey* use, too. (On a side note, there is a similar IOCTL 0x220020 handled by VrpHandleIoctlLoadDifferencingHiveForHost, but it's currently unclear how it's used or how it differs from 0x220008.)
Given all this, directly loading a differencing hive requires the ability to open the \Device\VRegDriver object (only accessible to administrators), as well as having the SeBackupPrivilege and SeRestorePrivilege rights (enforced by the IOCTL handler). Thus, it's clear that the option is not available to normal users. Alternatively, one can take advantage of the legitimate uses of the mechanism – bundle a malicious hive with a MSIX-packaged app or a Docker container that gets installed on the victim machine, and have it loaded as part of normal system operation. Naturally, this significantly limits the attack's practicality and also imposes further constraints on how the hive is loaded.
That said, loading custom differencing hives may not be necessarily required to exploit vulnerabilities related to layered keys. The \Registry\WC key is not locked down the same way as \Registry\A is, so it can be freely enumerated, and its subkeys can be opened and accessed according to their security descriptors. Furthermore, there are a number of default programs in Windows that make use of differencing hives, and in many cases an attacker can simply reuse one of them for their own purposes. For example, out of the 10 bugs I found that involved layered keys, only 2 of them required the ability to load custom differencing hives.
Mount PointsNone of the kernel-mode components enforce any specific requirements with regards to the differencing hive mount points, other than them being located in the master hive (as for every other hive). This means that in theory, they could be loaded anywhere within \Registry or one of its four subkeys. In practice, all services using these hives for their intended purpose always load them under \Registry\WC.
FlagsLet's take a look at my reverse-engineered definition of the input structure expected by VrpHandleIoctlLoadDifferencingHive on Windows 11, which can be found in the CreateAndLoadDifferencingHive.cpp proof-of-concept exploit for issue #2479:
struct VRP_LOAD_DIFFERENCING_HIVE_INPUT {
/* +0x00 */HANDLE hJob;
/* +0x08 */DWORD Unused1;
/* +0x0c */DWORD DiffHiveFlags;
/* +0x10 */DWORD LoadFlags;
/* +0x14 */WORD MountPointLength;
/* +0x16 */WORD HivePathLength;
/* +0x18 */WORD BaseLayerPathLength;
/* +0x1a */WORD Unused2;
/* +0x1c */DWORD Unused3;
/* +0x20 */HANDLE hToken;
/* +0x28 */WCHAR Buffer[1];
};
Here at offset 0x10, LoadFlags represents the same information that is normally passed via the Flags argument to NtLoadKey* system calls. The set of legal flags for differencing hives is very narrow and consists of only three of them: REG_HIVE_NO_RM (0x100), REG_OPEN_READ_ONLY (0x2000) and REG_IMMUTABLE (0x4000).
Moreover, there is also an extra DiffHiveFlags member at offset 0xc that specifies the flags specific to differencing hives. So far, I have observed three supported flags and deduced their meaning to be as follows:
#define DIFF_HIVE_ADD_TO_TRUST_CLASS 1
#define DIFF_HIVE_WRITETHROUGH 2
#define DIFF_HIVE_TRUSTED 4
The first and third flags are related to trust classes and the following of symbolic links, while the second one defines whether the hive is writethrough, which is a special type of differencing hive that redirects all write operations on its keys to the lower layers. If DIFF_HIVE_WRITETHROUGH is set in DiffHiveFlags, then REG_IMMUTABLE must be set in LoadFlags.
UnloadingAccording to my experimentation, differencing hives are generally unloaded when the corresponding silo is destroyed: either by a system service invoking the 0x220018 IOCTL (VrpHandleIoctlUnloadDynamicallyLoadedHives) tearing down the container, or automatically when cleaning up the corresponding job object, which ends up in the internal VrpUnloadDifferencingHive routine. Eventually, both functions call ZwUnloadKey* (a kernel-mode wrapper around NtUnloadKey*), following the standard hive unloading procedure.
ConclusionIn this post, I tried to shed some light on the structure of the registry tree, both at the high-level (WinAPI) and low-level (internal system libraries and the kernel). As shown, the relationship between these two views is complicated and contains many unexpected quirks. At the same time, becoming familiar with this design is invaluable for understanding some of the more advanced registry features and their security ramifications. I haven't been able to find a resource that systematically covers this subject, so I hope the blog post was helpful. In the next installment in the series, I will explain the internal structure of the hives and the many things that may surprise you there.