Windows漏洞利用技巧:限制虚拟内存访问
2021-02-27 11:20:00 Author: www.4hou.com(查看原文) 阅读量:211 收藏

0x00 前言

这篇文章主要介绍了一种多年来我一直尝试利用的漏洞利用技巧,这种技巧终于在Windows 10的最新版本中取得了成功。利用这种技巧,可以让我们获得对虚拟内存的访问,在遇到虚拟内存时获得反馈并延长至无限期的访问。在这篇文章中,首先将介绍一些背景知识,来说明为什么这种技巧可以成功利用,随后将介绍发现这种利用方式的过程,以及可以使用的漏洞类型概述。

0x01 背景

我们什么时候需要这种漏洞利用技巧?Mateusz Jurczyk和Gynvael Coldwind在具有开创性的Bochspwn研究中找到了一个很好的例子,可以说明适用的安全漏洞类型。经过他们的研究,发现了一种自动发现Windows内核中内存double-fetch的方法。

如果大家还没有读过这篇论文,我可以做简要的说明,这里所说的double-fetch可以理解为一个检查时间vs使用时间(TOCTOU)漏洞。代码从内存中读取一个值(例如缓冲区长度),首先验证该值是否在所需范围之内,然后在需要用到这个值时,再次从内存中读取该值。一旦攻击者在第一次读取(检查)和第二次读取(使用)之间修改内存中的值,就可以绕过前面的检查过程,可能会引发安全问题,例如特权提升或信息泄露。下面展示的是一个简单的例子,说明double-fetch的产生过程。

DWORD* lpInputPtr = // controlled user-mode address
UCHAR  LocalBuffer[256];
 
if (*lpInputPtr > sizeof(LocalBuffer)) { ①
  return STATUS_INVALID_PARAMETER;
}
RtlCopyMemory(LocalBuffer, lpInputPtr, *lpInputPtr);②

这段代码将缓冲区从受控的用户模式地址复制到固定大小的栈缓冲区中。缓冲区以DWORD大小值开头,这个值表示缓冲区的总大小。如果lpInputBuffer指向的大小值在“第一次读取与缓冲区大小进行比较”①和“第二次读取复制到缓冲区”②之间发生了改变,就可能会导致内存损坏。举例来说,如果第一次读取时值为100,第二次读取时为400,这样的情况下代码会通过大小检查,因为100小于256,但实际上会将修改后的400复制到缓冲区中,从而破坏了栈。

一旦发现了这样的漏洞,Mateusz和Gynvael就开始研究如何利用。在本文的第四章中详细介绍了如何实现漏洞利用。像这一类的漏洞利用都是存在一定概率的,漏洞利用过程通常需要读取和写入的两个线程相互竞争,只有在两次读取之间发生修改的情况下才能够成功。

为了进一步扩大TOCTOU的窗口期,有很多技术都滥用了Windows上虚拟内存的行为。Windows上的进程通常可以访问最大8TiB大小的大型虚拟内存区域。这个大小可能要比系统中的物理内存大得多,特别是考虑到限制是按照进程而不是按照系统的。因此,为了保持如此大的内存地址空间的错觉,内核需要按需对内存进行分页。

在分配内存后,CPU的页表被设置为指示内存区域的存在,但此时被标记为无效。此时,已经分配了虚拟内存区域,但没有物理内存的支持。当进程尝试访问该内存区域时,CPU将会生成一个异常,通常称为页错误(Page-Fault),由内核进行处理。

内核可以查找导致页错误的已访问内存地址,然后尝试修复该地址。修复页错误的方式取决于内存访问的类型。一个简单的例子是,如果内存已分配但尚未使用,内核将获得一个物理内存页,将其初始化为零,然后调整页表,以将新的物理内存页映射到故障地址。一旦修复了页错误,据可以按照访问内存的指令重新启动有故障的线程,并且内存访问应该就可以成功进行,就好像它始终存在一样。

更复杂的情况是需要判断页面是否是内存映射文件的一部分。在这种情况下,内核需要请求从磁盘读取页面数据,然后才能满足页错误触发的条件。这可能要花费很长的时间,至少对于传统的机械磁盘来说是这样,因为可能需要在等待页面读取时挂起故障的线程。一旦读取页面后,就可以修复内存,然后恢复原始线程,根据错误指令重新启动线程。

1.png

最终的结果是,与CPU本身的速度(即处理页错误)相比,可能要花费大量时间。但是,滥用这些虚拟内存行为只会扩大TOCTOU的窗口期,并没有办法提供一个用于交换内存中值的精确时间。那么这样一来,漏洞利用技术仍然存在局限性。例如,在某些情况下,在具有单个CPU内核的计算机上进行漏洞利用的速度非常慢,因为这一过程依赖于并发线程的读写。

一个理想的漏洞利用原语是可以任意增大利用窗口期,从而轻松获得竞争的胜利。结合以前对这类漏洞利用的知识和经验,我觉得理想的原语应该要满足以下条件:

(1)适用于Windows 10 20H2默认安装版本操作系统;

(2)在读取或写入内存时可以发出一个明确的信号;

(3)在从用户模式和内核模式访问内存时都可以利用;

(4)允许无限期延迟内存的访问;

(5)访问的内存中的数据是任意的;

(6)可以从一系列特权级别中设置原语;

(7)可以在同一漏洞利用过程中多次捕获。

上述这些是我们完全理想情况下的条件,我们不能保证可以满足上述所有的条件。如果我们仅仅实现了其中的一部分,漏洞的可利用范围就会受到一些限制。接下来,我们对先前研究成果加以概述,这可能会让我们对如何继续寻找原语有所启发。

0x02 先前研究成果

在我与Mateusz进行交流并努力进行后续研究的过程中,似乎没有找到建立在Bochspwn论文基础上的其他TOCTOU利用思路。至少,在Windows环境下确实如此。但我在其他平台,特别是Linux上,已经发现了新的漏洞利用技术。这两种技术都依赖于我先前描述的虚拟内存的行为。

在Linux中发现的第一种技术是利用Userfault文件描述符(userfaultfd)在进程中发生页错误时获取通知。启用userfaultfd后,进程中的辅助线程可以读取通知,并在用户模式下处理页错误。处理错误的方法可能是在适当的位置映射内存或更改页面保护。这里的关键是,有故障的线程会被挂起,直到由另一个线程处理页错误为止。因此,如果内核函数访问了内存,则请求会被捕获,直到完成为止。这样一来,就可以无限期地延迟存储器访问,同时也能够获得用于访问的定时信号的原语。使用userfaultfd还可以将错误区分为读错误和写错误,因为可以对内存页进行写保护。

使用userfaultdd可以进行进程内访问,例如从内核进行访问,但是如果访问内存的代码位于另一个进程中,就没有真正的用处。为了解决这一问题,我们可以使用FUSE文件系统,就像Jann Horn此前在Project Zero博客文章中所演示的。FUSE文件系统完全以用户模式实现,但是对文件的任何请求都会通过Linux内核的虚拟文件系统API进行。由于文件访问似乎是由内核文件系统实现的,因此可以使用mmap将文件映射到内存中。当FUSE支持的内存区域上发生页错误时,会向用户模式文件系统守护程序发出请求,这可能会无限期地延迟读取或写入请求。

0x03 远程文件系统

据我所知,在Windows上不存在与Linux的userfaultd相类似的功能。但有一个功能引起了我的注意,那就是内存写入监视。但是,它似乎仅允许应用程序查询自上次检查之后是否写入了内存,并且不允许捕获内存写入。

如果我们无法将页错误捕获到虚拟内存中,那么应该如何在FUSE等用户模式文件系统上映射文件呢?遗憾的是,在Windows 10中没有内置的FUSE驱动程序(可能是尚未包含),但这不意味着没有机制可以在用户模式下实现一个文件系统。此前已经有研究成果尝试在Windows上制作真正的FUSE,例如WinFsp项目,但我认为将它们在真实系统上安装的概率会很小。

我首先想到的是尝试利用多个UNC Provider(MUP)客户端。当我们通过UNC路径访问文件时(例如\\server\share\file.bin),会由内核中的MUP驱动程序处理,并将其传递给已注册的客户端驱动程序之一。就内核而言,打开的文件是一个常规文件(包含一些警告),这通常意味着该文件可以映射到内存中。但是,对该文件内容的任何请求都不会直接处理,而是由服务器通过网络协议处理。

理想情况下,我们应该可以实现自己的服务器,处理对文件映射的读取或写入请求,这将使我们能够检测或延迟该请求,以便我们可以利用任何的TOCTOU。我们分析了Microsoft MUP驱动程序,确认其是否支持Windows 10版本,以及默认情况下是否启用了该驱动程序。

远程文件系统支持版本如下:

· SMB所有版本默认支持(可能需禁用SMBv1);

· WebDAV所有版本默认支持(服务器SKU除外);

· NFS所有版本支持,非默认启用,需调整配置;

· P9仅Windows 10 1903支持,非默认启用,需WSL支持;

· 远程桌面客户端所有版本默认支持。

尽管MUP是为远程文件系统而设计的,但并不要求文件系统服务器必须是远程的。SMB、WebDAV、NFS是基于IP的协议,可以重定向到localhost。P9使用本地Unix套接字,无论如何都无法远程进行。终端服务客户端通过RDP协议将文件访问请求发送回客户端系统。对于所有这些协议,我们可以用不同的方式来实现服务器,并查看我们是否可以检测并延迟对文件映射的读写操作。

我决定只关注SMB和WebDAV这两个,因为只有它们是默认启用的,并且很少被使用。理论上,在安装远程桌面客户端时,通常默认不会启用RDP服务器。同样,设置RDP会话也很复杂,可能需要有效的身份验证凭据,因此我决定首先分析它。

3.1 服务器消息块

SMB几乎与Windows一样古老,早在1987年就在Lan Manager 1.0中引入。最新的SMB 3.1版本与最早的版本仅有一些相似之处,但新版本不再使用TCP/IP连接的NetBIOS。从血统上来看,SMB是所有网络文件系统中最理想的一个集成,并且MUP API是针对SMB的需求而设计的。

我决定对通过SMB映射文件的行为进行简单测试。这非常简单,因为我们可以通过localhost访问同一台计算机上的SMB。首先我在本地磁盘上创建了一个1GB的文件,原因在于,如果SMB支持缓存文件数据,则不可能一次性读取如此之大的文件。然后,我启动了WireShark,并监测回环接口以捕获SMB流量,如下所示。

2.png

然后,我快速编写了一个PowerShell脚本,该脚本会将文件映射到内存中,然后使用几个不同的偏移量从内存中读取几个字节。

Use-NtObject($f = Get-NtFile "\\localhost\c$\root\file.bin" -Win32Path) {
    Use-NtObject($s = New-NtSection -File $f -Protection ReadWrite) {
        Use-NtObject($m = Add-NtSection -Section $s -Protection ReadWrite) {
            $m.ReadBytes(0, 4)
            $m.ReadBytes(256*1024*1024, 4)
            $m.ReadBytes(512*1024*1024, 4)
            $m.ReadBytes(768*1024*1024, 4)
        }
    }
}

其中,会从偏移量0、256MiB、512MiB和768MiB读取4个字节。返回到WireShark,我使用过滤器smb2.cmd == 8筛选了输出中的SMBv2读取请求,可以观察到以下四个数据包。

Read Request Len:32768 Off:0 File: root\file.bin
Read Request Len:32768 Off:268435456 File: root\file.bin
Read Request Len:32768 Off:536870912 File: root\file.bin
Read Request Len:32768 Off:805306368 File: root\file.bin

尽管长度始终为32KiB,并不是我们期望的4KiB,但这与我们在脚本中访问的确切内存偏移量相对应。请注意,这不是我们期望的常规Windows内存分配单位64KiB。在我们的测试中,除了请求的32KiB之外,我们再也没有看到其他任何内容。

我们测试过的所有字节都与32KiB对齐,那么,如果我们从地址512MiB减去2的地址访问了4个字节,在这种字节未对齐的情况下会怎么样呢?通过修改脚本并添加以下内容,可以确认其行为:

$m.ReadBytes(512*1024*1024 - 2, 4)

在WireShark中,我们看到了以下读取请求。

Read Request Len:32768 Off:536838144 File: root\file.bin
Read Request Len:32768 Off:536870912 File: root\file.bin

访问仍然在32KiB边界,但是当请求跨越了两个块时,内核会从文件中获取前面的32KiB数据,随后再获取后面的32KiB数据。大家可能认为这有一定道理,但事实证明,这只是测试过程中出现的偶然情况。

3.png

上图展示了用于处理映射文件读取的结构。在读取地址后,内核将从最近的4KiB页边界(而不是32KiB边界)请求32KiB。但是,根据支持的大页面大小,在最上面还存在一个辅助的结构。如果读取的是大页面末尾32KiB内的任意位置,则读取的偏移量始终是最后32KiB的值。

例如,在我的系统上,大页面大小(使用GetLargePageMinimum API查询)为2MiB。因此,如果从偏移量512MiB开始,位于512MiB和514-32KiB之间,内核将找到最接近的4KiB边界的偏移量并读取32KiB。在514-32KiB到514MiB之间,读取过程请求的始终是514-32KiB的偏移量,从而使得32KiB不会跨越大页面边界。

这样就允许在4KiB边界进行读取,但读取的数据量仍然为32KiB。这意味着,一旦访问一个4KiB页面,内核将填充当前页面和随后的7个页面。那么,有什么方法可以只填充单个本地页面吗?根据Mateusz的评论,我进行了测试。如果SMB服务器返回的字节数少于读取请求的字节数,则只会填充读取所覆盖的页面,并不会产生失败。通过返回这些较短的读取结果,我们可以将精度减小到本地页面大小,除了最后的32KiB之外。如果读取请求小于本地页面大小,那么其他的页面就会被清零。

那么,对于写入来说呢?我们再次修改脚本,将ReadBytes改为WriteBytes,例如:

$m.WriteBytes(256*1024*1024, @(0xAA, 0xBB, 0xCC, 0xDD))

我们可以在WireShark中看到对该文件的以下写入请求,类似于以下内容:

Write Request Len:4096 Off:268435456 File: root\file.bin

但是,如果我们进行更深入的研究,就会发现写入仅在文件关闭后才会发生,并不是在对WriteBytes调用的响应过程中。这是有原因的,没有任何方法可以检测在什么时候发生了写入操作并迫使页面刷新到文件系统。即使有方式可以在每次写入时都刷新到网络服务器,那也会对性能产生巨大的影响。

但是,所有内容都不会丢失,因此在安全写入内存之前,必须使用文件中的内容进行填充。如果我们在写入操作之前进行观察,就会看到对32KiB区域的相应读取操作,该请求包含与读取同步的写入位置。我们可以通过相应的读取来检测写入,但是无法在协议的层面将读取与写入区分开。

所有这些测试表明,如果我们可以控制服务器,就可以检测到对映射文件的内存访问。那么,我们可以延迟访问吗?这里使用Tal Aloni的SMBLibrary在.NET 5中编写了一个简单的SMB服务器。我使用自定义文件系统处理程序实现了服务器,并向读取路径添加了一些代码,当文件偏移量大于512MiB时,该路径会延迟10秒。

if (Position >= (512 * 1024 * 1024)) {
    Console.WriteLine("====> Delaying at Position {0:X}", Position);
    Thread.Sleep(10000);
    Console.WriteLine("====> Continuing.");
}

读取操作返回的数据可以是任意的,我们只需要在读取中填充适当的字节缓冲区即可。为了测试访问时间,我将内存读取请求包装在Measure-Command调用中,以对内存访问进行计时。

Measure-Command { $m.ReadBytes(512*1024*1024 - 4, 4) }
Measure-Command { $m.ReadBytes(512*1024*1024 - 4, 4) }
Measure-Command { $m.ReadBytes(512*1024*1024, 4) }
Measure-Command { $m.ReadBytes(512*1024*1024, 4) }

为了比较访问时间,在512MiB边界以下的4个字节处、512MiB边界处分别发出读取请求。通过发出这两个请求,我们应该可以看到每次读取的结果是否不同。结果如下:

# Below 512MiB (Request 1)
Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 1
Milliseconds      : 25
...
# Below 512MiB (Request 2)
Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 0
Milliseconds      : 1
...
# Above 512MiB (Request 1)
Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 10
Milliseconds      : 358
...
# Above 512MiB (Request 2)
Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 0
Milliseconds      : 1
...

低于512MiB的第一次访问大约需要1秒钟,这是因为仍然需要向服务器发出请求,并且服务器是使用.NET编写的,因此运行新代码的启动时间可能会很慢。第二个请求花费的时间远远少于1秒,因为此时内存已经本地缓存,所以不需要任何请求。

对于512MiB以上的访问,第一个请求大约需要10秒,这与增加的延迟相关。第二个请求不到1秒钟,因为该页面现在已经本地缓存。这正是我们所期望的,并且证明我们可以至少延迟10秒钟。实际上,可以在强制重置连接之前将请求延迟至少60秒。这基于SMB客户端的会话超时。可以在PowerShell中使用以下命令查询SMB客户端超时:

PS> (Get-SmbClientConfiguration).SessionTimeout
60

测试过程中,需要注意几点。首先,客户端或Windows缓存管理器似乎能对远程文件进行一些缓存。如果在打开文件时请求特定的访问权限,例如GENERIC_READ | GENERIC_WRITE用于所需的访问,然后启用缓存。这意味着,如果读取请求先于在本地缓存,就不会发送到服务器。但是,如果我们为所需的访问指定了MAXIMUM_ALLOWED,似乎就不会进行缓存。其次,有时文件的某些部分会被预先缓存,例如文件的第一个和最后一个32KiB。我还没弄清楚这是什么原因,奇怪的是,本地代码似乎比.NET代码发生这种情况的概率更高,也许是因为Windows Defender可能正在监测内存,还有可能是Superfetch。通常情况下,只要将内存访问定位在一个大文件中间的某个位置,应该就是安全的。

我们在运行示例代码后,可能会注意到一个问题,在本地运行示例服务器失败,并显示以下错误:

System.Net.Sockets.SocketException (10013): An attempt was made to access a socket in a way forbidden by its access permissions.

默认情况下,Windows 10启用了SMB服务器。这将接管TCP端口并独占这一端口,因此普通用户无法绑定到这些端口。可以禁用本地SMB服务器,但这需要管理员权限。尽管如此,如果我们必须与远程服务器进行通信,还是需要验证SMB服务器方法是否能起作用。

我还试图研究了一些技巧,希望能使内置SMB服务器正常工作。例如,我尝试设置机会锁来捕获文件读取。我利用这个技巧来实现LUAFV驱动程序中的TOCTOU漏洞利用。遗憾的是,SMB服务器会检测到这个文件已经处于锁定状态,并等待机会锁中断,然后才允许访问该文件。这样一来,这种方法就不可行了。

为了进行测试,可以禁用LanmanServer服务及相应驱动程序。如果要在任意系统上禁用它,几乎可以确定需要连接到远程服务器。我还公开了示例服务器的代码,尽管这只是一个演示程序,但完全可以复现。它允许读取本地页面大小的粒度(假设为4KiB)。服务器代码理论上在Linux上可以使用,但从NuGet的SMBLibrary 1.4.3版本开始,存在一个Bug,该Bug会导致服务器出错。GitHub上有一个修复程序,但在撰写本文时,还没有发布更新后的补丁包。

那么,这种SMB客户端滥用的方式是否能满足我们之前的条件呢。事实上,在7条中已经满足了5条。

(1)适用于Windows 10 20H2默认安装版本操作系统(符合);

(2)在读取或写入内存时可以发出一个明确的信号(符合);

(3)在从用户模式和内核模式访问内存时都可以利用(符合);

(4)允许无限期延迟内存的访问(符合);

(5)访问的内存中的数据是任意的(符合);

(6)可以从一系列特权级别中设置原语;

(7)可以在同一漏洞利用过程中多次捕获。

使用SMB客户端确实符合我们的大多数条件。我同时验证了内核模式和用户模式,二者都可以捕获内存。最大的问题在于,难以从沙箱化应用程序中使用。这是因为在默认情况下,MUP限制来自受限制的低IL进程的远程文件系统访问,并且AppContainer沙箱需要特定的功能,而这些功能不太可能授予大多数应用程序。并不是说完全不适用沙箱应用程序,但确实很难做到。

尽管我们的技巧没有真正地无限期延迟内存读取,但就我们的场景来说,60秒的时间对于大多数漏洞利用来说已经足够。同样,一旦激活了捕获,我们就无法强制内存管理器从服务器请求同一页面。我尝试过使用内存缓存标志和直接IO,但至少对于SMB上的文件似乎没有任何作用。不过,可以在映射文件时指定自己的基址,以便通过取消映射原始文件并映射到新副本以将文件中的不同偏移量映射到相同的虚拟地址。这样一来,就可以多次使用相同的地址。

3.2 WebDAV

既然SMB无法在本地轻松使用,那么WebDAV呢?默认情况下,Windows 10不会启用TCP 80端口,因此我们可以启动自己的Web服务器进行通信。另外,与Linux不同的一点是,不需要管理员权限来绑定到编号为1024以下的TCP端口。即使没有这两个优势,WebDAV客户端也支持制定服务器TCP端口的语法。例如,如果使用路径 \\localhost@8080\share ,就可以通过8080端口建立WebDAV HTTP连接。

但是,WebDAV客户端是否公开了合适的读取和写入原语,让我们可以捕获内存访问呢?我使用NWebDav库编写了一个简单的WebDAV服务器,以用于本地文件。运行脚本,在8080端口上指定WebDAV服务器打开1GiB文件,立即就出现了报错:

Get-NtFile : (0xC0000904) - The file size exceeds the limit allowed and cannot be saved.

仅仅打开文件的过程就出现了错误,错误代码为STATUS_FILE_TOO_LARGE。我们可以在多个Microsoft知识库文章中找到其原因。WebDAV共享中访问任何文件的默认大小限制为50MB(十进制兆字节),因为以前如果让Windows系统下载任意大小的文件可能会导致拒绝服务。

这样的大小限制,导致WebDAV不再适用于我们的攻击场景。如果将文件大小调整为50MB以下,则WebDAV客户端会在从文件打开调用返回之前,就将整个文件拉取到本地磁盘。然后,将该文件作为本地文件映射到内存中。WebDAV服务器永远不会收到同步读取/写入内存映射的GET或PUT请求,因此没有检测或捕获特定内存请求的机制。

0x04 文件系统覆盖API

我们可以滥用SMB客户端,但是默认安装情况下不适用于本地。因此,我决定找寻另一种方法。在研究Windows筛选器驱动程序时,我注意到有一些驱动程序提供了一种在现有文件系统上覆盖另一个文件系统的机制。借助查阅MSDN并查看API文档,最终找到了3个比较适合的,具体如下:

· 投影文件系统(Projected File System),支持Windows 10 1809,默认不启用;

· Windows Overlay(WOF),支持所有版本,默认启用;

· 云文件API,支持Windows 10 1709,默认启用(非桌面服务器SKU除外)。

目前,最值得关注的就是投影文件系统。这是由Windows开发的,旨在为GIT提供虚拟文件系统。它允许将占位符文件“投影”到磁盘目录中,并且根据需要将这些文件的内容重新复原成完整文件。理论上看,这听起来非常理想,只要能够零碎地填充文件的内容,我们就可以在接收PRJ_GET_FILE_DATA_CB回调时添加延迟。

但是,基于微软ProjectedFileSystem示例代码的基础实现会始终在文件打开期间复原整个文件,类似于WebDAV。也许我错过了某个地方,可以设置为流式传输内容,而不是一口气填充它,但是我目前没能找到。除了这个因素之外,默认情况下系统中也不会安装投影文件系统,这样一来它就没有太大的实际作用。

Windows Overlay(WOF)实际上不允许我们实现自己的文件系统语义。相反,它允许我们从辅助Windows映像文件(WIM)覆盖文件,或将其压缩到同一个卷上。这确实无法为我们提供所需的控制功能,我们也许可以利用它,但还是需要付出很多努力才行。

最后一个就是云文件API。OneDrive提供了本地在线文件系统,可以用来实现任何文件系统覆盖。其工作原理与投影文件系统非常相似,包含文件占位符和按需对文件进行复原的概念。文件的内容不需要来源于任何在线服务(例如OneDrive),它们都可以在本地获取。至关重要的是,在我们进行了一些基本的测试之后,发现它支持根据正在读取的内容流式传输文件的内容,并且可以延迟文件数据请求,此时读取线程将被阻塞,直到满足读取条件为止。在配置基础同步根目录时,可以定义CF_HYDRATION_POLICY_PRIMARY策略为CF_HYDRATION_POLICY_PARTIAL值以启用这一功能。这样一来,云文件API只能合并被访问的文件部分。

这似乎非常完美,但我在使用PowerShell文件映射脚本进行测试时,发现没有按照预期进行工作,始终会要求我的云文件提供者提供完整的文件。我们检查云筛选器驱动程序,发现在收到映射占位符文件的请求时,IRP_MJ_ACQUIRE_FOR_SECTION_SYNCHRONIZATION处理程序会在完成之前对文件进行完整地复原。如果文件没有完整复原,那么对NtCreateSection的调用将永远不会返回,这就会阻止文件映射到内存中。

进一步对过滤器进行研究的过程中,我意识到,可以将SMB客户端环回与云筛选器API结合起来。我们已经知道,SMB客户端并没有真正映射文件,即使对于本地也是如此,实际上是在通过SMB协议按需读取文件。并且我们也知道,只要文件没有映射到内存中,云筛选器API就能够按需对文件的一部分进行流传输。因此,我们采用了下图的部署:

4.png

要使用这个原语,我们首先使用CfRegisterSyncRoot API来配置策略,注册同步根目录,从而设置我们的云服务商。然后,可以使用CfCreatePlaceholders在目录中创建1GiB的占位符。此时,在磁盘上还没有任何文件内容。如果现在我们通过SMB环回客户端打开并映射占位符文件,那么这个文件不会立即被还原。

对映射的任何内存进行访问,都将导致SMB客户端请求一个32KiB块,该请求将传递给我们的用户模式云服务商,我们可以根据需要进行检测或延迟。毫无疑问,文件的内容也可以是任意的。根据测试,似乎不能像实现自定义SMB服务器时那样,将读取粒度强制减少到本地页面大小,但是仍然可以在大页面的大小限制内,以本地页面大小边界进行请求。这样可能能够修改文件大小,以欺骗SMB服务器进行较短的读取。在这里提供了云服务商的示例实现。

0x05 用例

现在,我们已经获得了一种利用方法,可以让我们捕获和延迟虚拟内存的读写操作。最大的问题在于,这是否能改善对double-fetch等漏洞的利用过程?其答案取决于实际的漏洞。首先要提醒的是,我在这里所说的“页面”,是指引起对SMB服务器的请求的内存单位(例如32KiB),而不是本地页面大小(例如4KiB)。

我们以本文开头给出的示例为例。这个漏洞从同一个内存地址lpInputPtr读取两次其中的值。第一次是用于比较,第二次是用于复制。这样的漏洞利用所具有的局限性在于,内存捕获只有一次机会。如果在读取大小以进行比较的过程中被捕获,我们就可以实现无限的延迟。但是,一旦提供了请求的内存页面,并且恢复了出错的线程,就不会再在第二次读取时再次捕获,仅仅会从内存中读取,就如同没有事情发生过一样。

这时不禁要问,在检测到第一次读取时,是否可以重新映射内存页面?遗憾的是,这样不行。在恢复线程后,它将在错误指令处重新启动,并再次执行读取,因此会发生以下的情况:

5.png

从图中可以看出,最终陷入了无限循环,因为重新映射了一个新页面,这恰好出发了另一个页错误。如果不执行步骤③,那么操作可以完成,并且在恢复线程、读取当前内存以进行比较和第二次读取之间存在一个时间窗口期。但是,在这个示例中,窗口期可能是几条指令的执行时间,因此使用我们改进后的技巧也许不会比原来的漏洞利用方式更优。换而言之,我们的优势仅仅在于得知了什么时间发生读取,这样就可以更准确地瞄准这个窗口期。

这个例子只是最不理想的情况,在两次读取之间也可能会有更多的时间。例如,Bochspwn的研究中就给出了另一个示例:

PDWORD BufferSize = // controlled user-mode address
PUCHAR BufferPtr  = // controlled user-mode address
PUCHAR LocalBuffer;
 
LocalBuffer = ExAllocatePool(PagedPool, *BufferSize);①
if (LocalBuffer != NULL) {
  RtlCopyMemory(LocalBuffer, BufferPtr, *BufferSize);②
} else {
  // bail out
}

这里存在相同的double-fetch行为,但是不同之处在于将值传递给了另一个函数(在本例中为ExAllocatePool,该函数分配内核内存)。这种情况中,①和②之间可能会有相当长的时间延迟,具体要取决于当前的内存配置或请求的分配量。那么,我们有什么办法可以赢得竞争?

首先,这肯定不能100%成功。但是我们可以利用一种行为来尝试稍微同步一下读取和写入线程。我们回想之前,为了写入未解析的页面,必须首先从服务器读取页面的内容。因此,为了保持一致性,任何线程写入未解析的页面都必须产生页错误,并且与另一个刚从该页面读取的线程都等待相同的锁,如下图所示:

6.png

通过同步读取和写入的线程,我们可以有一定的机会在漏洞利用期间发生写入操作。但这仍然是一个概率问题,取决于调度程序。例如,写入线程可能在读取线程之前被唤醒,这样会导致指针取到的是最终值。还有可能,读取线程会在写入线程计划运行之前完成运行,从而导致值不会更改。这里,可能还有一些跟调度程序有关的技巧,例如使用多个读取或写入线程,或者选择适当的优先级,从而利用这些优先级来保证读写顺序。也欢迎大家对如何提高可靠性提出更好的想法。

我们可能还会想到一种方法,就是未对齐的访问,将值分成两个单独的页面。从微体系结构的角度来看,读取的内容很可能会分为两部分,先读取一页,然后读取另一页。但是,我们回顾页错误的工作原理,它会生成一个异常,从而导致处理程序在内核中执行。此时,当内核处理页错误时,指令已经完成的所有工作都会被淘汰。在恢复线程后,它将重新启动出错的指令,该指令将重新发出适当的微操作,以从未对齐的地址进行读取。除非编译器为未对齐的访问生成了两次加载(在某些体系结构上可能会发生这种情况),否则将无法重新启动整个过程中的内存访问指令。

似乎我们尝试了很多方法,都只能略微提高漏洞利用技巧的可用性。事实上,漏洞利用的类型与海里的鱼一样多。例如,我们可以将原始示例修改如下:

PDWORD lpInputPtr = // controlled user-mode address
UCHAR  LocalBuffer[256];
 
if (lpInputPtr[0] > sizeof(LocalBuffer) || lpInputPtr[1] != 2) {
  return STATUS_INVALID_PARAMETER;
}
RtlCopyMemory(LocalBuffer, lpInputPtr, *lpInputPtr);

现在,检查过程会确保缓冲区足够大,并且缓冲区的第二个DWORD没有设置为2。第二个字段可能代表缓冲区类型,并且类型2对这个请求无效。如果检查这段代码的编译器输出,会发现它与本地代码存在2-3条指令的差异。从概率上来说,这似乎并没有从根本上提高赢得TOCTOU竞争的概率。但是,借助我们的漏洞利用技巧,现在就可以构建一个具有确定性的漏洞利用。

7.png

上图展示了如何实现这种具有确定性的漏洞利用。尽管缓冲区在虚拟内存中仍然是连续的,但是我们可以将Size字段放置在与输入缓冲区其余部分不同的页面上。第一页(N-1)应该已经被故障转移到内存中,并且包含小于LocalBuffer大小的Size字段。我们就可以让大小①的读取正常完成。

接下来,代码将读取第N页上的Type字段②。该页面当前不在内存中,因此在访问该页面时会发生页错误③。这要求内核从文件中读取内容,我们可以检测和延迟。当检测到读取后,只要修改Size字段,使其包含一个大于LocalBuffer大小的值④。最终,我们完成读取,这会在Type字段读取指令重新启动线程⑤。这段代码可以继续,并且会由于读取过大的Size字段导致内存损坏。

这里的要点在于,如果在两次读取(double-fetch)之间,代码接触了我们控制的任何用户模式内存,而非double-fetch的内存,应该就有可能将其转换为具有确定性的漏洞利用。在这种情况下,目标系统有几个CPU、内核中的调度算法是什么、double-fetch之间有多少条指令等因素都无关紧要,漏洞利用都可以正常工作。

在double-fetch的后续文章中,提供了一些漏洞利用的相关数据。到目前为止,按照上述示例,如果选择了正确的时间窗口期,在几秒之后成功的概率可以达到100%。对于同一个漏洞的某些类型,我们可以获得100%的可靠性,但是除了可靠性之外,与该文章相比,其他方面没有太多的改善。

到目前为止,所有的示例都只展示了竞争条件的利用。但是在文章中还提到了第二类漏洞,也就是二进制竞争,这种方式很难被利用,并且从未获得过100%的成功。我们来看看文章中的示例,看看我们的漏洞利用技巧是否更好一些。

PVOID* UserPointer = // controlled user-mode address
__try {
   ProbeForWrite(*UserPointer, sizeof(STRUCTURE), 1);①
   RtlCopyMemory(*UserPointer, LocalPointer, sizeof(STRUCTURE));②
} __except {
   return GetExceptionCode();
}

从表面上看,这与前面的示例并没有太大的不同,但是在这种情况下,目标指针被更改,而不是大小。用于检查指针的ProbeForWrite内核API位于用户模式地址,并且内存是可写的。这是验证用户提供的指针未指向内核内存的常用方法。

如果在①和②之间指针值从用户模式地址改为了内核模式地址,则该示例可以覆盖内核内存。由于仅存在两个有效的指针值,即用户模式地址或内核模式地址,因此具有概率性的漏洞利用会更难一些。如果我们暴力破解指针值,即使在两次读取之间将其更改为内核指针,也有可能最终两次都读取的是用户模式指针。

幸运的是,由于调用了ProbeForWrite,如果可以捕获用户内存访问,则如下图所示:

8.png

首先读取UserPointer①并将结果指针值传递给ProbeForWrite。ProbeForWrite API检查指针是否位于用户模式地址空间中,然后探查内存的每个页面,直到长度参数的大小②。如果页面无效或不可写,则会生成异常,并由示例的__except块捕获。这一过程为我们提供了漏洞利用的机会,我们可以在要探测的用户模式页面上利用这个技巧,导致ProbeForWrite生成我们可以捕获的页错误③。但是,由于所探查的地址与存储指针的地址不同,我们可以在捕获请求的同时将其修改为包含内核模式的地址④。这样一来,我们就可以确定赢得竞争。

当然,我一直关注内核double-fetch,因为它最初让我去寻找这样的行为。在很多情况下,这种方式都可以用于用户模式应用程序的漏洞利用。最明显的一种场景就是服务与特权较低的应用程序共享内存。其中的一个示例就是DfMarshal COM封送处理程序中存在的double-fetch。COM封送处理程序在进程之间共享一个内存段,因此可以使用这里的漏洞利用技巧。但这个技巧并不是必须的,因为易受攻击代码的逻辑可以让我们创建一个无限循环来获得double-fetch窗口期。但如果这种方法行不通,在代码处于可以切换句柄的位置时,我们就可以利用这个技巧来检测和延迟。

另一个更巧妙的用途是进程特权从特权较低的进程读取内存。这可能要用到显式使用的API,例如ReadProcessMemory,也可能是间接的,例如使用NtQueryInformationProcess查询进程的命令行就可以读出我们控制的内存位置。

使用这个漏洞利用技巧时需要注意的是,它可以用来增加窗口期以赢得时间的竞争。在这种情况下,它与我之前关于OpLock的研究类似,但是用于内存访问。实际上,对内存的访问可能是易受攻击的代码附带的,它不一定是内存double-fetch,也不一定是TOCTOU漏洞。举例来说,我们可能赢得带有符号连接的两个文件路径之间的竞争。只要可以让存在漏洞的代码探测我们控制的用户模式地址,就可以将其作为定时信号,并扩大漏洞利用的范围。

0x06 总结

在这里,我已经描述了基于SMB和云文件API的一种漏洞利用技巧,它可以帮助证明对某些类型的应用程序和内核漏洞的利用。也许还有其他方法能够借助我未曾使用过的API达到类似的效果,但是就目前而言,这是我能想到的最好方法。它可以让我们捕获来自用户模式内存的读取,检测访问发生的时间,并将读取延迟至少60秒。在这里,提供了SMB和云文件API漏洞利用技巧的代码示例。

在得出结论前,我需要再强调一下这个漏洞利用技巧的局限性。

(1)不能在沙箱中使用,只能以普通用户权限进行。

(2)从文件映射的任何页面仅有一次机会。如果任何其他事物(例如反病毒软件)试图读取该页面或从文件中读取,都有可能会提早触发捕获。

(3)无法检测到读取的确切位置,仅限于4KiB的粒度。对于通过云文件API进行的本地访问,始终会填充接下来的7页和32KiB读取的一部分。如果访问自定义SMB服务器,则读取大小可以减小为4KiB。为防止某些漏洞的利用,这些漏洞仅需要在较大结构内的一小部分区域内进行精确捕获。

(4)只能间接检测写入,不能专门捕获写入。

从实际角度开栏,这里介绍的技巧并不能显著提高Bochspwn论文中描述的传统double-fetch的成功率。实际上,对于大多数这样的漏洞,我们可能还想要使用一种概率更高的方式。但是,该技巧适用于其他类别的漏洞,在这些类别中,都可以得到一个确定的定时信号。

这种漏洞利用技巧仅有一次机会,这也导致针对简单的double-fetch代码路径进行漏洞利用的过程中可能无法得到比较大的收益。同样,更复杂的代码在访问存在漏洞的代码之前可能会多次读取和写入内存地址,这可能会使触发信号变得更加困难。

本文翻译自:https://googleprojectzero.blogspot.com/2021/01/windows-exploitation-tricks-trapping.html如若转载,请注明原文地址


文章来源: https://www.4hou.com/posts/MNJ3
如有侵权请联系:admin#unsafe.sh