翻译:山66
预计稿费:200RMB(不服也来投稿!)。
投稿方法:通过linwei#360.cn发送电子邮件或登录网页在线投票
前言
2011年,当Windows 7 service pack 1达到顶峰时,我开始接触编程,j00ru发布了白皮书《Windows security hardening through kernel》,该白皮书以用户模式以多种方式访问Windows内核指针。
我决定重新回味一下这篇白皮书中讨论的各种技术,搜罗可用于Windows 7上的相应版本,然后调查它们能否在Windows 8 / 8.1 / 10上奏效。遇到无法在Windows 8 / 8.1 / 10上工作的时候,我会进一步研究相应的函数在新版本的Windows中发生了怎样的变化。这方面的工作,虽然很多都被别人做过了,但通过动手实践,我还是学到了很多东西;同时,作为一个有趣的逆向工程的练习,或许对大家也会有所帮助。
对于每个例子,我都会提供一个可用于Windows 7 32位的实现,然后将其移植到64位Windows,如果发现无法用于新版本的Windows 的话,则说明原来用到的某些特性在新版本操作系统中已经发生了变化。
本文中讨论的每一种技术,在Github上都可以下载到相应的Visual Studio项目。
Windows System Information classes
NtQuerySystemInformation是一个经典的和众所周知的未公开函数,利用逆向工程的获得的各种细节,人们发现它可以用来收集关Windows内核的状态信息。 它在MSDN上的定义如下:
第一个参数是SYSTEM_INFORMATION_CLASS的值,这个值决定返回什么信息。 这些值可以在win中找到,其他的值也被人通过逆向工程找到了(例如在wine项目实现中就可以找到这些值)。 在j00ru的论文中,他考察了4个枚举值,我们将在后文中单独加以解释。
第二个参数是指向输出数据的结构的指针,它会随着SystemInformationClass值的不同而变化,第三个参数是其长度。 最后一个参数用于返回写入输出结构的数据量。
为了避免为各个SystemInformationClass值重复编码,我将在这里给出实际定义和调用NtQuerySystemInformation的代码。 首先,我们将包含标准的Visual Studio项目头文件,同时要完整导入Windows.h文件,因为它定义了我们需要用到的许多Windows特有的结构和函数。
Windows 7 32 bit SystemModuleInformation
这里介绍的第一个SystemInformationClass值是SystemModuleInformation,当使用此值时,返回当前已经加载到内核空间的地址的所有驱动程序的相关数据,包括它们的名称和大小。
首先,我们需要定义枚举值SYSTEM_INFORMATION_CLASS,稍后我们将其传递给NtQuerySystemInformation,这里其值为11,如下所示。
构建并运行上述代码,我们将得到以下输出结果。
这个例子的完整代码(包括后面讨论的在64位Windows上运行的版本)可以从Github上面下载。
SystemHandleInformation
在j00ru的论文中提到的第二个SystemInformationClass值是SystemHandleInformation,它给出了内核内存中所有进程的每个对象的HANDLE和指针,其中包括所有Token对象。在这里,我们将使用SystemHandleInformation的扩展版本,因为原始版本只给出16位的HANDLE值,这在某些情况下可能是不够的。 首先,我们需要再次定义正确的SYSTEM_INFORMATION_CLASS值。
为了使用这个SystemInformationClass值,NtQuerySystemInformation提供了一个奇怪的API,当使用NULL指针调用它时,它不是返回所需的内存,而只是返回NTSTATUS代码0xC0000004。 这是STATUS_INFO_LENGTH_MISMATCH的代码,当为待写入的输出分配的内存不足时,就会返回该代码。为了处理这个问题,我为输出分配了很少的内存,然后不断调用NtQuerySystemInformation,每次将内存量加倍,直到它返回一个不同的状态代码为止。
SystemLockInformation
在j00ru的论文中考察的第三个SystemInformationClass值是SystemLockInformation,它返回当前存在于内核内存中的每个Lock对象的详细信息和地址。 同样的,我们首先要定义正确的SYSTEM_INFORMATION_CLASS值。
完整代码,包括64位Windows的相应版本,可以从Github下载。
SystemExtendedProcessInformation
在j00ru的论文中提到的最后一个SystemInformationClass值是SystemExtendedProcessInformation,它返回在系统中运行的所有进程和线程的详细信息,包括每个线程用户和内核模式堆栈的地址。 首先,我们需要定义正确的SYSTEM_INFORMATION_CLASS值。
在这些结构中,我们感兴趣的关键值是StackBase和StackLimit字段,它们提供了线程内核模式堆栈的起始地址及其边界。
再次重申,NtQuerySystemInformation不会告诉我们需要分配多少内存,所以我们需要利用循环来调用它。
这个示例的完整代码(包括用于64位系统的相应版本)可以在Github上找到。
Windows 8 64 bit
所有这些代码,要想用于64位Windows 8上,都需要稍作修改。当然,具体需要做出怎样的修改,则需要借助于调试代码本身来完成。
SystemModuleInformation
只有两处需要稍作修改,首先位于system_module结构之后的ImageBaseAddress指针是32位的,所以需要加入一个填充变量,至于填充的额外32位所包含的内容则是无所谓的。
编译之后,就可以成功运行在64位Windows 8上面了:
此外,编译后的代码也可以从Github上下载。
SystemHandleInformation
对于SystemHandleInformation来说,只需要改动print语句,其他一切正常。
在64位Windows 8上的运行结果:
最终的代码也可以从Github上下载。
SystemExtendedProcessInformation
SystemExtendedProcessInformation所需的改动也很少,只要在SYSTEM_THREAD_INFORMATION结构中填充128位即可——它肯定是有用处的,但具体我还不太清楚。
完成上述修改之后,代码就可以在64位Windows 8上面正常运行了:
最终的代码也可以从Github上下载。
Windows 8.1 64 bit onward
至于在Windows 8.1上修改这些代码方面,我还是多少有点优势的:毕竟我早就阅读过Alex Ionescu的一篇文章,因此我知道可通过一种稍微不同的方式来运行二进制代码。 在Windows Vista中引入了完整性级别的概念,这将导致所有进程在下面所示的六个完整性级别之一上面运行。
完整性级别较高的进程可以访问更多的系统资源,例如沙盒进程通常是在较低的完整性级别上面运行,并且对系统其余部分的访问权限是最小的。 更多的细节可以在上面链接的MSDN页面上找到。
我创建了一个完整性水平较低的cmd.exe副本,具体方法请参见这里。当我试图在这个命令提示符下面运行NtQuerySystemInformation的二进制代码时,就会得到错误代码0xC0000022:
STATUS_ACCESS_DENIED的这个NTSTATUS代码定义如下:
进程已请求访问对象,但尚未授予这些访问权限。
但是,如果在中等完整性级别的命令提示符下运行该二进制代码话,则一切正常:
这意味着必须向函数添加完整性级别检查。
您可以使用SysInternals中的procexp查看完整性级别进程(见最后一列):
这时我开始研究,为了添加了该项检查,NtQuerySystemInformation在Windows 8和8.1之间发生了哪些变化。利用IDA考察NtQuerySystemInformation函数后,我发现它依赖于调用“ExpQueryInformationProcess”函数。
通过Diaphora检查这两个版本的n的差异,我发现这个函数在两个操作系统版本之间发生了重大变化。
通过比较两个实现汇编代码的不同之处,很容易就可以看出,这里添加了一个对“ExIsRestrictedCaller”的调用,通过交叉引用可以获悉,它主要是从ExpQuerySystemInformation中调用的,并且在相关函数中也被调用了几次。
我还看了一下函数本身,我注释的汇编代码见下文。
根据我的理解,该函数的工作机制为:
1、检查在ecx中传递给它的未知值是否为0,如果是的话就返回0
2、使用PsReferencePrimaryToken增加调用进程令牌的引用计数
3、使用SeQueryInformationToken将调用进程令牌的TokenIntegrityLevel读入一个局部变量
4、使用ObDereferenceObject减少调用进程令牌的引用计数
5、检查SeQueryInformationToken是否返回错误代码,如果是就返回1
6、如果SeQueryInformationToken成功,将读取令牌完整性级别,并与0x2000(这个值表示中等完整性级别)进行比较
7、如果令牌完整性级别低于0x2000则返回1,否则返回0
Alex Ionescu在他的博客上提供了这个函数的逆向版本。 每次该函数被调用时,它就返回1,然后调用函数将返回前面提到的错误代码。
Win32k.sys系统调用信息泄露 Windows 7 32 bit
这个问题最初是由j00ru在发布白皮书几个月前发现的,并在原始博客文章中有更深入的讨论。
问题是,win32k.sys中的一些系统调用的返回值是小于32位的,例如VOID或USHORT,所以,在返回之前没有清除eax寄存器。 由于各种原因,在调用返回之前,内核地址在eax中结束,因此在调用之后立即读取eax,这些地址就会被完全暴露或部分暴露。
例如NtUserModifyUserStartupInfoFlags就完全暴露了ETHREAD结构的地址,下面你可以看到,在该函数返回之前调用了UserSessionSwitchLeaveCrit,这似乎向eax中加载了一个指向ETHREAD的指针,但是,由于函数返回之前没有清空寄存器的内容,导致这个地址完整保留了下来。
要想使用这些系统调用来泄漏地址,我们首先需要添加标准include和Winddi,因为它们定义了将要调用的函数使用的一些GDI(图形设备接口)的结构。
Windows 8 64 bit onward
要使代码在Windows 8上运行,必须首先更新函数偏移量来匹配新的主机VM的二进制代码。 请注意,这里缺少NtGdiFONTOBJ_vGetInfo函数的地址,因为该函数在Windows 8 VM的gdi32版本中没有相应的定义。
最后,将相应的变量的长度改为64位,同时所有的printf语句也要进行相应的修改。
最终的代码可以从Github上下载。
忙活半天,终于可以在64位系统上运行我们的代码了,并且这个问题在Windows 8中也得到了修复!
Matt Miller在Black Hat USA 2012上的演讲的内核部分中讨论Windows 8漏洞利用缓解改进情况的时候,曾经引用了这个修复:
解决这些问题的方法非常简单,观察一下的从Windows 7和Windows 8中的win32.sys(如下图所示),我们可以看到,现在这些函数的实现方式中,调用敏感函数后所有的RAX被设置为一个新值。例如,在我考察过的两个泄露ETHREAD的函数中,UserSessionSwitchLeaveCrit导致返回前将泄露的地址放入RAX/ EAX中,这个问题已得到修复。
NtUserGetAsyncKeyState:Windows 8的实现在左边,Windows 7的实现在右边。 以前,这会导致泄漏ETHREAD的部分地址,因为在函数返回之前,只有eax的前16位被修改,现在使用movsx后,它将对较高的位进行清零。
NtUserModifyUserStartupInfoFlags:Windows 8的实现在左边,Windows 7的实现在右边。 以前,这会泄漏完整的ETHREAD地址,因为eax在返回之前根本没有被修改,现在eax被显式地设置为1。
描述符表 Windows 7 32 bit
x86描述符表有各种用途,在j00ru的论文中考察的是中断描述符表(IDT),处理器用它查找处理中断和异常的代码,而全局描述符表(GDT) 由处理器使用以定义内存段。
关于描述符表的更多细节请参考j00ru的论文,它们主要在内存隔离和特权隔离中扮演关键角色。全局描述符表寄存器(GDTR)定义了GDT的起始地址及其大小,它可以通过sgdt x86指令读取:
SGDT仅对操作系统软件有用; 但是,它可以在应用程序中使用,并且不会生成异常。
这意味着在Ring 3中运行的代码可以读取GDTR的值且不会引起异常,但无法对它进行写入操作。 GDTR的格式如下:
中断描述符表寄存器(IDTR)定义了IDT的起始地址及其大小,它可以使用sidt x86指令读取,并且与sgdt类似,也可以从ring 3调用,这一点真是带来了极大的便利性。IDTR的格式如下所示:
此外,Windows允许使用GetThreadSelectorEntry函数读取GDT中的特定表项。 在j00ru的论文中,他使用它来读取几个潜在的敏感表项,但是我将通过它来读取任务状态段(TSS)描述符。
我们可以使用内联汇编以6字节缓冲区作为参数来执行sidt指令。
完成所有这些工作后,我们就可以编译并运行代码来查看地址了:
包括用于64位Windows的完整代码都可以从Github下载。
Windows 8 64 bit
我们的代码只要稍作修改,就可以在64位Windows上正常使用。最重要的是,Visual Studio无法在面向amd64的项目中使用内联汇编。对于sidt/sgdt来说,我们可以通过Visual Studio定义Compiler Intrinsic来解决这个问题。我们可以通过下列代码来读取GDTR。
因为从Ring 3执行sidt/sgdt指令是amd64指令集的特性,而非操作系统特性,所以在Windows 8中仍然可以读取这些值:
Windows 8.1:
Windows 10:
与进程所在的完整性级别或用户具有的权限无关。
Hyper-V
根据Dave Weston和Matt Miller的Black Hat关于Windows 10的漏洞利用缓解进展方面的演讲来看,如果在系统上启用Hyper-V,并执行sidt或sgdt指令的话,管理程序将捕获它们并拦截返回值。
但是,这一点我还没有亲自验证过。
Win32k.sys Object Handle Addresses Windows 7 32 bit
Win32k是一个重要的驱动程序,提供将图形输出到Windows上的显示器、打印机等的相关功能。它维护会话(会话由表示单个用户的登录会话的所有进程和其他系统对象组成。)和存储所有GDI(图形设备接口)和用户句柄的句柄表。
为了降低访问此表的性能开销,通常将其映射到用户空间中的所有GUI进程。 用户空间中该表的地址可通过u导出为gSharedInfo。
这允许从用户模式寻找内核内存空间中所有GDI和用户对象的地址。 首先,我们需要定义这个表在内存中的结构,下面的结构取自ReactOS。
Windows 8 64 bits
为了将代码移植到64位系统,我们需要对代码稍作修改。 首先将SERVERINFO结构扩展为64位,方法是对dwSRVIFlags和cHandleEntries字段的大小进行相应的调整。
Windows 10?
根据Dave Weston和Matt Miller在黑帽大会上的演讲,已经无法通过GDI共享句柄表获得内核地址。
但是当这个二进制代码在64位Windows 10 周年版虚拟机中运行时,我找到了一些像内核指针的东西:
通过考察这些地址,发现它们与内核空间中的预期会话空间地址范围相吻合,也就是都位于正确的取值范围内——至少对于64位的Windows 7来说的确如此。
接下来,我加载了一个64位Windows 8机器,连接内核调试器并转储了句柄表,并将其与我在调试器中看到的值进行了相应的比较。下面的几个匹配值已经高亮显示,我们期望的值都能从用户模式代码中找到。
然后,我在64位的Windows 10上面进行了同样的试验。
我发现句柄表的结构和指向的值,在不同的操作系统版本之间非常一致。我现在没有更多的时间来深入研究这些,所以这里先打一个问号,留待以后继续探索。