Windows利用技术:通过路径查找赢得竞争条件

访问原始链接 Google 翻译

本文最初于2016年为Project Zero博客撰写。但最终它被单独发表在PoC||GTFO期刊第13期以及印刷版的第二卷中。为了庆祝我们的新博客,我们在此博客上重新发布它,并包含了一个更新的分析,以查看它在现代Windows 11系统上是否仍然有效。

在我的Windows研究过程中,我倾向于发现相当多的竞争条件漏洞。一种相当典型的可利用形式看起来像这样:

  1. 执行一些安全检查
  2. 访问某些资源
  3. 执行安全操作

如果你能在步骤1和步骤3之间改变系统的状态,你可能能够绕过安全检查或导致其他安全问题。最大的问题是竞争窗口通常极其短暂。在某些情况下,通过足够多次地运行exploit并希望至少命中一次,可能是可利用的。在其他情况下,你可能只有一次成功的机会,如果你不能保证每次都赢得竞争,那么它实际上可能是不可利用的(然而,这并不是说你无论如何都不应该向供应商报告)。

多年来,我想出了各种技术来扩大竞争窗口,包括文件机会锁和捕获虚拟内存访问。然而,这些技术并不总是适用,所以我想要找到一种方法来增加时间窗口,以便在代码访问我们控制的资源的情况下赢得竞争。具体来说,我们将攻击命名资源的查找过程。以下是我构思一个可行解决方案的思考过程概述。

调查对象管理器查找性能

Windows NT的底层隐藏着对象管理器命名空间(OMNS)。你通常不会直接与它交互,Win32 API在很大程度上将其隐藏起来。NT内核定义了一组对象,例如文件、事件、注册表项,这些对象都可以有一个与之关联的名称。OMNS提供了查找这些命名对象的方法。它就像一个文件系统,因此例如,你可以指定一个NT系统调用的路径,如 \BaseNamedObjects\MyEvent,然后可以查找并打开一个事件对象。

有两种特殊的对象类型用于OMNS:对象目录和符号链接。对象目录充当其他对象的命名容器,而符号链接允许将名称重定向到另一个OMNS路径。符号链接被大量使用,例如Windows驱动器号实际上是指向真实卷设备对象的符号链接。当我们调用NT系统调用时,内核必须查找整个路径,遵循任何符号链接,直到找到命名对象,或者找不到匹配项。

为了创建一个有用的利用技术,我们希望使查找我们控制的资源的过程尽可能慢。例如,如果我们能让它花费1或2秒,那么我们就有巨大的机会窗口来赢得竞争条件。因此,我想找到一种方法来操纵对象管理器查找过程,以实现这个目标。

关于测试设置的说明:所有测试都将打开一个命名的事件对象,这模拟了前面可利用操作列表中的步骤2。使用的系统是一台新的Surface Pro 11th Edition CoPilot+ PC,搭载运行在3.40GHz的Snapdragon X Elite。该系统安装了Windows 11 24H2,但据我所知,在得出这些结果的过程中,没有任何AI功能受到损害。

首先,让我们测量一下正常查找所需的时间。为了尽量减少开销,我们将用C++编写测试,如下所示。它创建一个命名事件,然后用指定的迭代次数打开该事件。最后,它将基于QueryPerformanceCounter API的测量结果,返回单次迭代所花费的时间(以μs为单位)。我没有在列表中包含支持类,这些将在稍后链接的项目中提供。

static double RunTest(const wstring name, int iterations,
        wstring create_name = L"", HANDLE root = nullptr) {
    if (create_name.empty()) {
        create_name = name;
    }
    ScopedHandle event_handle = CreateEvent(create_name, root);
    ObjectAttributes obja(name);
    vector<ScopedHandle> handles;
    Timer timer;
    for (int i = 0; i < iterations; ++i) {
        HANDLE open_handle;
        Check(NtOpenEvent(&open_handle, MAXIMUM_ALLOWED, &obja));
        handles.emplace_back(open_handle);
    }
    return timer.GetTime(iterations);
}

对于测试,我将选择一个简单的唯一名称,例如 \BaseNamedObjects\MyEvent。 在我的测试系统上,迭代次数为1000的结果可能符合我们的预期,简单命名事件的查找过程大约为2μs。这包括系统调用转换、查找过程和对事件对象的访问检查。

虽然理论上你可以用这个时间赢得竞争,但这似乎非常不可能,即使在多核处理器上也是如此。所以让我们思考一种改进查找时间的方法(当我说“改进”时,我的意思是使查找时间更慢)。我们可以立即考虑两种类似的方法:

  1. 创建一个包含一个非常长名称的路径。查找过程将不得不使用字符串比较操作来比较整个名称,以验证它正在访问正确的对象。即使比较操作经过高度优化,这也应该相对于字符串长度呈线性时间。
  2. 创建多个小的命名目录并进行递归。例如 \A\A\A\A\…\EventName。这里的假设是每次查找都需要固定的时间来完成。该操作应该再次相对于目录的递归深度呈线性时间。

在这一点上,我们还没有必要查看任何实际的内核代码,我们暂时也不会开始,所以更多的经验测试似乎是可行的方法。让我们从第一种方法开始,创建一个长字符串并对其执行查找。

路径字符串可以有多长?对象管理器路径受限于UNICODE_STRING结构所允许的最大字符串大小。

struct UNICODE_STRING {
  USHORT Length;
  USHORT MaximumLength;
  PWSTR  Buffer;
}

我们可以看到Length成员是一个USHORT,它是一个无符号16位整数,这将最大长度限制为2^16 - 1。然而,这是一个字节计数,所以实际上这限制了我们最多2^15 - 1或32767个宽字符。我们需要能够在可写目录(如*\BaseNamedObject*)中创建对象,这会稍微减少长度,但不足以产生显著影响。因此,我们将使用以下代码通过长度在1个字符到32000个字符之间的名称打开事件对象:

std::wstring path;
while (path.size() <= 32000) {
    auto result = RunTest(L"\\BaseNamedObjects\\A" + path, nullptr, 1000);
    printf("%zu,%f\n", path.size(), result);
    path += std::wstring(500, 'A');
}

结果如下所示:

虽然有点噪音,但线性查找时间的假设似乎是正确的。字符串越长,查找所需的时间就越长。对于一个32000个字符长的字符串,这似乎最高达到大约35μs。在我看来,这仍然不足以作为一个有用的原语,但这当然是一个开始。

现在让我们看看递归目录方法。在这种情况下,上限大约是16000个目录。这是因为每个路径组件必须至少包含两个字符,一个反斜杠和一个单字符名称(例如 \A\A\A…)。因此,我们的最大路径限制减半。当然,我们假设遍历查找过程的时间将大于比较4个Unicode字符所需的时间,但让我们测试一下以确保。

ScopedHandle base_dir = OpenDirectory(L"\\BaseNamedObjects");
HANDLE last_dir = base_dir.get();
std::vector<ScopedHandle> dirs;
for (int i = 0; i < 16000; i++) {
    dirs.emplace_back(CreateDirectory(L"A", last_dir));
    last_dir = dirs.back().get();
    if ((i % 500) == 0)
    {
        auto result = RunTest(GetName(last_dir) + L"\\X", iterations);
        printf("%d,%f\n", i + 1, result);
    }
}

结果如下所示:


结果符合我们的预期,看起来是线性的,至少在13000个递归目录左右之前是这样,那里有一个不连续的过渡。我在同一台机器上多次运行测试,总是遇到同样的问题,但在x64机器上运行并没有显示相同的异常,所以我认为这不是代码的问题。

尽管如此,毫无疑问,查找对象的时间基于递归目录的数量是线性的。对于16000的递归深度,平均查找时间约为1300μs,大约比长路径名查找结果大40倍。当然,这也有缺点。首先,你需要在内核中创建16000个左右的目录对象,每个目录占用一些内核池内存。在64位平台上,这不太可能成为问题。

我们还需要考虑设置时间,如果太长,我们可能仍然会错过竞争条件。我们可以通过使用Windows系统调用相对于现有目录创建对象的能力来加速创建目录的过程。这使我们能够避免为每个新目录解析完整路径,毕竟这正是我们试图使其变慢的原因。

此外,进程必须保持对每个目录的句柄,否则它们将被删除,因为普通用户无法使内核对象永久存在。幸运的是,单个进程的句柄限制大约为1600万,所以我们远低于该限制。

那么1300μs对我们来说足够吗?也许吧,它肯定比正常查找的2μs大了几个数量级。但我们能做得更好吗?我们现在已经用完了路径空间,我们已经用递归目录名称填满了允许的最大字符串长度。我们需要一种方法来倍增这种效果,而不需要更长的路径。

在这里,我们可以使用对象管理器符号链接。通过将符号链接作为长路径的最后一个组件,我们可以强制内核重新解析,并重新开始查找。在最终查找时,我们只需将符号链接指向目标。

通过测试,我们只能重定向64次,然后就会收到错误,为什么我们不能无限次地这样做呢?嗯,原因相当明显,每次遇到符号链接时,内核都会重新启动解析过程,如果你将一个符号链接指向自身,最终会陷入无限循环。64次重新解析限制防止了这种情况成为问题。以下代码将为我们进行此测试:

ScopedHandle base_dir = OpenDirectory(L"\\BaseNamedObjects");
HANDLE last_dir = base_dir.get();
std::vector<ScopedHandle> dirs;
for (int i = 0; i < 16000; i++) {
    dirs.emplace_back(CreateDirectory(L"A", last_dir));
    last_dir = dirs.back().get();
}
std::vector<ScopedHandle> links;
std::wstring last_dir_name = GetName(last_dir);
for (int i = 0; i < 63; ++i) {
    links.emplace_back(CreateLink(IntToString(i), last_dir,
                       last_dir_name + L"\\" + IntToString(i + 1)));
}
printf("%f\n", RunTest(links.front().name(), 10, L"63", last_dir));

我们只进行10次测试迭代,以尽量减少运行所需的时间。结果符合我们的预期,查找事件所需的时间与符号链接的数量和递归目录的数量成正比。对于64个符号链接和16000个目录,查找事件大约需要4.5毫秒(注意我现在必须将结果的比例改为毫秒)。这应该足够了吧?也许,但我很贪心,我想要更多。我们怎样才能使查找时间更糟呢?

在这一点上,是时候拿出反汇编器,看看查找过程在内核中是如何工作的。首先,让我们看看对象目录结构是什么样子。我们可以使用WinDBG在调试会话中使用命令dt nt!_OBJECT_DIRECTORY来转储它。转换回C风格的结构,它看起来像下面这样:

struct OBJECT_DIRECTORY {
     POBJECT_DIRECTORY_ENTRY HashBuckets[37];
     EX_PUSH_LOCK Lock;
     PDEVICE_MAP DeviceMap;
     ULONG SessionId;
     PVOID NamespaceEntry;
     ULONG Flags;
     PPOBJECT_DIRECTORY ShadowDirectory.
}

基于HashBucket字段的存在,可以安全地假设内核正在使用哈希表来存储目录条目。这有一定道理,如果内核只维护一个目录条目列表,性能会相当差,然而,使用哈希表,只要哈希算法能很好地减少冲突,查找时间就会减少。但只有在算法没有被主动利用的情况下才是这样。由于我们试图增加查找的成本,我们可以故意添加具有冲突的条目,以使查找过程花费最坏情况的时间,这相对于目录中的条目数量是线性的。这再次为我们提供了另一个缩放因子,在这种情况下,条目数量将仅受可用内存的限制,因为我们永远不需要将名称放入路径中。

那么哈希算法是什么?感兴趣的主要函数是ObpLookupObjectName,它被ObReferenceObjectByName等函数引用。目录条目逻辑埋藏在这个大函数的某个地方,但幸运的是,有一个辅助函数ObpLookupDirectoryEntry具有相同的逻辑(它实际上没有被ObpLookupObjectName调用,但这并不重要),它更小,更容易进行逆向工程,以下是该函数的简化版本。

POBJECT_DIRECTORY ObpLookupDirectoryEntry(POBJECT_DIRECTORY Directory,
                                          PUNICODE_STRING Name,
                                          ULONG AttributeFlags) {
  BOOLEAN CaseInSensitive = (AttributeFlags & OBJ_CASE_INSENSITIVE) != 0;
  SIZE_T CharCount = Name->Length / sizeof(WCHAR);
  WCHAR* Buffer = Name->Buffer;
  ULONG Hash = 0;
  while (CharCount) {
    Hash = (Hash / 2) + 3 * Hash;
    Hash += RtlUpcaseUnicodeChar(*Buffer);
    Buffer++;
    CharCount--;
  }

  OBJECT_DIRECTORY_ENTRY* Entry = Directory->HashBuckets[Hash % 37];
  while(Entry) {
    if (Entry->HashValue == Hash) {
      if (RtlEqualUnicodeString(Name,
            ObpGetObjectName(Entry->Object), CaseInSensitive)) {
        ObReferenceObject(Entry->Object);
        return Entry->Object;
      }
    }
    Entry = Entry->ChainLink;
  }

  return NULL;
}

所以哈希算法非常简单,它重复混合当前哈希值的位,然后将大写的Unicode字符添加到哈希中。我们可以想出一种聪明的方法来从中获得哈希冲突,但实际上这很简单,对象管理器允许我们指定包含NUL字符的名称,因此,如果我们取目标名称,比如‘A’,并在其前面加上长度递增的仅包含NUL的字符串,我们就会同时得到哈希冲突和桶冲突。由于路径字符限制,我们只能创建32000个左右的冲突条目,但正如我们将看到的,这不是问题。以下代码将测试这种行为:

int collision_count = 32000;
ScopedHandle base_dir = CreateDirectory(L"\\BaseNamedObjects\\A");
ScopedHandle test_dir = CreateDirectory(L"A", base_dir.get());
vector<ScopedHandle> dirs;
for (int i = 0; i < collision_count - 1; i++) {
    wstring name = MakeCollisionName(collision_count - i);
    dirs.emplace_back(CreateDirectory(name, base_dir.get()));
    if ((i % 500) == 0) {
        Timer timer;
        for (int j = 0; j < iterations; ++j) {
            OpenDirectory(L"A", base_dir.get());
        }
        printf("%d,%f\n", i, timer.GetTime(iterations));
    }
}

让我们看看在单个目录中这样做的结果:

图表显示了一个大致线性的图。对于给定的冲突计数,它远不如递归目录方法好,大约100μs对1300μs,但它是查找时间中的一个乘法因子,我们可以滥用它。

我们可以将这个额外的因子应用到我们所有的16000个递归目录中,再加上符号链接,我们可能会得到一个疯狂的查找时间。然而,有一个问题:插入时间。每次我们向目录添加新条目时,内核必须进行查找以检查该条目是否已存在。这意味着对于我们添加的每个新目录条目,我们必须在哈希表中进行(n-1)^2次检查,只是为了在插入之前发现我们没有该条目。这意味着添加新条目的时间大约与条目数量的平方成正比,当然这不是三次方或指数增长,但这几乎算不上安慰。在测试机器上,创建一个包含32000个条目的单个冲突目录大约需要2.5秒(是的,秒)。如果我们想为所有16000个递归目录条目都这样做,那将需要大约12小时!

好吧,我想我们有点过头了,通过调整数值,我们可以得到一些设置时间不太长且能给我们较长查找时间的东西。但我仍然很贪心。我想看看我能把查找时间推到多远,有没有办法让我们获得所有优势?

谜题的最后一块是引入影子目录,如果对象管理器在目录中找不到条目,它允许一个备用路径。你可以使用几乎任何其他对象管理器目录作为影子,这将允许我们控制查找行为。影子目录与符号链接有一个关键区别,它们不会导致查找过程中发生重新解析。这意味着它们不受64次重新解析限制的约束。这不会导致无限循环,因为每次查找都会消耗一个路径组件,最终将没有更多的路径可供查找。如果我们按照以下安排将两个目录放在一起,我们可以传递一个类似的路径给我们的递归目录查找,而无需实际创建所有目录。

Shadow Directories (1).png
那么这实际上是如何工作的呢?如果我们打开一个形式为 \A\A\A\A\A… 的路径,内核将首先查找初始的A目录。这是图中左侧的目录。然后它将尝试打开下一个A目录,它在右侧,同样会找到。接下来,内核再次查找A,但这次它不存在,因为该目录有一个指向其父目录的影子链接,所以它在那里查找,找到相同的A目录并重复该过程。这将持续到我们耗尽要查找的路径元素为止。

那么让我们确定这种方法的性能。我们可能预期它相对于实际创建所有这些目录来说性能较差,但希望不会差太多。我们可以使用以下代码进行测试:

wstring dir_name = L"\\BaseNamedObjects\\A";
ScopedHandle shadow_dir = CreateDirectory(dir_name);
ScopedHandle target_dir = CreateDirectory(L"A", shadow_dir.get(), shadow_dir.get());
for (int i = 0; i < 16000; i += 500) {
    wstring open_name = dir_name;
    for (int j = 0; j < i; j++) {
        open_name += L"\\A";
    }
    open_name += L"\\X";
    printf("%d,%f\n", i, RunTest(open_name, iterations, L"X",
                                 shadow_dir.get()));
}

结果如下,图表中包含了原始的正常递归查找测试以供比较。

看起来不错,有趣的是,基于这个测试,影子目录的查找时间比递归目录更长。我们仍然得到一个奇怪的不连续区域,但在这种情况下,它开始得更早,也许这是基于字符串长度或类似因素的缓存效应?

所以最终的结果是,我们可以用仅仅2个目录来完成,而不是创建16000个带有16000个冲突的目录,这更容易管理,并且在我的工作站上只需要大约5秒。因此,让我们用以下代码将所有内容组合在一起,该代码具有以下参数:

  • 使用影子配置中的2个对象目录,包含16000个路径组件
  • 每个目录16000个冲突
  • 64个符号链接重新解析
wstring dir_name = L"\\BaseNamedObjects\\A";
ScopedHandle shadow_dir = CreateDirectory(dir_name);
ScopedHandle target_dir = CreateDirectory(L"A", shadow_dir.get(), shadow_dir.get());
vector<ScopedHandle> dirs;
CreateCollidingEntries(shadow_dir, 16000, dirs);
CreateCollidingEntries(target_dir, 16000, dirs);

wstring last_dir_name = dir_name;
for (int i = 0; i < 16000; i++) {
    last_dir_name += L"\\A";
}
vector<ScopedHandle> links;
for (int i = 0; i < 63; ++i) {
    links.emplace_back(CreateLink(IntToString(i), shadow_dir.get(),
                       last_dir_name + L"\\" + IntToString(i + 1)));
}
printf("%f\n", RunTest(last_dir_name + L"\\0", 1,
                       IntToString(symlink_count), shadow_dir.get()));

在测试系统上,单次查找的最终时间是请击鼓 3分钟。我想我们或许能用这个赢得竞争条件。

结论

经过所有这些努力,我们可以让内核花费大约3分钟来查找单个受控资源路径。这相当令人印象深刻。我们有很多选项可以让内核启动查找过程。文件系统和注册表最终都会与对象管理器命名空间交互,因此例如,你可以植入一个NTFS挂载点,其启动路径会导致任何打开该文件的进程锁定3分钟。

8年过去了,微软没有试图对这个利用技术做任何事情可能并不奇怪。这是一个面对病态输入时出现意外行为的典型故事,可能不值得为了有意义地提高性能而影响对象管理器代码。

关于性能的最后一点说明。这里给出的时间将根据机器的性能而有很大差异,因此它们只应作为指导。如果你回顾一下这篇文章在PoC||GTFO上的原始发布,你会发现时间要长得多。例如,最终测试在我用于测试的Xeon工作站上花了19分钟,而不是3分钟。我不知道这是否表明Surface Pro中使用的ARM64 CPU比Xeon快得多,或者这只是典型工作站上运行的垃圾程序与全新安装的Windows 11微软PC之间的差异。无论如何,如果你不能在3分钟或19分钟内利用竞争条件,那么你的bug可能真的无法利用。

你可以在Github上找到完整的测试代码。