如何借助COM对Windows受保护进程进行代码注入(第二部分) - 嘶吼 RoarTalk – 网络安全行业综合服务平台,4hou.com

如何借助COM对Windows受保护进程进行代码注入(第二部分)

41yf1sh 系统安全 2018-12-05 10:00:31
268537
收藏

导语:在之前的文章中​,我们讨论了一种将任意代码注入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版本之后)可能会尝试通过某种方式缓解这些攻击,但我们应该还是可以找到绕过的方法。

  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论

 
本站4hou.com,所使用的字体和图片文字等素材部分来源于原作者或互联网共享平台。如使用任何字体和图片文字有侵犯其版权所有方的,嘶吼将配合联系原作者核实,并做出删除处理。
©2022 北京嘶吼文化传媒有限公司 京ICP备16063439号-1 本站由 提供云计算服务