对于我来说,寻找新型的横向渗透方法或有趣的代码执行技术是一种消磨时光的好方法。由于Windows在启动时会生成大量的RPC服务,所以,在寻找与众不同的代码执行技术的时候,难度会直线下降。通常来说,这些活动的投入产出比还是非常客观的,因为SOC或EDR供应商往往专注于更常见的已公之于众的技术,而能够在主机上触发代码执行的新方法的问世可能会给调查团队的工作带来麻烦。
在以前的文章中,我试图找出能够用于混合常见攻击签名的不同方法。自从撰写探索Mimikatz和lsass内部机制的文章以来,我收到了许多关于如何找到所展示的lsass DLL加载技术以及如何识别其他技术方面的信息的请求。所以,在这篇文章中,我将为读者介绍一个工作流程,帮助大家查看Windows RPC方法内部;同时,还会介绍一些非常有用的方法,将寻找感兴趣的向量的工作量降至最低。
需要说的是,这篇文章的目标读者是那些对Windows底层功能感兴趣,并且不满足于已经公布的0dayzz或横向渗透技术的人。而本文的重点就是提供一些想法,希望能帮助这些读者找到相关的新技术。
因此,我们首先要做的是明确要识别的对象。就横向渗透技术而言,我们的理想目标应该这样一种服务——可以通过RPC进行远程交互,或者可以通过LPC暴露本地的服务,用于将代码导入处于运行状态的进程中。
就目前来说,在搜索RPC服务时,我们可以借助于RpcView,这是为数不多的工具之一,旨在以足够的粒度来公开RPC服务,甚至生成可编译的IDL。虽然这个工具非常棒,但我的关注点在于它是如何完成RPC枚举的。了解这一点之后,我们就可以为自己的逆向工具定制某些特殊的功能。因此,在本文的第一部分中,这就是我们将要探索的内容……以及如何完成RPC枚举。
一旦我们掌握了从进程中提取RPC信息的方法,接下来要做的事情,就是了解公开的RPC方法是否会触发我们感兴趣的API调用,例如CreateProcess或LoadLibrary。这是我们将在本文的第二部分在要集中讨论的主题。
自动枚举RPC
对于RPC枚举,我最初是借助于Rpcrt4.dll(这是Windows提供的库,用于支持RPC运行时)以及RpcView的源代码,来了解Windows RPC的内部运行机制的。
现在,我们要更进一步:了解RPC服务器的相关信息。好在微软为我们提供了一个关于如何创建RPC服务器的优秀文档,所以,这里将以它为基础,并进一步介绍如何暴露出单一方法。我已将相关项目添加到Github,以方便大家参考。
如果我们考察该示例RPC项目的主函数,会发现有许多API是用于启动RPC服务器的,包括:
RpcServerUseProtseqEp:用于配置端点以接受RPC连接。
RpcServerRegisterIf:用于向RPC运行时注册RPC接口。
RpcServerListen:启动RPC服务器的侦听功能。
注意,所有这些API调用都是从Rpcrt4.dll导出的,所以,让我们将其交给Ghidra,看看这些函数是如何运作的。
如果我们从RpcServerUseProtseqEp开始,我们会它会检查RPC运行时是否已加载到一个进程中:
这里会验证全局变量RpcHasBeenInitialized,如果它被设置为false,那么执行流程将进入PerformRpcInitialization。最终,这会触发InitializeRpcServer的一个函数,而该函数则会初始化GlobalRpcServer的全局变量:
那么,GlobalRpcServer是干啥的?如果仔细考察RpcView的代码,我们会在RpcInternals.h中找到答案:
typedef struct _RPC_SERVER_T{ MUTEX_T Mutex; ULONG __bIslistening; ULONG bIsListening; ULONG MinimumCallThreads; ULONG Wait; ULONG OutCalls; ULONG Unk1; ULONG InCalls; ULONG Unk2; SIMPLE_DICT_T AddressDict; ULONG lAvailableCalls; ULONG Unk3; SIMPLE_DICT_T _ProtSeqQueue; ULONG Unk4[8]; ULONG OutPackets; ULONG Unk5; MUTEX_T Mutex2; ULONG MaxCalls; ULONG Unk6; VOID PTR_T hEvent; ULONG Unk7[4]; SIMPLE_DICT_T InterfaceDict; ULONG _bIsListening; ULONG bIsMaxCalls1234; ULONG Unk8[6]; ULONG InPackets; ULONG Unk9; RPC_FORWARD_FUNCTION PTR_T pRpcForwardFunction; ULONG Unk10[6]; SIMPLE_DICT_T AuthenInfoDict; LIST_ENTRY_T RpcIfGroupListEntry; ULONG PTR_T __SRWLock; LIST_ENTRY_T field_1E0; }RPC_SERVER_T, PTR_T PRPC_SERVER_T;
如上所示,这个对象提供了许多对我们的RPC逆向分析非常有用的字段。其中,我们要特别关注一下InterfaceDict,它是SIMPLE_DICT_T的一个实例。再次回顾RpcView的代码,我们发现这个结构的格式为:
typedef struct _SIMPLE_DICT_T{ VOID PTR_T PTR_T pArray; UINT ArraySizeInBytes; UINT NumberOfEntries; VOID PTR_T SmallArray[SIMPLE_DICT_SMALL_ARRAY]; }SIMPLE_DICT_T, PTR_T PSIMPLE_DICT_T;
其中,pArray字段是一个指向RPC_INTERFACE_T的指针;RPC_INTERFACE_T具有以下布局:
typedef struct _RPC_INTERFACE_T { PRPC_SERVER_T pRpcServer; ULONG Flags; ULONG Unk1; MUTEX_T Mutex; ULONG EpMapperFlags; ULONG Unk2; RPC_MGR_EPV PTR_T pMgrEpv; RPC_IF_CALLBACK_FN PTR_T IfCallbackFn; RPC_SERVER_INTERFACE_T RpcServerInterface; PMIDL_SYNTAX_INFO pSyntaxInfo; VOID PTR_T pTransfertSyntaxes; ULONG TransfertSyntaxesCount; ULONG __Field_C4; ULONG NbTypeManager; ULONG MaxRpcSize; UUID_VECTOR PTR_T pUuidVector; SIMPLE_DICT_T RpcInterfaceManagerDict; UCHAR Annotation[MAX_RPC_INTERFACE_ANNOTATION]; ULONG IsCallSizeLimitReached; ULONG currentNullManagerCalls; ULONG __Field_150; ULONG __Field_154; ULONG __Field_158; ULONG SecurityCallbackInProgress; ULONG SecurityCacheEntry; ULONG field_164; VOID PTR_T __SecurityCacheEntries[16]; SIMPLE_DICT_T FwEpDict; ULONG Unk3[6]; struct RPCP_INTERFACE_GROUP PTR_T pRpcpInterfaceGroup; }RPC_INTERFACE_T, PTR_T PRPC_INTERFACE_T;
这里,我们又找到一个RpcServerInterface字段。实际上,该字段是由我们在POC中创建的MIDL来进行填充的(具体参见useless_s.c):
static const RPC_SERVER_INTERFACE useless___RpcServerInterface = { sizeof(RPC_SERVER_INTERFACE), {{0xaaf3c26e,0x2970,0x42db,{0x91,0x89,0xf2,0xbc,0x0e,0x07,0x3e,0x7c}},{1,0}}, {{0x8A885D04,0x1CEB,0x11C9,{0x9F,0xE8,0x08,0x00,0x2B,0x10,0x48,0x60}},{2,0}}, (RPC_DISPATCH_TABLE*)&useless_v1_0_DispatchTable, 0, 0, 0, &useless_ServerInfo, 0x04000000 };
根据RpcView对该结构的定义,我们发现其中含有如下所示的布局:
typedef struct _RPC_SERVER_INTERFACE_T{ UINT Length; RPC_IF_ID InterfaceId; RPC_IF_ID TransferSyntax; PRPC_DISPATCH_TABLE_T DispatchTable; UINT RpcProtseqEndpointCount; PRPC_PROTSEQ_ENDPOINT_T RpcProtseqEndpoint; RPC_MGR_EPV PTR_T DefaultManagerEpv; void const PTR_T InterpreterInfo; UINT Flags ; } RPC_SERVER_INTERFACE_T, PTR_T PRPC_SERVER_INTERFACE_T;
同样,这里也有许多字段,同时,我们对MIDL生成的代码很感兴趣,例如在我们的MIDL编译中生成的DispatchTable:
static const RPC_DISPATCH_TABLE useless_v1_0_DispatchTable = { 2, (RPC_DISPATCH_FUNCTION*)useless_table };
这里的值2实际上是暴露的方法的数量,这一点我们可以从RPC_DISPATCH_TABLE结构的定义中看出来:
typedef struct _RPC_DISPATCH_TABLE_T{ UINT DispatchTableCount; RPC_DISPATCH_FUNCTION PTR_T DispatchTable; ULONG_PTR_T Reserved; } RPC_DISPATCH_TABLE_T, PTR_T PRPC_DISPATCH_TABLE_T;
与之前的RpcServerInterface字段相关的另一个字段是InterpreterInfo,该字段在我们的示例项目中填充了以下值:
static const MIDL_SERVER_INFO useless_ServerInfo = { &useless_StubDesc, useless_ServerRoutineTable, useless__MIDL_ProcFormatString.Format, useless_FormatStringOffsetTable, 0, 0, 0, 0};
该结构的定义如下所示:
typedef struct _MIDL_SERVER_INFO_T { PMIDL_STUB_DESC_T pStubDesc; const VOID PTR_T PTR_T DispatchTable; const unsigned char PTR_T ProcString; const unsigned short PTR_T FmtStringOffset; const VOID PTR_T PTR_T ThunkTable; RPC_IF_ID PTR_T pTransferSyntax; ULONG_PTR_T nCount; VOID PTR_T pSyntaxInfo; } MIDL_SERVER_INFO_T, PTR_T PMIDL_SERVER_INFO_T;
这个结构在其DispatchTable字段中包含一个函数指针数组,可以用来检索通过RPC接口公开的所有方法,其定义如下所示:
static const SERVER_ROUTINE useless_ServerRoutineTable[] = { (SERVER_ROUTINE)UselessProc, (SERVER_ROUTINE)Shutdown };
对于上述内存结构,我们必须仔细加以考察(我们可以借助于RpcView来识别它们的内存布局,并时刻保持内存处于最新状态)。为此,我们需要在内存中先找到GlobalRpcServer,因为它是其他结构的根基之所在。
借助于Ghidra,我们可以看到这个全局变量位于内存的.data部分:
为了在内存中找到该指针,可以利用RpcView遍历.data部分,每次考察8个字节(在x64 arch的情况下),并且检查结构中的潜在字段,就好像它是指向RPC_SERVER的正确指针一样。如果所有字段都找到了,RpcView就认为它有一个指向RPC_SERVER的有效指针;否则,直接转至接下来的8个字节。
我们将在自己的工具中使用相同的策略来定位相同的指针:
在目标进程内存中定位Rpcrt4.dll模块。
定位DLL的.data节。
每次读取8个字节,并解除作为潜在的RPC_SERVER指针的引用。
检查InterfaceDict等字段以确保这些字段与期望值相互匹配,例如NumberOfEntries是否为一个合理的值。
如果完整性检查失败,继续处理后面的8个字节。
完整的代码可以从这里找到。
如果一切顺利,我们就会找到相应的RPC_SERVER实例,为此,我们只需遍历所需的struct字段以识别每个公开的方法。
按理说,我们可以在此告一段落了,但有时候为潜在的方法地址设置RPC方法名称也是非常有用的。要在捆绑的Microsoft二进制文件中公开方法名称,我们可以从Microsoft的符号服务器中获取PDB。为此,我们可以使用Dbghelp库,这样,我们就能够以编程方式下载每个PDB,并在运行时解析每个方法名称。具体代码,可以从这里下载。
那么,如果我们将所有零部件拼凑到一起的话,将会发生什么情况呢? 我们自然希望能够它能够报告从当前运行的所有进程中导出的所有RPC方法:
现在,一切看起来都运行正常,但如果我们将已识别的RPC方法和地址导出到JSON文件中的话,那么这会在进一步自动化过程中发挥更大的作用。为此,我们可以借助于niohmann的JSON C ++库来轻松实现这一点。
读者可以在此处找到RpcEnum的完整项目源代码。目前,这些代码适用于Windows 10 1903系统上的x64二进制文件,希望对大家构建自己的工具能够有所帮助。
利用Ghidra进行Headless分析
由于现在已经能够枚举和提取进程中的RPC方法名称和地址,我们接下来需要一种方法来分析每个方法,以帮助识别后续调用。现在手动执行该操作需要花费相当多的时间,但幸运的是,Ghidra已经公开了一个headless分析器,它运行使用扩展脚本,所以无需打开UI或与UI进行交互。
现在,我们已经能够预先知道将使用哪个DLL和EXE,因此,接下来需要对其进行逐个分析,并将它们添加到Ghidra的项目中,以便以后编写脚本。为此,我们可以使用下列命令,并将每个模块作为参数进行传递:
.\support\analyzeHeadless.bat C:\Ghidra Windows.gpr -import C:\windows\system32\target.dll
一旦完成上述命令(当然,这肯定需要一段时间),我们将得到一个Ghidra项目,其中含有与待分析的EXE和DLL相关RPC方法:
创建好包含我们感兴趣的模块的项目后,我们接下来需要找到一种有效的方法来探索所有这些数据。我发现一种特别有用的方法,即为每个公开的RPC方法创建调用图,以帮助理解所做的外部Win32 API调用。为此,可以借助一个简单的Python脚本来解析我们以前生成的JSON文件,并在使用每个模块的自定义Jython post-script来调用headless型Ghidra实例。读者可以从这里下载这个python脚本。
我们的Ghidra Jython post-script将利用Ghidra公开的API来递归分析RPC方法,并为每个方法映射一个调用图。读者可以在此处找到执行该操作的简单示例。一旦执行,我们就可以创建一个由内部函数和Win32函数组成的CSV文件,这些函数将被各个公开的RPC方法所调用:
上面的CSV文件包含了我们搜索潜在有用的代码路径所需的所有信息,接下来让我们更进一步——尝试创建一个调用图,以便以交互方式进行探索。
Neo4j,不仅可用于Bloodhound
我猜本文的大多数读者都是因为Bloodhound而接触Neo4j的。众所周知,Bloodhound为使Infosec社区了解图论的有用性做了很多工作,但是这种技术的用途远不止在AD网络上搜索DA这么有限。
在开始探索Neo4j的强大功能之前,我们首先需要知道如何将数据转换为所需的图外观。实际上,Neo4J允许用户直接从CSV文件导入数据。为此,我们首先需要使用以下方式创建节点:
USING PERIODIC COMMIT LOAD CSV WITH HEADERS FROM "file:/log-int.csv" AS csv FIELDTERMINATOR ' ' MERGE (f:IntFunction {name:csv.name, module:csv.module }); USING PERIODIC COMMIT LOAD CSV WITH HEADERS FROM "file:/log-ext.csv" AS csv FIELDTERMINATOR ' ' MERGE (f:ExtFunction {name:csv.name, module:csv.module }); USING PERIODIC COMMIT LOAD CSV WITH HEADERS FROM "file:/log-rpc.csv" AS csv FIELDTERMINATOR ' ' MERGE (f:RPCFunction {name:csv.name, module:csv.module });
接下来,我们需要在每个节点之间创建相应的关系(或边)。其中,这里最明显的关系就是“调用”关系,我们将在其中标记后续函数是如何调用每个函数的。为此,我们可以使用如下调用:
USING PERIODIC COMMIT LOAD CSV WITH HEADERS FROM "file:/log-int.csv" AS csv FIELDTERMINATOR ' ' MATCH (f {name:csv.calledby, module:csv.calledbymodule }) MATCH (f2 {name:csv.name, module:csv.module}) MERGE (f)-[:Calls]->(f2) USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM "file:/log-ext.csv" AS csv FIELDTERMINATOR ' ' MATCH (f {name:csv.calledby, module:csv.calledbymodule }) MATCH (f2 {name:csv.name, module:csv.module}) MERGE (f)-[:Calls]->(f2) USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM "file:/log-rpc.csv" AS csv FIELDTERMINATOR ' ' MATCH (f {name:csv.calledby, module:csv.calledbymodule }) MATCH (f2 {name:csv.name, module:csv.module}) MERGE (f)-[:Calls]->(f2)
加载后,我们应该发现自己现在可以使用调用图,并能够在数据库中进行搜索,如下面的NetrJobEnum RPC方法所示:
借助于一个包含我们可以交互式探索的关系的数据库,识别可能感兴趣的代码路径会变得更加容易。例如,如果我们想要找出最终将调用Win32 API(如LoadLibraryExW)且少于3跳的所有RPC方法,我们可以使用Cypher查询:
MATCH p=(:RPCFunction)-[:Calls*3]->(:ExtFunction {name: "LoadLibraryExW"}) RETURN p
如何进行相应的修改,使其可以识别不仅调用LoadLibraryExW,还调用注册表API(如RegQueryValueExW)的代码路径呢?具体如下所示:
上面的例子不仅表明当Neo4j与正确的数据集结合时到底有多么强大,同时,也展示了为获得感兴趣的代码路径而拼凑一个工具链是多么简单。
小结
对于某些读者来说,可能已经发现了可以借助于Neo4j浏览节点的各种领域。
第一个领域是Control Flow Guard,虽然在Ghidra的9.0.4中处理得很好,但它仍然将调用图显示为一个节点,并在尝试递归标识调用的函数时中断代码路径。目前来看,这似乎是从VTABLE调用的虚拟方法所致,因为Ghidra目前还无法很好地分析这些方法,因此,导致出现熟悉的__guard_dispatch_icall函数。
第二个领域是初始加载速度。虽然使用Neo4J探索调用能够提高分析阶段的速度(至少对我来说是这样),但目前需要考虑将数据加载到数据库所需的时间。我对Neo4J的了解并不多,因此可能有一些方法可以提高将数据加载到数据库以及查找关系的性能。
这些领域将是我下一步的研究重点,因为我继续探索RPC和LPC的潜在攻击面,但如果你有任何改进方法的建议,请随时与我联系。