导语:在之前的文章中​,我们讨论了一种将任意代码注入PPL-Windows TCB进程的技术。由于一些原因,我们之前讨论的技术不适用于具有较强保护的受保护进程。本篇文章主要为了解决这一问题。

概述

之前的文章中,我们讨论了一种将任意代码注入PPL-Windows TCB进程的技术,该技术结合了我此前发现并向Microsoft报告的许多漏洞。由于一些原因,我们之前讨论的技术不适用于具有较强保护的受保护进程(Protected Processes,PP)。本篇文章主要为了解决这一问题,并提供详细信息,说明如何在不具备管理员权限的情况下劫持完整的PP-Windows TCB流程。本文侧重于技术探讨,我们尝试是否能在一个完整的PP中执行代码,因为在PP中通过PPL可以做的事情并不多。

首先,我们对上一次攻击实验进行简单回顾,目前我们能够确定一个以PPL运行的进程,并且该进程暴露了一个COM服务。具体来说,该服务是“.NET运行时优化服务”(.NET Runtime Optimization Service),该服务包含在.NET框架中,并在CodeGen级别使用PPL将缓存的签名级别应用于AOT编译的DLL,从而允许它们用于用户模式代码完整性(UMCI)。通过修改COM代理配置,可能导致类型混淆的发生,从而允许我们劫持已知DLL配置,来加载任意DLL。一旦在PPL中成功运行代码,我就可以滥用缓存签名功能中的漏洞,来创建一个签名,并加载到任何PPL中的DLL,从而升级到PPL-Windows TCB级别。

寻找新目标

我首先考虑对完整的PP进行漏洞利用,并借助我们在PPL-Windows TCB上运行代码时获得的额外访问权限。大家可能认为,可以滥用缓存的已签名DLL来绕过安全检查,从而加载到完整的PP中。但不幸的是,内核的代码完整性模块忽略了完整PP的缓存签名级别。那么,用已知DLL呢?如果我们在PPL-Windows TCB中以管理员权限运行代码,那么我们可以直接写入已知DLL对象目录,并尝试让PP加载任意DLL。然而,正如我在上一篇博客中提到的,这个方法也不起作用,因为完整的PP忽略了已知DLL。即使确实加载了已知的DLL,我们的目标也不是通过获得管理员权限来将代码注入进程。

因此,我决定重新研究之前编写的PowerShell脚本,以发现哪些可执行文件将作为完整的PP在什么级别运行。在Windows 10 1803上,有大量可执行文件以PP-Authenticode级别运行,但只有4个可执行文件以更高权限级别启动,如下表所示。

C:\windows\system32\GenValObj.exe(运行级别Windows)
C:\windows\system32\sppsvc.exe(运行级别Windows)
C:\windows\system32\WerFaultSecure.exe(运行级别Windows TCB)
C:\windows\system32\SgrmBroker.exe(运行级别Windows TCB)

由于我们目前还没有从PP-Windows级别提升到PP-Windows TCB级别的方法,无法像之前对PPL进行的操作那样,因此在这4个可执行文件中,只有WerFaultSecure.exe和SgrmBroker.exe这两个文件是我们潜在的目标。我将这两个可执行文件与已知的COM服务注册相关联,并尝试寻找这些可执行文件是否暴露了COM的攻击面。回想我上次利用的.NET可执行文件并没有注册其COM服务,因此我进行了一些基本的逆向工程,来寻找COM的使用。

我们发现,SgrmBroker可执行文件似乎没有什么用,这是一个独立的用户模式应用程序的封装,作为Windows Defender System Guard的一部分,用于实现系统的运行时环境证明(Runtime Attestation),并且不需要调用任何COM API。WerFaultSecure似乎也不会调用COM,但它可以加载COM对象。因为我了解到,Alex Ionescu使用我原来的COM脚本小程序(Scriptlet)代码执行攻击,通过劫持WerFaultSecure中的COM对象加载过程,成功获取了PPL-Windows TCB级别。尽管WerFaultSecure没有公开的服务,但如果它可以初始化COM,那么我是否也可以滥用它来运行任意代码呢?要掌握COM的攻击面,首先我们需要了解COM是如何实现进程外服务器(Out-of-process COM Servers)和远程处理(COM Remoting)的。

深入研究COM Remoting内部

COM客户端和COM服务器之间的通信,是通过MSRPC协议进行的,该协议基于Open Group的DCE/RPC协议。对于本地通信,是通过高级本地过程调用端口(ALPC)进行传输。对于更高级别的通信,客户端和服务器之间的通信过程如下图所示:

1.png

为了使客户端能够找到服务器的位置,该进程在RPCSS中使用DCOM激活器(DCOM Activator)来注册ALPC终端。该终端与服务器的对象导出ID(Object Exporter ID,OXID)共同注册,后者是由RPCSS分配的64位随机生成编号。当客户端想要连接服务器时,必须首先要求RPCSS将服务器的OXID解析为RPC终端。在知道ALPC RPC终端的情况下,客户端可以连接到服务器,并调用COM对象上的方法。

OXID值可以在进程外(OOP)COM激活结果中找到,也可以在编组后的对象引用(OBJREF)结构中找到。客户端在RPCSS的IObjectExporter RPC接口上调用ResolveOxid方法。ResolveOxid的原型如下:

interface IObjectExporter {
  // ...
  error_status_t ResolveOxid(
    [in] handle_t hRpc,
    [in] OXID* pOxid,
    [in] unsigned short cRequestedProtseqs,
    [in] unsigned short arRequestedProtseqs[],
    [out, ref] DUALSTRINGARRAY** ppdsaOxidBindings,
    [out, ref] IPID* pipidRemUnknown,
    [out, ref] DWORD* pAuthnHint
);

在原型中,我们可以看到要解析的OXID会在pOxid参数中传递,服务器返回一个Dual String Bindings数组,表示要连接到此OXID值的RPC终端。此外,服务器还返回另外两条信息,一个是我们可以安全忽略的身份验证级别提示(pAuthnHint),另一个是不应该忽略的IRemUnknown接口的IPID(pipidRemUnknown)。

IPID是一个名为接口进程ID的GUID值。它表示服务器内部COM接口的唯一标识符,并且需要与正确的COM对象进行通信,因为它允许单个RPC终端通过一个连接复用多个接口。IRemUnknown接口是每个COM服务器必须实现的默认COM接口,该接口用于查询现有对象上的新IPID(使用RemQueryInterface),并维护远程对象的引用计数(使用RemAddRef和RemRelease方法)。无论是否导出实际的COM服务器,是否可以通过解析服务器的OXID来发现IPID,这个接口都始终存在。因此,我想知道这个接口还支持其他哪些方法,看看是否有哪些地方可以用于获得代码执行。

COM运行时代码负责维护一个包含所有IPID的数据库,它会在收到调用一个方法的请求后查找服务器对象。如果我们知道这个数据库的结构,那么就能够发现IRemUnknown接口的实现位置,也就能解析它的方法,并找出该接口支持的其他功能。幸运的是,我使用OleViewDotNet工具,特别是PowerShell模块中的Get-ComProcess命令,完成了对数据库格式的逆向工程。如果我们对使用COM的进程运行该命令,但实际上没有实现COM服务器(例如记事本notepad),就可以尝试识别出正确的IPID。

2.PNG

在上图中,我们看到实际上有两个IPID导出,分别是IRundown和一个Windows.Foundation接口。我们忽略Windows.Foundation,重点研究IRundown。事实上,如果我们对任何COM进程执行相同的检查,都会发现它们也导出了IRundown接口。这样一来,IRundown就看起来非常“诱人”了。如果我们将ResolveMethodNames和ParseStubMethods参数传递给Get-ComProcess,该命令将尝试解析接口的方法参数,并根据公共符号查找名称。通过解析的接口数据,我们可以将IPID对象传递给Format-ComProxy命令,获得IRundown接口的基本文本描述。在经过整理后,IRundown接口如下所示:

[uuid("00000134-0000-0000-c000-000000000046")]
interface IRundown : IUnknown {
   HRESULT RemQueryInterface(...);
   HRESULT RemAddRef(...);
   HRESULT RemRelease(...);
   HRESULT RemQueryInterface2(...);
   HRESULT RemChangeRef(...);
   HRESULT DoCallback([in] struct XAptCallback* pCallbackData);
   HRESULT DoNonreentrantCallback([in] struct XAptCallback* pCallbackData);
   HRESULT AcknowledgeMarshalingSets(...);
   HRESULT GetInterfaceNameFromIPID(...);
   HRESULT RundownOid(...);
}

这个接口是IRemUnknown的超集,它不仅实现了RemQueryInterface等方法,还添加了一些额外的方法。真正让我感兴趣的,是其中的DoCallback和DoNonreentrantCallback方法,从名称上来看它们似乎会执行某种类型的“回调”。也许我们可以对这些方法进行滥用?我们使用了一些逆向工程的方法,对DoCallback进行了分析,具体如下:

struct XAptCallback {
 void* pfnCallback;
 void* pParam;
 void* pServerCtx;
 void* pUnk;
 void* iid;
 int   iMethod;
 GUID  guidProcessSecret;
};
 
HRESULT CRemoteUnknown::DoCallback(XAptCallback *pCallbackData) {
 CProcessSecret::GetProcessSecret(&pguidProcessSecret);
 if (!memcmp(&pguidProcessSecret,
             &pCallbackData->guidProcessSecret, sizeof(GUID))) {
   if (pCallbackData->pServerCtx == GetCurrentContext()) {
     return pCallbackData->pfnCallback(pCallbackData->pParam);
   } else {
     return SwitchForCallback(
                  pCallbackData->pServerCtx,
                  pCallbackData->pfnCallback,
                  pCallbackData->pParam);
   }
 }
  return E_INVALIDARG;
}

这个方法非常有趣,其中包含一个指向要调用的方法的指针结构和一个任意参数。如果想要调用任意方法,唯一的限制就是我们必须提前知道随机生成的GUID值、进程凭据(Secret)和服务器上下文地址。检查每个进程的随机值,是COM API中的常见安全模式,通常用于将功能限制在进程中的调用方。

那么,DoCallback的作用是什么?COM运行时会为每个初始化的COM创建一个新的IRundown端口。这对于不同部分之间调用方法来说非常重要,比如从MTA调用STA对象,就需要从正确的部分中调用相应的IRemUnknown方法。因此,开发人员在其中添加了一些方法,这些方法对于不同部分之间的调用是非常有效的,包括“任意调用”方法。它由COM运行时在内部使用,并通过CoCreateObjectInContext等方法间接公开。为防止DoCallback方法被滥用,应该检查每个进程的凭据,并限制只有进程内部可以进行调用。

滥用DoCallback

现在,我们有一个原语可以在任何进程中执行任意代码,该进程通过调用DoCallback方法来初始化COM,并且该方法应该具有PP权限。为了成功调用任意代码,我们需要知道以下4个信息:

1、COM进程正在侦听的ALPC端口;

2、IRundown接口的IPID;

3、初始化进程的凭据(Secret)值;

4、有效的上下文地址,理想情况下应该与GetCurrentContext在同一RPC线程上返回的值相同。

如果进程公开了COM服务器,那么获取ALPC端口和IPID就非常容易,因为二者都将在OXID解析期间提供。不幸的是,WerFaultSecure没有公开我们创建的COM对象,因此这是一个需要解决的问题。要提取进程凭据和上下文值,就需要读取进程内存的内容。那么另一个问题就来了,PP的一个安全特性就是阻止非PP进程从PP进程中读取内存。我们接下来要解决这两个问题。

即使拥有管理员权限,也不允许直接从PP进程读取内存。我们理论上可以加载一个驱动程序,但这样做会完全打破PP,因此需要考虑如何在不需要内核代码执行的情况下完成任务。

首先,也是最简单的,我们可以从RPCSS中提取ALPC端口和IPID。RPCSS服务不会受到保护(甚至是PPL),所以只需知道该值存储在内存中的位置。对于上下文指针,我们应该能够强制执行该位置,如果选择32位版本的WerFaultSecure,会稍微容易一些。

提取凭据的过程则有一些困难。凭据会在可写内存中被初始化,因此一旦被修改,就会在进程的工作集中结束。由于页面(Page)没有锁定,所以只要内存条件正确,就能够进行分页。因此,如果我们可以强制将包含凭据的页面分页到磁盘上,那么即使是来自PP进程,我们也能够读取。作为管理员,我们可以执行以下操作来窃取凭据:

1、确保凭据已经初始化,同时页面已经被修改;

2、强制进程修改其工作集,确保包含凭据的修改后页面最终被分页到磁盘上;

3、使用NtSystemDebugControl系统调用创建内核内存崩溃转储文件。崩溃转储可以由管理员创建,并且不启用内核调试,其中将包含内核中的所有实时内存。这一过程不会使系统崩溃。

4、解析包含凭据的页表条目(Page Table Entry,PTE)故障转储,PTE应该能够暴露分页数据在磁盘上的页面文件中的位置;

5、打开包含页面文件的卷,并进行读取访问,解析其中的NTFS结构,查找页面文件,并查找分页数据提取凭据。

针对我们要执行的攻击,这一过程似乎太过复杂,所以我们想尝试另一种解决方案。

利用WerFaultSecure的原始用途

到目前为止,我一直在说WerFaultSecure是一个可以用来在PP/PPL中运行任意代码的进程。但是,我没有透彻地说明为什么这个进程最高可以在PP/PPL权限运行。Windows错误报告服务(Windows Error Reporting)使用WerFaultSecure从受保护进程创建故障转储。所以,为了确保它能够转储任何可能的用户模式PP,它就需要在更高的PP级别权限运行。这么说来,我们可以让WerFaultSecure创建自身的崩溃转储,并泄漏进程内存中的内容,从而允许我们提取需要的任意信息。

我们之所以无法使用WerFaultSecure,是因为它在将崩溃转储写入磁盘之前,就先对其进行加密。这种加密方式只能由Microsoft来解密,使用了非对称加密来保护提供给Microsoft WER Web服务的随机会话密钥。除了寻找这一实现过程中的漏洞,以及对新加密方式所使用的原语进行攻击之外,看起来似乎没有一个更好的方法。

但是,2014年,Alex在NoSuchCon上发表了关于PPL的研究成果,并讨论了他在研究WerFaultSecure如何创建加密转储文件时发现的漏洞。该过程包含两个步骤,首先导出未加密的故障转储,然后加密崩溃转储。在这个过程中,有可能窃取到未经加密的崩溃转储。根据其调用WerFaultSecure的方式,它接受了两个文件句柄,一个用于未加密的转储,另一个用于加密转储。通过直接调用WerFaultSecure,可以保证未加密的转储永远不会被删除,这也就意味着我们甚至不需要进行加密过程的竞态。

在这里存在一个漏洞,该漏洞于2015年被修复(MS15-006)。在修复后,WerFaultSecure直接对故障转储进行加密,并且永远不会在未加密的磁盘上结束。由此我们开始思考,是否可以从Windows 8.1上获取存在漏洞版本的WerFaultSecure,并在Windows 10上执行。我从Microsoft网站上下载了Windows 8.1的ISO文件,并提取了二进制文件,并对其进行测试,结果如下:

3.png

结果证明,从Windows 8.1中获得的存在漏洞WerFaultSecure版本,在Windows 10上能成功以PP-Windows TCB级别运行。其原因我们还不清楚,但考虑PP的安全加固方式,所有的权限都基于可执行文件的签名来判断。由于可执行文件的签名仍然有效,因此操作系统会信任该文件,从而使其在请求的保护级别中运行。我们认为,Windows中有一些方法来阻止特定的可执行文件,但他们恐怕并不能撤销自己的签名证书。考虑到Microsoft已经在Windows 8升级到8.1之后,为了阻止绕过WinRT UMCI签名的降级攻击,添加了一个新的EKU。我们认为,在证书中,也应该保存了一个操作系统二进制文件的EKU,用于表明操作系统的版本。

在参考Alex的演示文稿,并进行了逆向分析之后,我能够列举出为了执行PP转储,需要传递给WerFaultSecure进程的参数:

· /h  启用安全转储模式

· /pid {pid}  指定要转储的进程ID

· /tid {tid}  指定要转储的线程ID

· /file {handle}  为未加密的故障转储指定可写文件的句柄

· /encfile {handle}  为加密的故障转储指定可写文件的句柄

· /cancel {handle}  为应该取消的转储指定事件的句柄

· /type {flags}  指定MIMDUMPTYPE标志以调用MiniDumpWriteDump

这样一来,我们就拥有了要完成漏洞利用所需要的一切。我们不需要管理员权限,就可以将旧版本的WerFaultSecure作为PP-Windows TCB启动。我们可以使用初始化的COM转储另一个WerFaultSecure副本,并使用故障转储来提取我们需要的所有信息,包括通信所需的ALPC端口和IPID。我们不需要自行编写崩溃转储解析器,因为可以使用Windows附带的Debug Engine API。一旦我们提取了所需的所有信息,就可以调用DoCallback,并调用任意代码。

组合实现代码注入

要完成漏洞利用,接下来还有两个问题需要解决——如何让WerFaultSecure启动COM?我们调用什么可以在PP-Windows TCB进程中运行任意代码?

我们首先来解决第一个问题,如何启动COM。正如我之前所提到的,WerFaultSecure没有直接调用任何COM方法。经过与Alex的讨论,我们发现诀窍是让WerFaultSecure转储AppContainer进程,这会导致对FaultRep DLL中的方法CCrashReport :: ExemptFromPlmHandling的调用,从而加载CLSID {07FC2B94-5285-417E-8AC3-C2CE5240B0FA},将被解析为未记录的COM对象。重要的是,这将允许WerFaultSecure初始化COM。

但不幸的是,在设置COM远程处理(COM Remoting)时,并没有如预想的那样。如果仅仅加载COM对象,并不能足以初始化IRundown接口或RPC终端。这是有道理的,如果所有COM调用都是在同一个部分进行编码,那么为什么还要为COM初始化整个远程处理代码呢?在这种情况下,即使我们可以使得WerFaultSecure加载COM对象,它也不符合设置远程处理的条件。针对这种情况,有一种方法就是将COM注册从进程内(In-process)类更改为OOP类。如下图所示,首先从HKEY_CURRENT_USER查询COM注册,这意味着我们可以在不需要管理员权限的情况下对它进行劫持。

4.PNG

不幸的是,查看代码并不起作用,下面是精简后的代码:

HRESULT CCrashReport::ExemptFromPlmHandling(DWORD dwProcessId) {
 CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
 IOSTaskCompletion* inf;
 HRESULT hr = CoCreateInstance(CLSID_OSTaskCompletion,
     NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&inf));
 if (SUCCEEDED(hr)) {
   // Open process and disable PLM handling.
 }
}

这段代码将标志CLSCTX_INPROC_SERVER传递给CoCreateInstance。该标志将COM运行时的查找代码范围限制为仅查找进程内类注册。即使我们用一个OOP类替换注册,COM运行时也会忽略它。幸运的是,还有另一种方法,代码是使用带有CoInitializeEx的COINIT_APARTMENTTHREADED标志将当前线程的COM部分初始化为STA。查看COM对象的注册,其线程模型设置为“Both”。在实际中,也就意味着对象支持直接从STA或MTA调用。

但是,如果将线程模型设置为“Free”,则该对象仅支持来自MTA的直接调用,这意味着COM运行时必须启用远程处理,在MTA中创建对象(使用类似于DoCallback的方法),然后从原始部分编组到特定对象的调用。COM启动远程处理后,会初始化所有远程功能,包括IRundown。由于我们可以劫持服务器注册,现在我们就只需要更改线程模型,这将导致WerFaultSecure启动我们可以利用的COM远程处理。

接下来,我们解决第二个问题。我们可以在进程中调用什么,来执行任意代码?我们使用DoCallback调用的任何内容,都必须满足以下条件,从而避免未定义的行为:

1、只需要一个指针大小的参数;

2、如果需要,只返回调用的较低32位作为HRESULT;

3、由于CFG机制的保护,它必须是有效的间接调用目标。

由于WerFaultSecure并没有特殊的权限,因此任何DLL导出函数都至少应该是一个有效的间接调用目标。LoadLibrary明显符合我们的标准,因为它需要一个参数,是一个指向DLL路径的指针,并且我们并不关心返回值。我们不能加载任意DLL,这个DLL一定要具有正确的签名,那么我们如何来劫持已知DLL呢?

前面我提到过,PP无法从已知DLL加载,因为LdrpKnownDllDirectoryHandle全局变量的值在进程初始化期间始终设置为NULL。当DLL加载程序检查是否存在已知DLL时,如果句柄为NULL,就会立即返回。但是,如果句柄非空,就会执行常规检查,就像在PPL中一样。如果进程映射来自现有节对象的映像,那么就不会执行其他安全检查。因此,如果我们可以更改LdrpKnownDllDirectoryHandle全局变量,使其指向集成到PP的目录对象,就可以使其加载任意DLL。

最后一个难题,是找到一个导出的函数,我们可以调用它来将任意值写入全局变量。事实证明,这比想象的要更难一些。理想的函数,是使用单个指针值作为参数,并写入该位置的函数。经过一些试错后,我决定使用USER32中的SetProcessDefaultLayout和GetProcessDefaultLayout。Set函数使用单个值作为其函数(实际上是在内核中,但这已经足够了)。然后,get方法将该值写入任意指针位置。这并不完美,因为我们可以设置并写入的值仅限于数字0-7。但是,通过在get调用中偏移指针,我们可以写入0x0?0?0?0?的形式,其中问号代表0-7之间的数值。对于这个值,只需要引用我们控制的进程的句柄,所以我们可以轻松的制作满足要求的句柄。

总结

总而言之,在不具有管理员权限的情况下,如果希望进程在PP-Windows TCB内部执行任意代码,我们可以执行以下操作:

1、创建一个虚假的已知DLL目录,复制句柄,直到其满足适合通过Get/SetProcessDefaultLayout写入的模式。将句柄标记为可继承。

2、在ThreadingModel设置为“Free”的情况下,为CLSID {07FC2B94-5285-417E-8AC3-C2CE5240B0FA}创建COM对象劫持。

3、在PP-Windows TCB级别,启动Windows 10 WerFaultSecure,并从AppContainer进程请求崩溃转储。在创建进程期间,必须添加虚假已知DLL,以确保它能继承到新的进程。

4、等待COM初始化,使用Windows 8.1 WerFaultSecure转储目标的进程内存。

5、解析崩溃转储,以发现IRundown的进程密钥、上下文指针和IPID。

6、连接到IRundown接口,并使用DoCallback和Get/SetProcessDefaultLayout将LdrpKnownDllDirectoryHandle全局变量修改为第1步中创建的句柄值。

7、再次调用DoCallback,来调用LoadLibrary,并从虚假的已知DLL中加载一个名称。

上述操作过程适用于所有Windows 10版本,包括1809。值得注意的是,调用DoCallback可以用于任何能够读取内存内容并且进程已经初始化COM远程处理的进程。例如,如果在特权COM服务中存在任意内存泄漏漏洞,就可以利用这一攻击方式将任意内存读取转换为任意内存执行。

至此,我的一系列Windows受保护进程代码注入的分享就结束了。我认为,防止用户攻击共享资源(例如注册表和文件)的进程注定都会失败。这可能也正是Microsoft不支持PP/PPL作为安全边界的原因。隔离的用户模式似乎是一个更加强大的原语,但它也伴随了额外的资源需求,PP/PPL并不是最主要的部分。我们预计,Windows 10的后续更新版本(1809版本之后)可能会尝试通过某种方式缓解这些攻击,但我们应该还是可以找到绕过的方法。

源链接

Hacking more

...