Hotpatching has been looming over Windows 11 for a while now, having already been shipped on the server & cloud deployments. It first came out in March that the first major version to include it will be 24H2, which can now be confirmed in a few minutes of reversing the kernel or ntdll. The feature turns out to be quite extensive and hooks itself in many core functionalities of the OS. As such, it’s bound to break a few programs here and there, and one of the unlucky victims this time was x64dbg.
热补丁在 Windows 11 上已经出现了一段时间,已经在服务器和云部署中发布。它于 3 月首次发布,第一个包含它的主要版本将是 24H2,现在可以在反转内核或 ntdll 的几分钟内确认。事实证明,该功能非常广泛,并且与操作系统的许多核心功能挂钩。因此,它必然会在这里和那里破坏一些程序,而这次不幸的受害者之一是 x64dbg。
Last week I was doing some debugging on the 24H2 insider build and realized that the memory map view in x64dbg was broken. Instead of showing a single entry for each section of each module, some modules had their sections inlined in a single entry. For example, memory belonging to kernel32.dll looked like this:
上周,我正在对 24H2 内部版本进行一些调试,并意识到 x64dbg 中的内存映射视图已损坏。某些模块不是为每个模块的每个部分显示单个条目,而是将其部分内联在单个条目中。例如,属于kernel32.dll的内存如下所示:
Address Size Party Info
00007FFD053C0000 00000000000C8000 System kernel32.dll, ".text", "fothk", ".rdata", ".data", ".pdata", ".didat", ".rsrc", ".reloc"
Whereas normally, it would show like this:
而通常情况下,它会显示如下:
Address Size Party Info
00007FF975E90000 0000000000001000 System kernel32.dll
00007FF975E91000 000000000007E000 System ".text"
00007FF975F0F000 0000000000033000 System ".rdata"
00007FF975F42000 0000000000002000 System ".data"
00007FF975F44000 0000000000006000 System ".pdata"
00007FF975F4A000 0000000000001000 System ".didat"
00007FF975F4B000 0000000000001000 System ".rsrc"
00007FF975F4C000 0000000000001000 System ".reloc"
After a brief investigation, I found out that the reason it was breaking was an extra page existing at the end of the image region of those modules. The contents of the page didn’t immediately suggest what it’s being used for, so I didn’t initially push my investigation further. But the weirdness didn’t end there, and soon I noticed that I couldn’t do much with this page, as VirtualProtect
failed to work on it, returning STATUS_NOT_COMMITTED
. However, the page was seen as being committed by x64dbg, as well as other memory inspection tools and debuggers.
经过简短的调查,我发现它崩溃的原因是这些模块的图像区域末尾存在一个额外的页面。该页面的内容并没有立即表明它的用途,所以我最初没有进一步推进我的调查。但奇怪的事情并没有就此结束,很快我就注意到我对这个页面无能为力,因为 VirtualProtect
无法处理它,返回 STATUS_NOT_COMMITTED
.但是,该页面被视为由 x64dbg 以及其他内存检查工具和调试器提交。
This sparked my curiosity and I decided to track down the root cause of this behaviour, as I hadn’t encountered anything like it before.. This lead me down the rabbit hole of CFG changes on 24H2 and gave birth to this post.
这激发了我的好奇心,我决定追查这种行为的根本原因,因为我以前从未遇到过这样的事情。这让我陷入了 24H2 CFG 变化的兔子洞,并诞生了这篇文章。
tl;dr tl;博士
- With hotpatching on Windows 11 comes SCP, a new feature whose purpose seems to be to provide relocatable, position-independent functions that can later be hooked painlessly into processes and individual modules. Even though the changes encompass more than CFG code, the majority of it seems to be focused on making CFG functions independent of external code & data.
随着 Windows 11 上的热补丁而来的是 SCP,这是一项新功能,其目的似乎是提供可重新定位的、与位置无关的功能,这些功能以后可以无痛地挂接到进程和单个模块中。尽管这些变化包含的不仅仅是 CFG 代码,但其中大部分似乎都集中在使 CFG 功能独立于外部代码和数据上。 - The primary change is the implementation of new (but functionally the same) CFG functions in their dedicated sections in ntdll (usermode) and kCFG functions in ntoskrnl (kernel). The sections are copied and fixed up into their own dedicated pages at runtime, and these pages are then mapped into both processes and individual modules which satisfy some conditions related to hotpatching.
主要的变化是在 ntdll(用户模式)和 ntoskrnl(内核)的 kCFG 函数的专用部分中实现了新的(但功能相同)CFG 函数。这些部分在运行时被复制并固定到它们自己的专用页面中,然后这些页面被映射到满足与热补丁相关的某些条件的进程和单个模块中。 - There don’t seem to be underlying security improvements and the changes are likely focused only on providing compatibility with hotpatching.
似乎没有潜在的安全改进,这些变化可能只集中在提供与热补丁的兼容性上。
preliminaries 预赛
This post is not about hotpatching. It is a very extensive feature and reversing it would require more effort than I’ve put in here. The purpose of this post is to share implementation details of CFG in 24H2, including both usermode & kernel. The new implementation is somewhat coupled with hotpatching, so we won’t be able to answer some questions, and some details may remain obscured. Nevertheless, I believe that the post should provide a good overview of the changes and pointers for further research. All debugging & reversing was done on the Windows 11 Pro 26100.268 insider build.
这篇文章不是关于热补丁的。这是一个非常广泛的功能,逆转它需要比我在这里投入更多的努力。这篇文章的目的是分享 CFG 在 24H2 中的实现细节,包括用户模式和内核。新的实现在某种程度上与热补丁相结合,因此我们将无法回答一些问题,并且某些细节可能仍然模糊不清。尽管如此,我认为这篇文章应该很好地概述了这些变化和进一步研究的指导。所有调试和反转都是在 Windows 11 Pro 26100.268 内部版本上完成的。
In this post I assume that the reader is acquainted with CFG on windows & its history and that they have moderate knowledge of Windows NT internals. CFG has a long history on Windows and you can learn more about it from these sources:
在这篇文章中,我假设读者熟悉 Windows 上的 CFG 及其历史,并且他们对 Windows NT 内部结构有一定的了解。CFG 在 Windows 上有着悠久的历史,你可以从以下来源了解有关它的更多信息:
- Official MS documentation
官方 MS 文档 - Trend Micro in-depth exploration of CFG (PDF)
趋势科技对 CFG 的深入探索 (PDF) - eXtended Flow Guard Under the Microscope by OffSec
OffSec显微镜下的扩展流量防护装置
The following may also provide some additional context for this post:
以下内容还可以为本文提供一些额外的背景信息:
- Hotpatching on Windows Windows 上的热修补
- Mitigating Spectre variant 2 with Retpoline on Windows
在 Windows 上使用 Retpoline 缓解 Spectre 变体 2
classic cfg 经典CFG
Trend Micro’s document I’ve mentioned above provides a very detailed description of the classic CFG implementation on Windows. We’ll summarize the mechanisms involved here:
我上面提到的趋势科技文档非常详细地描述了 Windows 上的经典 CFG 实现。我们将总结这里涉及的机制:
- The kernel contains a system-wide bitmap whose each bit denotes whether a range of 4 bytes are a valid call target. This bitmap is updated each time an image is loaded or unloaded in the userspace. To expose the bitmap to userspace, the address of the bitmap is written to ntdll’s exported
DllSystemInitBlock
.
内核包含一个系统范围的位图,其每个位表示 4 个字节的范围是否是有效的调用目标。每次在用户空间中加载或卸载图像时,都会更新此位图。若要将位图公开给用户空间,请将位图的地址写入 ntdll 的 exportedDllSystemInitBlock
. - To make use of CFG, a module provides a configuration in its
IMAGE_LOAD_CONFIG_DIRECTORY
debug directory, in the format explained in the docs. Additionally, a table of valid call targets is provided within one of the sections.
为了使用 CFG,模块在其IMAGE_LOAD_CONFIG_DIRECTORY
调试目录中提供配置,格式在文档中说明。此外,其中一个部分中还提供了有效呼叫目标的表。 - The kernel loader reads
GuardCFFunctionTable
andGuardCFFunctionCount
from the directory, walks through the table, and sets the appropriate bits in the system-wide bitmap.
内核加载程序从目录中读取GuardCFFunctionTable
和GuardCFFunctionCount
遍历表,并在系统范围的位图中设置适当的位。 - The usermode loader reads
GuardCFCheckFunctionPointer
&GuardCFDispatchFunctionPointer
and patches them to point towards one of the classic CFG functions, e.g. if CFG is enabled for the process, thenLdrpValidateUserCallTarget
/LdrpDispatchUserCallTarget
or the export-suppression counterparts, otherwise the nop functions.
用户模式加载器读取GuardCFCheckFunctionPointer
&GuardCFDispatchFunctionPointer
并修补它们以指向经典的 CFG 函数之一,例如,如果为进程启用了 CFG,则LdrpValidateUserCallTarget
/LdrpDispatchUserCallTarget
或导出抑制对应物,否则为 nop 函数。 - Classic CFG functions validate (or validate and dispatch) a call target argument that’s passed in
RAX
. They use the system-wide bitmap for that, which they access by looking up its address in theDllSystemInitBlock
, which was previously written by the kernel.
经典 CFG 函数验证(或验证和调度)传入RAX
的调用目标参数。他们为此使用系统范围的位图,他们通过在DllSystemInitBlock
中查找其地址来访问该位图,该地址以前由内核编写。 - Kernel CFG (kCFG) works in a similar manner, but only if virtualization-based security is enabled. This is because VBS is the only way to truly protect the kCFG bitmap from being easily overwritable.
内核 CFG (kCFG) 的工作方式与此类似,但前提是启用了基于虚拟化的安全性。这是因为 VBS 是真正保护 kCFG 位图不容易被重写的唯一方法。
new ntdll sections 新的 NTDLL 部分
The first thing that stands out in the 24H2 build are the new sections in ntdll. There are four of them: SCPCFG, SCPCFGFP, SCPCFGNP, SCPCFGES; and they’re marked as RX, each of them taking only a single page. The structure of contents is the same for each section:
在 24H2 版本中突出的第一件事是 ntdll 中的新部分。其中有四个:SCPCFG、SCPCFGFP、SCPCFGNP、SCPCFGES;它们被标记为 RX,每个只占一页。每个部分的内容结构是相同的:
[+0x00] SCPCFG header
[+0x00][0x04] Offset to dispatch (no es) function
[+0x04][0x04] Offset to dispatch (es) function
[+0x08][0x04] Offset to validate (no es) function
[+0x0C][0x04] Offset to validate (es) function
[+0x10][0x04] Offset to invalid call handler
[+0x14][0x04] Offset to rtl function table
[+0x18][0x28] Unknown // I didn't bother reversing
[+dynamic] Dispatch (no es) function
[+dynamic] Dispatch (es) function
[+dynamic] Validate (no es) function
[+dynamic] Validate (es) function
[+dynamic] Invalid call handler
[+dynamic] Icall handler
[+dynamic] Unwind table
[+0x00][0x0C] Unwind table stuff
[+dynamic] Rtl function table
[+0x00][dynamic] array of RUNTIME_FUNCTION entries
For example, the SCPCFG
section looks like this:
例如,该 SCPCFG
部分如下所示:
[+0x00] SCPCFG header
[+0x00][0x04] = 0x40
[+0x04][0x04] = 0xC0
[+0x08][0x04] = 0x140
[+0x0C][0x04] = 0x1C0
[+0x10][0x04] = 0x240
[+0x14][0x04] = 0x2A4
[+0x18][0x28] = {66 66 66 66 66 66 66 0F 1F 84 00 00 00 00 00 66 66 66 66 66 66 66 0F 1F 84 00 00 00 00 00 66 66 0F 1F 84 00 00 00 00 00}
[+0x40] = ScpCfgDispatchUserCallTarget
[+0xC0] = ScpCfgDispatchUserCallTargetES
[+0x140] = ScpCfgValidateUserCallTarget
[+0x1C0] = ScpCfgValidateUserCallTargetES
[+0x240] = ScpCfgHandleInvalidCallTarget
[+0x280] = ScpCfgICallHandler
[+0x298] = Unwind table
[+0x00][0x0C] = {19 00 00 00 80 02 00 00 00 00 00 00}
[+0x2A4] = Rtl function table
[+0x00][0x0C] {.SectionBegin = 0x00, .SectionEnd = 0x280, .UnwindData = 0x298}
Names of the functions located in all of the sections resemble those of classic CFG functions. Thus it’s not hard to determine the purpose of each function:
位于所有部分中的函数名称类似于经典 CFG 函数的名称。因此,不难确定每个函数的用途:
- The validation & dispatch functions are counterparts of the classic CFG validation & dispatch functions.
验证和调度功能是经典CFG验证和调度功能的对应功能。 - The ES / NO ES distinction is made in regards to export suppression configuration, MS says some stuff about it here.
ES / NO ES 的区别是在导出抑制配置方面进行的,MS 在这里说了一些关于它的内容。 - The invalid call handler is the function that’s called from the dispatch function in case the call target is invalid.
无效的调用处理程序是在调用目标无效的情况下从调度函数调用的函数。 - The icall handler is there to work in conjunction with exception handling, but I’m not entirely sure of the mechanisms involved.
icall 处理程序可以与异常处理结合使用,但我不完全确定所涉及的机制。
Other than these functions, the sections also contain a function table with an unwind table, we’ll later see what these are used for. There are also 40 unknown bytes at the end of the header that I couldn’t understand, as I didn’t see any code accessing them.
除了这些函数之外,这些部分还包含一个带有展开表的函数表,我们稍后将看到它们的用途。标头末尾还有 40 个我无法理解的未知字节,因为我没有看到任何访问它们的代码。
On the surface, the functions between different sections look similar amongst each other, but also very similar to their classic CFG counterparts. The first difference that can be seen is that, where classic CFG functions use the address of the cfg bitmap written to DllSystemInitBlock
, the new functions have a hardcoded 0x0123456789ABCDEF. That looks like a placeholder value that’s supposed to be patched by something later. For example:
从表面上看,不同部分之间的功能看起来很相似,但也与经典的 CFG 对应物非常相似。可以看出的第一个区别是,经典的 CFG 函数使用写入的 DllSystemInitBlock
cfg 位图的地址,而新函数具有硬编码0x0123456789ABCDEF。这看起来像一个占位符值,以后应该由某些东西修补。例如:
mov r11, cs:qword_1801D94F8 <------ bitmap address is stored inside ntdll DllSystemInitBlock in the .mrdata section
mov r10, rax
shr r10, 9
mov r11, [r11+r10*8]
mov r10, rax
shr r10, 3
test al, 0Fh
jnz short loc_fail
bt r11, r10
jnb short loc_fail
jmp rax
...
ntdll!LdrpDispatchUserCall
mov r11, 123456789ABCDEFh <------ hardcoded placeholder value
mov r10, rax
shr r10, 9
mov r11, [r11+r10*8]
mov r10, rax
shr r10, 3
test al, 0Fh
jnz short loc_fail
bt r11, r10
jnb short loc_fail
jmp rax
...
ntdll!ScpCfgDispatchUserCall
There are also meaningful differences between the sections. To demonstrate that, let’s take a look at the implementation of the dispatch function in each of the sections:
各部分之间也存在有意义的差异。为了证明这一点,让我们看一下每个部分中调度函数的实现:
jmp rax
ntdll!ScpCfgDispatchUserCallTarget_Nop
(SCPCFGNP)ntdll!ScpCfgDispatchUserCallTarget_Nop
(SCPCFGNP)
mov r11, 123456789ABCDEFh
mov r10, rax
shr r10, 9
mov r11, [r11+r10*8]
mov r10, rax
shr r10, 3
test al, 0Fh
jnz short loc_fail
bt r11, r10
jnb short loc_fail
jmp rax
...
ntdll!ScpCfgDispatchUserCall
(SCPCFG)ntdll!ScpCfgDispatchUserCall
(SCPCFG)
mov r11, 123456789ABCDEFh
mov r10, rax
shr r10, 9
mov r11, [r11+r10*8]
mov r10, rax
shr r10, 3
test al, 0Fh
jnz short loc_180160069
bt r11, r10
jnb short loc_180160074
jmp rax
...
ntdll!ScpCfgDispatchUserCallTarget_ES
(SCPCFGES)ntdll!ScpCfgDispatchUserCallTarget_ES
(SCPCFGES)
mov r11, 123456789ABCDEFh
mov r11, [r11]
jmp r11
ntdll!ScpCfgDispatchUserCallTarget_Fptr
(SCPCFGFP)ntdll!ScpCfgDispatchUserCallTarget_Fptr
(SCPCFGFP)
The implementation of the dispatch function in SCPCFG & SCPCFGES is the same, but the other two sections work in a different manner. SCPCFGNP implements a nop function, immediately jumping to the indirect function pointer. On the other hand, the implementation in SCPCFGFP jumps to a function pointer that’s read from an address that seems to be unknown at compile time.
SCPCFG 和 SCPCFGES 中调度功能的实现是相同的,但其他两个部分的工作方式不同。SCPCFGNP 实现一个 nop 函数,立即跳转到间接函数指针。另一方面,SCPCFGFP中的实现会跳转到一个函数指针,该指针是从编译时似乎未知的地址读取的。
With this in mind, it’s not hard to determine the intent behind each section:
考虑到这一点,不难确定每个部分背后的意图:
- SCPCFG & SCPCFGES represent regular CFG implementations, having access to a bitmap that stores information on whether an address is a valid call target. SCPCFGES uses export suppression, whereas SCPCFG doesn’t.
SCPCFG 和 SCPCFGES 表示常规的 CFG 实现,可以访问位图,该位图存储有关地址是否为有效呼叫目标的信息。SCPCFGES 使用出口抑制,而 SCPCFG 则不使用。 - SCPCFGNP represents lack of CFG. Its implementations are defined to let any call target through. (NP = NOP)
SCPCFGNP 代表缺乏 CFG。它的实现被定义为允许任何调用目标通过。(NP = NOP) - SCPCFGFP calls an implementation that’s located elsewhere through a function pointer. (FP = function pointer)
SCPCFGFP 通过函数指针调用位于其他位置的实现。(FP = 函数指针)
One more thing worth looking into is the invalid call target handler. For all sections except SCPCFGNP, its implementation looks like this:
另一件值得研究的事情是无效的调用目标处理程序。对于除SCPCFGNP之外的所有部分,其实现如下所示:
mov r11, 123456789ABCDEFh
jmp r11
ntdll!ScpCfgHandleInvalidCallTarget
/ntdll!ScpCfgHandleInvalidCallTarget_ES
/ntdll!ScpCfgHandleInvalidCallTarget_Fptr
Meaning that it also is supposed to jump to a yet-to-be-determined place. As we’ll see later, all of the placeholder values are going to be patched by the kernel.
这意味着它也应该跳到一个尚未确定的地方。正如我们稍后将看到的,所有占位符值都将由内核修补。
The only references to the new sections from within ntdll are in a new export – RtlpScpCfgntdllExports
, which points towards the headers and ends of the different sections. The export is only referenced from the export table as well.
ntdll 中对新部分的唯一引用是在新的导出 – RtlpScpCfgntdllExports
中,它指向不同部分的标题和结尾。导出也仅从导出表中引用。
.rdata:00000001801654A0 RtlpScpCfgntdllExports
.rdata:00000001801654A0 dq offset ScpCfgHeader_Nop
.rdata:00000001801654A8 dq offset ScpCfgEnd_Nop
.rdata:00000001801654B0 dq offset ScpCfgHeader
.rdata:00000001801654B8 dq offset ScpCfgEnd
.rdata:00000001801654C0 dq offset ScpCfgHeader_ES
.rdata:00000001801654C8 dq offset ScpCfgEnd_ES
.rdata:00000001801654D0 dq offset ScpCfgHeader_Fptr
.rdata:00000001801654D8 dq offset ScpCfgEnd_Fptr
.rdata:00000001801654E0 dq offset LdrpGuardDispatchIcallNoESFptr
.rdata:00000001801654E8 dq offset __guard_dispatch_icall_fptr
.rdata:00000001801654F0 dq offset LdrpGuardCheckIcallNoESFptr
.rdata:00000001801654F8 dq offset __guard_check_icall_fptr
.rdata:0000000180165500 dq offset LdrpHandleInvalidUserCallTarget
Even though sections are present in ntdll, they’re not used by usermode code as such. To understand how everything connects at runtime, we’ll need to dive into kernel code.
即使 ntdll 中存在部分,用户模式代码也不会使用它们。要了解所有内容在运行时是如何连接的,我们需要深入研究内核代码。
kernel initialization 内核初始化
Similarly to classic CFG, much of the logic related to SCPCFG happens in the kernel. Classic CFG is initialized in MiInitializeCfg
, and this function remains unchanged between 23H2 and 24H2. SCPCFG is initialized in a different function – MiInitializeImageViewExtension
. This takes place during phase 1 initialization and its purpose is to map the four sections from ntdll into the kernel and fix them up so that they can later be dropped anywhere in the userspace and used out-of-the-box. The following steps make up the gist of the process:
与经典 CFG 类似,与 SCPCFG 相关的大部分逻辑都发生在内核中。经典 CFG 在 中 MiInitializeCfg
初始化,此函数在 23H2 和 24H2 之间保持不变。SCPCFG 在不同的函数 – MiInitializeImageViewExtension
中初始化。这发生在第 1 阶段初始化期间,其目的是将 ntdll 中的四个部分映射到内核中并修复它们,以便以后可以将它们放在用户空间中的任何位置并开箱即用。以下步骤构成了该过程的要点:
- [
MmInitializeImageViewExtension
] The kernel obtains a view of the global cfg bitmap for the initial system process by callingMiMapSecurePureReserveView
withPsInitialSystemProcess
. Internally, this is implemented throughMmMapViewOfSectionEx
and the view additionally being secured viaMiSecureVad
, which disallows the view protection from being changed. This view is then stored in a global variable.
[MmInitializeImageViewExtension
] 内核通过调用MiMapSecurePureReserveView
来PsInitialSystemProcess
获取初始系统进程的全局 cfg 位图视图。在内部,这是通过MmMapViewOfSectionEx
实现的,并且视图还通过MiSecureVad
来保护,这不允许更改视图保护。然后,此视图存储在全局变量中。 - [
MiInitializeImageViewExtensionCfg
] Four new combined pages are allocated, and the corresponding combine blocks are stored into a global array. The pages are initially empty and will be filled in later. We’ll call these SCPCFG pages, as they’ll store data taken from ntdll’s SCPCFG sections.
[MiInitializeImageViewExtensionCfg
] 分配了四个新的组合页,并将相应的组合块存储到一个全局数组中。这些页面最初是空的,稍后将填写。我们将这些页面称为 SCPCFG 页面,因为它们将存储从 ntdll 的 SCPCFG 部分获取的数据。 - [
PsInitializeScpCfgPages
/PspLocateNtdllAddressesForScpCfg
] The exportedRtlpScpCfgntdllExports
is located in ntdll via its export table and used to find the offset of the four SCPCFG sections in the file. Contents of each section are then copied into the corresponding SCPCFG page and a bunch of sanity checks are performed to ensure integrity of the data.
[PsInitializeScpCfgPages
/PspLocateNtdllAddressesForScpCfg
]导出RtlpScpCfgntdllExports
的通过其导出表位于 ntdll 中,用于查找文件中四个 SCPCFG 部分的偏移量。然后将每个部分的内容复制到相应的 SCPCFG 页面中,并执行一系列健全性检查以确保数据的完整性。 - [
PspLocateNtdllAddressesForScpCfg
] Pointers to ntdll’s SCPCFG functions are stored in a global array (PspNtdllScpFunctions
). Here, the pointers don’t point inside any of the new pages, nor to the sections embedded in ntdll. They’re calculated asntdllBaseAddress + ntdllImageSizeInMemory + fixedOffsetToTheFunction
, suggesting that one of the SCPCFG pages will be mapped at the end of ntdll’s userspace image region. We’ll later see that this is indeed the case.
[PspLocateNtdllAddressesForScpCfg
] 指向 ntdll 的 SCPCFG 函数的指针存储在全局数组 (PspNtdllScpFunctions
) 中。在这里,指针不指向任何新页面内部,也不指向 ntdll 中嵌入的部分。它们的计算公式为ntdllBaseAddress + ntdllImageSizeInMemory + fixedOffsetToTheFunction
,表明其中一个 SCPCFG 页面将映射到 ntdll 的用户空间图像区域的末尾。我们稍后会看到情况确实如此。 - [
PspFinalizeScpCfgPage
] Placeholder values (i.e. 0x0123456789ABCDEF) are replaced in all four SCPCFG pages. Particularly, the cfg bitmap address placeholder is replaced with the address of the view to the cfg bitmap obtained in the first step. The placeholder in the invalid call target handler is replaced with the pointer stored in the last field ofRtlpScpCfgntdllExports
, i.e.LdrpHandleInvalidUserCallTarget
. The placeholder function pointers in the SCPCFGFP section are replaced with the pointers stored inRtlpScpCfgntdllExports
, starting fromLdrpGuardDispatchIcallNoESFptr
and ending with__guard_check_icall_fptr
.
[PspFinalizeScpCfgPage
] 占位符值(即 0x0123456789ABCDEF)在所有四个 SCPCFG 页面中被替换。具体地,将cfg位图地址占位符替换为在第一步中获取的cfg位图的视图地址。无效调用目标处理程序中的占位符将替换为存储在 的最后一个RtlpScpCfgntdllExports
字段中的指针LdrpHandleInvalidUserCallTarget
,即 。SCPCFGFP 部分中的占位符函数指针将替换为存储在RtlpScpCfgntdllExports
中的指针,从 开始LdrpGuardDispatchIcallNoESFptr
,以__guard_check_icall_fptr
结束。
Show / Hide snippets 显示/隐藏片段
At the end of the initialization, the kernel has the following available:
在初始化结束时,内核具有以下可用功能:
- A protected view of the cfg bitmap, stored in a global variable, I called it
g_SCPCFGBitmapView
.
cfg 位图的受保护视图,存储在全局变量中,我称之为g_SCPCFGBitmapView
. - Four combined pages containing corresponding SCPCFG section data copied from ntdll, fixed up to make the code functional. The corresponding block of each page is stored in a global array, I called it
g_SCPCFGSectionBlocks
.
四个组合页面包含从 ntdll 复制的相应 SCPCFG 部分数据,并修复以使代码正常运行。每个页面的对应块存储在一个全局数组中,我称之为g_SCPCFGSectionBlocks
。 - Four function addresses stored in the global
PspntdllScpFunctions
array, which are pointing towards scpcfg functions in a page mapped at the end of the ntdll’s userspace image region.
存储在全局PspntdllScpFunctions
数组中的四个函数地址,这些地址指向映射在 ntdll 用户空间映像区域末尾的页面中的 scpcfg 函数。
notes: 笔记:
- The offsets to the first four functions inside of each section can be defined dynamically, but kernel code asserts that they’re actually fixed, i.e. the offsets must be 0x40, 0xC0, 0x140 and 0x1C0. This can be seen in
PsInitializeScpCfgPages
.
每个部分中前四个函数的偏移量可以动态定义,但内核代码断言它们实际上是固定的,即偏移量必须是 0x40、0xC0、0x140 和 0x1C0。这可以从 中PsInitializeScpCfgPages
看出。 PspLocateNtdllAddressesForScpCfg
takes a pointer to a struct namedRTL_SCP_CFG_NTDLL_EXPORTS_ARM64EC
, however the argument is simply zeroed-out. I haven’t checked the ARM64 version of the kernel, so it’s possible that this is properly filled in over there. Of course, it doesn’t make sense that we’d need ARM64EC information on x64.
PspLocateNtdllAddressesForScpCfg
获取指向名为RTL_SCP_CFG_NTDLL_EXPORTS_ARM64EC
的结构体的指针,但该参数只是归零。我没有检查内核的 ARM64 版本,所以它可能在那里正确填写。当然,我们需要有关 x64 ARM64EC信息是没有意义的。- The function pointers in
PspntdllScpFunctions
point towards nothing at the time they’re stored, as nothing has been mapped into the space at the end of ntdll’s image region. One of the SCPCFG pages will be mapped a bit later by the part of code responsible for usermode module linking. The delayed initialization here is not really a problem, as nothing will access the functions before the page is mapped.
函数指PspntdllScpFunctions
针在存储时不指向任何内容,因为没有内容映射到 ntdll 图像区域末尾的空间中。稍后,负责用户模式模块链接的代码部分将映射其中一个 SCPCFG 页面。这里的延迟初始化并不是一个真正的问题,因为在映射页面之前,没有任何东西会访问这些函数。 - It is now clear that the SCPCFGFP section is supposed to emulate classic CFG. In the fixup procedure, placeholder function pointers are replaced with the pointers to classic CFG functions. This doesn’t mean that it will always emulate the classic CFG, as the function pointers could easily be overwritten once again, but that doesn’t seem to be done at the time.
现在很明显,SCPCFGFP部分应该模拟经典的CFG。在修复过程中,占位符函数指针将替换为指向经典 CFG 函数的指针。这并不意味着它总是会模拟经典的 CFG,因为函数指针可以很容易地再次被覆盖,但当时似乎没有这样做。
kernel linking 内核链接
Once the initialization is finished, nothing else is done on a global scale. The SCPCFG pages are now ready and can be mapped into processes during their creation, as well as individual modules. These steps are separated, and we’ll cover them separately as well.
初始化完成后,不会在全球范围内执行任何其他操作。SCPCFG页面现已准备就绪,可以在创建过程中映射到流程以及单个模块中。这些步骤是分开的,我们也将分别介绍它们。
process linking 进程链接
The entry point that we’re concerned with is MiMapProcessExecutable
. This function is called during process allocation in the kernel mode, with the call chain being PspAllocateProcess
-> MiInitializeProcessAddressSpace
-> MiMapProcessExecutable
. The following steps are then taken to setup SCPCFG for the process:
我们关注的切入点是 MiMapProcessExecutable
。此函数在内核模式下的进程分配期间调用,调用链为 PspAllocateProcess
-> MiInitializeProcessAddressSpace
-> MiMapProcessExecutable
。然后,执行以下步骤为该过程设置 SCPCFG:
- [
MiCfgInitializeProcess
] The global cfg bitmap is mapped for the process by usingMiMapSecurePureReserveView
.g_SCPCFGBitmapView
is stored in the second argument, and if everything goes well (ie the process has access to the view), the kernel will simply return the same value. Otherwise, a new view is created. The view is then stored in the process mappings of the process object, at offset 0x3A0.
[MiCfgInitializeProcess
] 全局 cfg 位图通过使用MiMapSecurePureReserveView
映射到进程。g_SCPCFGBitmapView
存储在第二个参数中,如果一切顺利(即进程可以访问视图),内核将简单地返回相同的值。否则,将创建一个新视图。然后,视图存储在流程对象的流程映射中,在偏移0x3A0处。 - [
MiMapAllImageScpPages
] The code walks through the list of VADs belonging to the process, and checks if the VAD flags indicate that the VAD is read-only and hotpatchable. If so, the VAD must contain an image/module, and so it’s checked if the image contains function override fixups. If so,MiMapImageScpCfgPages
is called to map the section into the module corresponding to the image. This function deals with an individual module and is also used by the module linking code path, so we’ll cover it later.
[MiMapAllImageScpPages
] 代码遍历属于该进程的 VAD 列表,并检查 VAD 标志是否指示 VAD 是只读的和可热修补的。如果是这样,VAD 必须包含映像/模块,因此会检查映像是否包含函数覆盖修复。如果是这样,MiMapImageScpCfgPages
则调用该部分以映射到与图像对应的模块中。此函数处理单个模块,也由链接代码路径的模块使用,因此我们稍后将介绍它。
Show / Hide snippets 显示/隐藏片段
notes: 笔记:
- After the main part of code inside
MiCfgInitializeProcess
finishes, there’s an additional part that could potentially be run. If the architecture isI386
(0x14C) orArmThumb2
(0x1C4), the cfg bitmap is mapped into another view, but with different parameters (I haven’t bothered deciphering what they mean). The new view is then stored at offset0x3C0
in the process mappings.
在代码的主要MiCfgInitializeProcess
部分完成后,可能会运行一个额外的部分。如果体系结构是I386
(0x14C) 或ArmThumb2
(0x1C4),则 cfg 位图将映射到另一个视图中,但具有不同的参数(我没有费心破译它们的含义)。然后,新视图以偏移0x3C0
量存储在流程映射中。 - The conditions required to map the SCPCFG section into an image are related to hotpatching, i.e. the hotpatch indicator flag must be set, and
MiDoesImageContainFunctionOverrideFixups
must return true. Both of these are tightly coupled with the implementation of hotpatching, so we don’t cover it here. I will mention that the indicator flag is set inMiMapViewOfImageSection
when initially mapping the image, but the way its value is calculated is quite complicated and is out of scope.
将 SCPCFG 部分映射到映像所需的条件与热补丁有关,即必须设置热补丁指示器标志,并且MiDoesImageContainFunctionOverrideFixups
必须返回 true。这两者都与热补丁的实现紧密结合,因此我们在这里不介绍它。我要提到的是,指标标志是在最初映射图像时设置的MiMapViewOfImageSection
,但其值的计算方式非常复杂,超出了范围。
module linking 模块链接
The function of interest here is the giant MiMapViewOfImageSection
, which is called when an image (module) is mapped into the userspace. The steps taken here are pretty straightforward:
这里感兴趣的函数是 giant MiMapViewOfImageSection
,当图像(模块)映射到用户空间时调用它。这里采取的步骤非常简单:
- [
MiMapViewOfImageSection
] First, determine if the module is hotpatchable. If so, the size of the image VAD is increased by 0x1000, and an additional page will be available at the end of the image region. If the module is hotpatchable and additionally contains function override fixups, callMiMapImageScpCfgPages
to map the SCPCFG page into the additional page within the image region.
[MiMapViewOfImageSection
] 首先,确定模块是否可热修补。如果是这样,图像 VAD 的大小将增加 0x1000,并且在图像区域的末尾将提供一个额外的页面。如果模块是可热修补的,并且还包含函数覆盖修复,请调用MiMapImageScpCfgPages
以将 SCPCFG 页面映射到图像区域内的其他页面。 - [
MiMapImageScpCfgPages
] First, we check if the process that the module is being loaded into is using SCPCFG. If so,PsGetScpCfgPageTypeForProcess
is called to determine which page was mapped into the process. Afterwards, the page is fetched fromg_SCPCFGSectionBlocks
and mapped into the extra page at the end of extended image VAD. This is doneMiDecommitPages
, which additionally decommits the page.
[MiMapImageScpCfgPages
] 首先,我们检查正在加载模块的进程是否使用 SCPCFG。如果是这样,PsGetScpCfgPageTypeForProcess
则调用以确定哪个页面映射到进程中。之后,从g_SCPCFGSectionBlocks
扩展图像 VAD 末尾的额外页面中获取页面并映射到该页面。这已经完成MiDecommitPages
,这还会取消提交页面。
Show / Hide snippets 显示/隐藏片段
notes: 笔记:
- The conditions required for an image to contain the SCPCFG page at the end of its region is the same here as it was in
MiMapAllImageScpPages
– the hotpatch indicator being set and function override fixups being present. This means that system DLLs don’t receive any special treatment and go through the same code paths as regular DLLs.
图像在其区域末尾包含 SCPCFG 页面所需的条件与此处相同MiMapAllImageScpPages
– 正在设置热补丁指示器并存在功能覆盖修复。这意味着系统 DLL 不会接受任何特殊处理,并且会通过与常规 DLL 相同的代码路径。 PsGetScpCfgPageTypeForProcess
determines which type of SCPCFG page is mapped into the process. The check here is two-fold:
PsGetScpCfgPageTypeForProcess
确定映射到流程中的 SCPCFG 页面类型。这里的检查是双重的:- Recall that
MiCfgInitializeProcess
stores its global cfg bitmap view into process mappings at offset 0x3A0. This field is checked in this function and compared against the globalg_SCPCFGBitmapView
. If the two don’t match, 3 is returned, which corresponds to the SCPCFGFP section. This makes sense if you recall thatg_SCPCFGBitmapView
is hardcoded into asm in pages representing SCPCFG and SCPCFGES – if that view is not the one that process has, then the functions wouldn’t work properly.
回想一下,将其MiCfgInitializeProcess
全局 cfg 位图视图存储到偏移0x3A0的流程映射中。在此函数中检查此字段,并与全局g_SCPCFGBitmapView
.如果两者不匹配,则返回 3,该值对应于 SCPCFGFP 部分。如果您还记得g_SCPCFGBitmapView
在表示 SCPCFG 和 SCPCFGES 的页面中被硬编码为 asm,这是有道理的 – 如果该视图不是进程具有的视图,则函数将无法正常工作。 - On the other hand, if the two match, the export suppression flag is checked for the process to determine if SCPCFG or SCPCFGES is the appropriate section to use.
另一方面,如果两者匹配,则检查该过程的导出抑制标志,以确定 SCPCFG 或 SCPCFGES 是否是要使用的适当部分。 - Index 0, aka the SCPCFGNP section, is returned only if a certain flag is unset for the module, which most likely just corresponds to the CFG flag. I haven’t bothered tracking it down.
索引 0(又名 SCPCFGNP 部分)仅在未为模块设置特定标志时返回,该标志很可能只对应于 CFG 标志。我没有费心去追踪它。
- Recall that
- We can now see that, in most cases, SCPCFG / SCPCFGES will be mapped into a module, based on whether export suppression is enabled. The only way for the module to end up using SCPCFGFP (which, as we determined earlier, currently falls back to original CFG) is for the process to fail to map the global view of the cfg bitmap, which seems like an unlikely condition and I didn’t catch it happening. The NOP section would only be used if the module doesn’t support CFG.
我们现在可以看到,在大多数情况下,SCPCFG / SCPCFGES 将根据是否启用导出抑制映射到一个模块中。模块最终使用 SCPCFGFP(正如我们之前确定的那样,目前回退到原始 CFG)的唯一方法是该进程无法映射 cfg 位图的全局视图,这似乎是一个不太可能的情况,我没有发现它发生。仅当模块不支持 CFG 时,才会使用 NOP 部分。 MiMapImageScpCfgPages
is also where the location of the mapped page is determined. As it stands, it will always be mapped to the end of the image region.MiGetImageExtensionBaseAddress
is called to determine the userspace address that the page should be mapped to, and this currently returns the sum of the base address of the module and the original size of the image.
MiMapImageScpCfgPages
也是确定映射页面位置的位置。就目前而言,它将始终映射到图像区域的末尾。MiGetImageExtensionBaseAddress
用于确定页面应映射到的用户空间地址,这当前返回模块的基址和图像的原始大小之和。- Finally, as the address range to which the extra section belongs is decommitted, this somewhat explains why
VirtualProtect
fails on the page. It doesn’t explain why the page can still be seen as committed by other APIs and I was lazy to investigate that.
最后,由于额外部分所属的地址范围被取消提交,这在一定程度上解释了为什么VirtualProtect
页面上失败。它没有解释为什么该页面仍然可以被视为由其他 API 提交,我懒得调查这一点。
system dll block 系统 DLL 块
There’s one more thing that the kernel needs to link for a process. Recall that classic CFG requires that the address of the cfg bitmap is written to DllSystemInitBlock
in ntdll to make it known to the userspace. For SCPCFG we don’t need that, as the kernel writes the view to the bitmap directly to asm code. However, the userspace loader now needs to know which functions it should link to, as this is determined by the kernel. This info is once again written to the init block, now at offsets 0xF0 – 0x120. The fields at these offsets will contain 6 pointers to the corresponding SCPCFG functions, and are written by the kernel in PspPrepareSystemDllInitBlock
. PspGetScpCfgFunctions
is called to obtain the pointers to chosen functions. The choice and the action taken depends on a few conditions:
内核还需要为进程链接一件事。回想一下,经典 CFG 要求在 ntdll 中写入 DllSystemInitBlock
cfg 位图的地址,以使其为用户空间所知。对于 SCPCFG,我们不需要它,因为内核将视图直接写入位图到 asm 代码。但是,用户空间加载器现在需要知道它应该链接到哪些函数,因为这是由内核决定的。此信息再次写入 init 块,现在位于偏移量 0xF0 – 0x120。这些偏移量处的字段将包含指向相应 SCPCFG 函数的 6 个指针,并由 中的内核写 PspPrepareSystemDllInitBlock
入。 PspGetScpCfgFunctions
调用以获取指向所选函数的指针。选择和采取的行动取决于以下几个条件:
- At this point, ntdll is already mapped into the process, so the code checks if it already contains a scpcfg section – if not, the function returns zero and nothing is written to the block. If yes, the section is determined by calling
PsGetScpCfgPageTypeForProcess
on the process.
此时,ntdll 已映射到进程中,因此代码会检查它是否已经包含 scpcfg 部分 – 如果没有,则函数返回零,并且不会将任何内容写入块中。如果是,则通过调用PsGetScpCfgPageTypeForProcess
进程来确定该部分。 - If the determined section type is SCPCFG or SCPCFGES, the values stored in
PspntdllScpFunctions
are written.
如果确定的截面类型是 SCPCFG 或 SCPCFGES,则将写入存储在 中的PspntdllScpFunctions
值。 - If the determined section type is SCPCFGFP, nothing is written.
如果确定的截面类型为 SCPCFGFP,则不写入任何内容。
Show / Hide snippets 显示/隐藏片段
And it’s time we leave the kernel alone. What’s left is for the usermode loader to finish the job and actually link the indirect call pointers to the implementations in the mapped section.
现在是时候不理会内核了。剩下的就是让用户模式加载器完成作业,并实际将间接调用指针链接到映射部分中的实现。
usermode linking 用户模式链接
As is the case for classic CFG, usermode linking is pretty light and done in ntdll!LdrpCfgProcessLoadConfig
by the loader:
与经典 CFG 的情况一样,用户模式链接非常轻巧, ntdll!LdrpCfgProcessLoadConfig
并且由加载器完成:
- [
LdrpCfgProcessLoadConfig
] Most of the classic CFG code is still relevant here.IMAGE_LOAD_CONFIG_DIRECTORY
of the module is read, in order to determine where the indirect call function pointers are stored. For each of the indirect call pointers present in the debug directory, eitherLdrpCfgCheckRoutineCallback
orLdrpCfgDispatchRoutineCallback
is called. These functions are supposed to decide if the pointer should be linked to a scpcfg routine or to the classic ntdll routine. This is decided by looking at the corresponding values inDllSystemInitBlock
.
[LdrpCfgProcessLoadConfig
] 大多数经典的 CFG 代码在这里仍然适用。IMAGE_LOAD_CONFIG_DIRECTORY
的模块被读取,以确定间接调用函数指针的存储位置。对于调试目录中存在的每个间接调用指针,LdrpCfgCheckRoutineCallback
要么LdrpCfgDispatchRoutineCallback
被调用。这些函数应该决定指针是应链接到 scpcfg 例程还是经典 ntdll 例程。这是通过查看 中的DllSystemInitBlock
相应值来决定的。
For example, LdrpCfgDispatchRoutineCallback
looks like this:
例如, LdrpCfgDispatchRoutineCallback
如下所示:
void LdrpCfgDispatchRoutineCallback(void** fptr, int flags)
{
if (LdrControlFlowGuardEnforcedWithExportSuppression() && (flags & IMAGE_GUARD_CF_EXPORT_SUPPRESSION_INFO_PRESENT) != 0)
{
if (*(dllInitBlock + 0x108))
*fptr = *(dllInitBlock + 0x108); <----- kernel wrote the ES dispatch function pointer here
else
*fptr = LdrpDispatchUserCallTargetES;
}
else
{
if (*(dllInitBlock + 0x100))
*fptr = *(dllInitBlock + 0x100); <----- kernel wrote the (no ES) dispatch function pointer here
else
*fptr = LdrpDispatchUserCallTarget
}
}
ntdll!LdrpCfgDispatchRoutineCallback
One thing that sticks out here is that all modules will always link to the same functions, as function pointers are read from the ntdll init block. As far as the implementation in kernel goes, if these are available, they will have always been copied from PspntdllScpFunctions
, which means that all loaded modules will initially link to functions in the dedicated page mapped to ntdll’s image region, rather than their own dedicated page. This probably changes in the event that the module needs to be hotpatched, but that’s me speculating, as I didn’t get that far.
这里突出的一件事是,所有模块将始终链接到相同的函数,因为函数指针是从 ntdll init 块中读取的。就内核中的实现而言,如果这些可用,它们将始终是从 复制的 PspntdllScpFunctions
,这意味着所有加载的模块最初将链接到映射到 ntdll 映像区域的专用页面中的函数,而不是它们自己的专用页面。如果模块需要热补丁,这可能会发生变化,但这是我的推测,因为我没有走那么远。
extras 额外
One extra thing that the usermode loader does is add a part of the newly mapped section to the system function table by calling RtlAddGrowableFunctionTable
. The purpose of this function is to mark certain parts of code as functions in order to properly collect backtraces and dispatch exceptions. This is done in RtlpInsertOrRemoveScpCfgFunctionTable
, which is called after the module is mapped & cfg initialized, or alternatively when the module is unloaded. To fetch info on which functions within the section should be added to the table, the function calls ZwQueryVirtualMemory
with a new memory information class. Internally, it’s denoted as “image view extension” and its value is 0x0E. The code looks something like this:
用户模式加载程序执行的另一件事是通过调用 RtlAddGrowableFunctionTable
将新映射部分的一部分添加到系统函数表中。此函数的目的是将代码的某些部分标记为函数,以便正确收集回溯和调度异常。这是在 中 RtlpInsertOrRemoveScpCfgFunctionTable
完成的,在模块映射和 cfg 初始化后调用,或者在模块卸载时调用。若要获取有关应将节中的哪些函数添加到表中的信息,该函数使用新的内存信息类进行调用 ZwQueryVirtualMemory
。在内部,它表示为“图像视图扩展”,其值为 0x0E。代码如下所示:
typedef struct _MEMORY_IMAGE_EXTENSION_INFORMATION
{
PVOID PageTypeArgs;
ULONG PageOffset;
SIZE_T PageSize;
} MEMORY_IMAGE_EXTENSION_INFORMATION;
NTSTATUS RtlpInsertOrRemoveScpCfgFunctionTable(void* moduleBase, int, bool insertOrRemove)
{
MEMORY_IMAGE_EXTENSION_INFORMATION info = {};
if (SUCCEEDED(NtQueryVirtualMemory(-1, moduleBase, 0x0E, &info, sizeof(info), ...))
{
if (info.PageOffset && info.PageSize)
{
void* pageBase = moduleBase + info.PageOffset;
const uint32_t offsetToRtlTable = *(pageBase + 0x20);
const uint64_t pageSize = info.PageSize;
if (insertOrRemove)
{
RtlAddGrowableFunctionTable(..., pageBase + offsetToRtlTable, 1, 1, pageBase, pageSize);
}
else
{
RtlDeleteFunctionTable(pageBase + offsetToRtlTable);
}
}
}
}
ntdll!RtlpInsertOrRemoveScpCfgFunctionTable
kernel scpcfg 内核 SCPCFG
Kernel CFG has also undergone some changes, which are very similar to the ones we’ve seen in usermode. In ntoskrnl.exe we now have a “KSCP” section, which is formatted in the following manner:
内核 CFG 也经历了一些变化,这些变化与我们在用户模式中看到的变化非常相似。在ntoskrnl.exe中,我们现在有一个“KSCP”部分,其格式如下:
[+0x00] KSCP header
[+0x00][0x04] = length of the section
[+0x04][0x58] = offsets to individual functions below, sorted, 4 bytes each
[+0x5C][0x24] = Unknown
[+0x80] __guard_retpoline_icall_handler
[+0xA0] sub_140B570A0
[+0xC0] __guard_retpoline_switchtable_jump_rax
[+0xE0] __guard_retpoline_switchtable_jump_rcx
[+0x100] __guard_retpoline_switchtable_jump_rdx
[+0x120] __guard_retpoline_switchtable_jump_rbx
[+0x140] __guard_retpoline_switchtable_jump_rsp
[+0x160] __guard_retpoline_switchtable_jump_rbp
[+0x180] __guard_retpoline_switchtable_jump_rsi
[+0x1A0] __guard_retpoline_switchtable_jump_rdi
[+0x1C0] __guard_retpoline_switchtable_jump_r8
[+0x1E0] __guard_retpoline_switchtable_jump_r9
[+0x200] __guard_retpoline_switchtable_jump_r10
[+0x220] __guard_retpoline_switchtable_jump_r11
[+0x240] __guard_retpoline_switchtable_jump_r12
[+0x260] __guard_retpoline_switchtable_jump_r13
[+0x280] __guard_retpoline_switchtable_jump_r14
[+0x2A0] __guard_retpoline_switchtable_jump_r15
[+0x2C0] __guard_retpoline_indirect_cfg_rax
[+0x3C0] __guard_retpoline_exit_indirect_rax
[+0x440] __guard_retpoline_import_r10
[+0x4E0] __guard_retpoline_import_r10_do_retpoline
[+0x520] __guard_retpoline_import_r10_log_event
[+0x580] __guard_retpoline_jump_hpat
[+0x5A0] __guard_retpoline_exit
[+0x780] KscpCfgDispatchUserCallTargetEsSmep
[+0x7E0] KscpCfgDispatchUserCallTargetEsNoSmep
[+0x840] KscpCfgHandleInvalidCallTarget
The structure looks similar to the new sections in ntdll, but there aren’t only new CFG functions in the section, there are also retpoline functions being included. Retpoline functions aren’t new and they’ve been present in previous versions of the kernel as well, though in a different section – RETPOL. That section is now gone. At the end of KSCP, we have three functions which seem reminiscent of the ones we’ve seen in userspace. However, their implementation is different. For example:
该结构看起来类似于 ntdll 中的新部分,但该部分中不仅有新的 CFG 函数,还包括 retpoline 函数。Retpoline 函数并不新鲜,它们也存在于以前版本的内核中,尽管在不同的部分 – RETPOL。该部分现在已不见了。在 KSCP 的最后,我们有三个函数,它们似乎让人想起我们在用户空间中看到的函数。但是,它们的实现是不同的。例如:
jmp rax
-------------------------------------------------------------
db 7 dup(0CCh)
-------------------------------------------------------------
mov r10, rax
shr r10, 9
mov r11, [r11+r10*8]
mov r10, rax
shr r10, 3
test al, 0Fh
jnz short loc_140B577A9
bt r11, r10
jnb short loc_140B577C1
jmp rax
...
ntoskrnl!KscpCfgDispatchUserCallTargetEsSmep
There’s no placeholder values and the instructions themselves look like placeholders, ie there’s a weird disconnect between the first instruction and the rest of the function.
没有占位符值,指令本身看起来像占位符,即第一条指令和函数的其余部分之间存在奇怪的脱节。
initialization 初始化
KSCPSCFG gets initialized with the rest of KSCP (the latter denoting all of the functions in the sections, with the former denoting only the three CFG functions at the end). The initialization happens in following steps:
KSCPSCFG 与 KSCP 的其余部分一起初始化(后者表示各节中的所有函数,前者仅表示末尾的三个 CFG 函数)。初始化按以下步骤进行:
- [
MiPrepareScpFixupsForNtAndHal
] This is done during the preparation phase of system initialization. We begin by mapping the KSCP section and storing a pointer to it into a global variable, as well as its size in system pages. Let’s call theseg_ScpBase
andg_ScpSectionSizeInPages
. Then we callMiApplyDynamicFixupsToKernelAndHal
.
[MiPrepareScpFixupsForNtAndHal
] 这是在系统初始化的准备阶段完成的。我们首先将 KSCP 部分映射到全局变量中,并将指向它的指针存储到全局变量中,并在系统页面中存储其大小。我们称这些g_ScpBase
为g_ScpSectionSizeInPages
和 。然后我们调用MiApplyDynamicFixupsToKernelAndHal
. - [
MiApplyDynamicFixupsToKernelAndHal
] This function performs some fixups on the retpolines and then callsRtlInitializeKscpCfgFunctions
to fixupKscpCfgDispatchUserCallTargetEsSmep
andKscpCfgDispatchUserCallTargetEsNoSmep
. The fixup code is pretty simple, the beginning of both functions is patched so that the function jumps to__guard_retpoline_icall_handler
.
[MiApplyDynamicFixupsToKernelAndHal
] 此函数对 retpolines 执行一些修正,然后调用RtlInitializeKscpCfgFunctions
fixupKscpCfgDispatchUserCallTargetEsSmep
和KscpCfgDispatchUserCallTargetEsNoSmep
。修复代码非常简单,两个函数的开头都进行了修补,以便函数跳转到__guard_retpoline_icall_handler
.
KSCP is further initialized through MiInitializeKernelScp
, but not much of note happens to the CFG functions here. The primary goal of this function seems to be to create a function table that can later be accessed through usual interfaces, like RtlpxLookupFunctionTable
.
KSCP 通过 MiInitializeKernelScp
进一步初始化,但此处的 CFG 函数并没有太多值得注意的事情发生。此函数的主要目标似乎是创建一个函数表,以后可以通过常用接口访问该函数表,例如 RtlpxLookupFunctionTable
.
Show / Hide snippets 显示/隐藏片段
linking 连接
The only step left is to link the indirect call handlers to KSCPCFG functions each time a driver is loaded. This is done in a familiar place, during MiProcessKernelCfgImageLoadConfig
. This function parses the load configuration debug directory in the exe/driver being loaded, finds the indirect call pointers, and patches them to point towards CFG functions. Before 24H2, the functions being used would be guard_check_icall
and guard_dispatch_icall
(called guard_check_icall_no_overrides
and guard_dispatch_icall_no_overrides
in 24H2). In 24H2, this is changed, but only for the dispatch function. Instead of using guard_dispatch_icall_no_overrides
, the pointers will be linked to KscpCfgDispatchUserCallTargetEsSmep
or KscpCfgDispatchUserCallTargetEsNoSmep
, depending on the status of SMEP.
剩下的唯一步骤是在每次加载驱动程序时将间接调用处理程序链接到 KSCPCFG 函数。这是在熟悉的地方完成的,在 MiProcessKernelCfgImageLoadConfig
.此函数分析正在加载的 exe/驱动程序中的加载配置调试目录,查找间接调用指针,并修补它们以指向 CFG 函数。在 24H2 之前,正在使用的函数将是 guard_check_icall
and guard_dispatch_icall
(在 24H2 中称为 guard_check_icall_no_overrides
and guard_dispatch_icall_no_overrides
)。在 24H2 中,这已更改,但仅适用于调度功能。指针将链接到 KscpCfgDispatchUserCallTargetEsSmep
或 KscpCfgDispatchUserCallTargetEsNoSmep
而不是使用 guard_dispatch_icall_no_overrides
,具体取决于 SMEP 的状态。
The KSCP section is also additionally mapped for each module that’s loaded into the kernel, which is once again reminiscent of what happens in the userspace. This is done through MiMapKernelScp
, which is called during system image loading.
KSCP 部分还为加载到内核中的每个模块进行了额外映射,这再次让人想起用户空间中发生的事情。这是通过 MiMapKernelScp
完成的,在系统映像加载期间调用。
Show / Hide snippets 显示/隐藏片段
final thoughts 最后的思考
Lookin at the bigger picture, the changes do seem intended only for compatibility with hotpatching. We didn’t see any security improvements or optimizations that the new code is supposed to bring. Unfortunately, I didn’t find the motivation to dig further and figure out where exactly the new behaviour comes to shine, but it seems pretty clear that the entire set of changes was done to support hotpatching. This becomes obvious when looking at kernel changes, as there doesn’t seem be any additional security benefit from the new behaviour. The mention of “function overrides” in multiple places also seems to point towards there being a possibility to patch CFG functions for a module, or something of sort.
从更大的角度来看,这些变化似乎只是为了与热补丁兼容。我们没有看到新代码应该带来的任何安全改进或优化。不幸的是,我没有找到进一步挖掘并弄清楚新行为究竟在哪里闪耀的动力,但似乎很清楚,整套更改都是为了支持热补丁。在查看内核更改时,这一点变得很明显,因为新行为似乎没有任何额外的安全优势。在多个地方提到“函数覆盖”似乎也表明有可能为模块或类似的东西修补 CFG 函数。
We did find out what causes the bugs in x64dbg:
我们确实找出了导致 x64dbg 中错误的原因:
- The extra page at the end of image regions is the SCPCFG page.
图像区域末尾的额外页面是 SCPCFG 页面。 - The page is decommitted by the kernel, which is why
VirtualProtect
fails. I tinkered around a little bit, but couldn’t find a way to change this from userspace, asVirtualAlloc
withMEM_COMMIT
doesn’t seem to do anything and the followupVirtualProtect
calls fail.
该页面被内核取消提交,这就是失败的原因VirtualProtect
。我稍微修改了一下,但找不到从用户空间更改它的方法,因为VirtualAlloc
似乎MEM_COMMIT
什么也没做,后续VirtualProtect
调用失败了。
But there are a few questions I would’ve liked to have answered, and hope they’ll be answered in the future:
但是有几个问题我很想回答,并希望将来能得到回答:
- What does “SCP” stand for? My guess would be something like “Standalone Code Page”, but it could be so many other things that it’s probably not worth speculating.
“SCP”代表什么?我的猜测是“独立代码页”之类的东西,但它可能还有很多其他的东西,可能不值得推测。 - Why do hotpatchable modules need their own SCP sections, when they’re going to be linked to ntdll’s SCP section initially?
为什么可热修补模块需要自己的 SCP 部分,而它们最初要链接到 ntdll 的 SCP 部分? - Why is the decommitted usermode page still seen as committed? This one may be really easy, but I didn’t want to go down another rabbit hole.
为什么已提交的用户模式页面仍被视为已提交?这个可能真的很容易,但我不想再掉进另一个兔子洞。
I hope that more pieces of the puzzle are revealed in the upcoming months, as 24H2 releases to general audience and we get some more eyes on the kernel code. Ultimately, we won’t know the full story until MS decides to share some information, or somebody reverses the hotpatching machinery. I was not brave enough for that 🙂
我希望在接下来的几个月里,随着 24H2 向普通观众发布,更多的拼图被揭示出来,我们有更多的关注内核代码。最终,我们不会知道完整的故事,直到 MS 决定分享一些信息,或者有人逆转热补丁机制。我不够勇敢,无法:)
原文始发于ynwarcs:CFG in Windows 11 24H2