RPC是一个非常宽泛的概念,其核心理念是让一个函数可以在不同进程或者不同计算机之间被调用。微软在windows中实现了RPC功能,并且windows中有大量的功能调用其实是通过RPC来调用的,比如:服务的加载,关机消息的发送,包括DCOM也是基于RPC的二次封装.关于详细的说明推荐阅读微软官网: https://learn.microsoft.com/en-us/windows/win32/Rpc/rpc-start-page。由于RPC的规范非常复杂,且相关内容很少,所以我也只是根据文档和调式尽可能地将我的理解贴上来,如果有误欢迎指正。
有些EDR会通过Hook RPC调用过程中的一些关键点来实现RPC调用的过滤.因此我们的目标就是直接通过Syscall指令直接调用NT系列函数来完成RPC调用,从而绕过EDR的Hook点。
所有的源码已经放在了文章的最后面供读者下载。
源码中MyRPCServer是一个最简单的RPC服务端,它首先注册自己为一个RPC服务,协议类型是"ncalrpc",这表示它底层使用"LPC&ALPC 本地过程调用"来完成通信,本文讨论的所有RPC都是"ncalrpc"协议类型的RPC。Endpoint名称是"MyFirstRPC"。UUID是"88888888-6808-11cf-b73b-666666666666"
MyRPCServer向外只导出一个函数,并打印出4个传入的参数内容.MyRPCClient是一个最简单的RPC客户端,它调用MyRPCServer向外导出的HelloProc函数,并传入4个参数。MyRPCClient代码片段如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
下面是我们的Server端向外导出的函数的定义,注意,第一个参数是IDL_handle,它只作为一个上下文状态句柄,并不会真正的传输到服务端:
1 2 3 4 5 6 |
|
好现在正式开始,虽然MyRPCClient是个32位的EXE,但是我们依然使用64位的windbg调试,因为这样可以断在真正的64位NT函数上。在MyRPCClient入口处下批量断点,这样下断点的好处是Alpc相关的函数全部被下断点,不需要一个一个函数去下断,方便我们分析整个调用流程:
1 2 3 4 5 6 |
|
F5,程序断在了NtAlpcConnectPortEx上:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
该函数的第二个参数是目标Alpc Port的OBJECT_ATTRIBUTES,我们查看ConnectionPortObjectAttributes的ObjectName的值:
1 2 3 4 5 6 7 8 9 |
|
可以看到ALPC的端口名称是"\RPC Control\MyFirstRPC",假如我们的RPC服务明确指定了端口名称,那么系统会为我们在"RPC Control"下创建一个同名的ALPC端口,打开winobj同样可以看到这个ALPC端口:
再次F5,程序断在了NtAlpcQueryInformation上,这个函数是用来查询ALPC属性的,不关键,继续F5。
1 2 3 4 |
|
接着函数断在了NtAlpcSendWaitReceivePort函数上,
1 2 3 4 5 6 7 8 9 10 |
|
这个函数的第三个参数是关键点,表示要发送的ALPC消息,我们直接查看数据:
1 2 3 4 5 6 7 8 9 |
|
所有的ALPC消息前0x28个字节的数据是PORT_MESSAGE结构作为头部,紧跟着后面的是实际负载,也就是我们的PRC PDU(Protocol Data Units),根据消息头的描述,PDU有0x48的长度。RPC PDU是由PDU Header(RPC请求头,必须有),PDU body(经过打包的要传输的实际参数数据,非必须),身份验证数据(非必须),这三坨数据构成。
PDU Header的第一个字节表示请求的类型,1表示这次是Bind请求,RPC在函数功能在调用前都需要首先调用Bind请求。第0x0C开始是我的的RPC的UUID。具体Bind请求的每个字段的含义我们其实不用详细了解,因为对于同样的RPC请求每次的Bind请求内容都是固定的,我们只要第一次获取到Bind请求体保存下来即可。
这个函数的第五个参数(0x00f2afd0)是服务端针对本次请求返回的数据。
1 2 3 4 5 |
|
返回的内容决定了我们的Bind请求是否执行成功,同样是个PORT_MESSAGE结构的ALPC消息,我们单步运行到NtAlpcSendWaitReceivePort函数执行结束然后查看其内容:
1 2 3 4 5 6 7 8 9 10 11 |
|
怎么判断Bind请求是否成功呢?如上,返回的Request Header第一个字节必须为0x01(Bind),Request Header从第0x0C开始是RPC的UUID.这样可以判定为Bind成功.
这里关于Request Header我需要额外补充一点,即使是同样的Bind请求,不同的底层协议它的Request Header的结构也可能是不一样的。同样是MyRPCServer这份代码,我只是将协议从"ncalrpc"改成了了"ncacn_ip_tcp",Bind请求的请求头部结构就发生了变化,下图为我通过Wireshark抓包获取的MyRPCServer代码的"ncacn_ip_tcp"协议版本的RPC Bind请求:
前0x36为ETH&TCPIP协议头,之后为RPC PDU Header,可以清晰的看到其结构为rpcconn_bind_hdr_t结构(这个结构体是开源且规定好的)。而且在这里我们可以看到0x0B代表了Bind请求。为什么同样是Bind请求,"ncalrpc"就是0x01,"ncacn_ip_tcp"就是0x0B呢?这个问题我会放在文章末尾讨论,这不是整个流程的重点。
继续F5,再次断在了NtAlpcSendWaitReceivePort函数上,这次是真正的Request请求,我们查看请求的具体内容:
前0x28个字节是PORT_MESSAGE结构,根据PORT_MESSAGE的描述,消息体RPC PDU长度为0xA0,PDU Header第一个字节为0x00,表示是Request请求,PDU Header 长度为0x40,后面紧跟着的是PDU Body,长度为0x60。PDU Body里存放的是函数参数经过打包之后的数据。
我接下来一个参数一个参数的来讲解PDU Body里面的内容以及对应的参数:
(1)红框内是第一个参数(PDU Body偏移0x00):"pszString"是一个窄字符串数组,这是个不固定长度的数组,所以它的编码方式应该是如下图,第一个4字节表示数组的最大长度为0x0A,第二个4字节0x00表示从多少偏移开始为真正有效的数组内容,第三个4字节表示数组实际长度为0x0A,之后紧跟着的是0x0A个单位的实际数组内容,最后一个0x00是字符串结尾。注意,这里所谓的长度并不是字节长度,而是有多少个单位元素长度。
(2)绿框内是第二个参数(PDU Body偏移0x18):"wcsString"是一个宽字符串数组,这是个不固定长度的数组,所以它的编码方式应该和"pszString"一样,第一个4字节表示数组的最大长度为0x09,第二个4字节0x00表示从多少偏移开始为真正有效的数组内容,第三个4字节表示数组实际长度为0x09,之后紧跟着的是0x09个单位的实际数组内容,最后一个0x0000是字符串结尾。
(3)蓝框内是第三个参数(PDU Body偏移0x38):"nInt32"是一个四字节的int类型。我们可以看到"nInt32"参数的数据并不是紧跟着"wcsString"的数据尾部的,而是存放在了"wcsString"尾部两个字节之后。这是因为PUD Body的参数编码是需要进行数据对齐的,对齐方式是根据数据内容的大小进行1,2,4,8最大8字节的对齐。"nInt32"长度为4个字节,所以需要4字节对齐。所以需要在空了两个字节之后的位置存放。
(4)金框内是第四个参数(PDU Body偏移0x40):"nInt64"是一个八字节的int64类型,它因为需要8字节对齐,所以需要空4个字节之后的位置存放。
至此,所有参数都已经编码完成,这样我们就可以通过连续调用两次NtAlpcSendWaitReceivePort函数来自己构造RPC bind和RPC Request完成PRC的调用。这里要说一点,由于ALPC系列函数微软只作为内部使用,所以其参数并未公开,调用NtAlpcSendWaitReceivePort函数的参数和属性的填写我是通过windbg调试得到的,由于这并不是本文的重点,所以这里不具体展开讲解。具体请参考AlpcDoMyRPCClient工程源码。
我们已经对RPC有了基本认识,开始我们的最终目的:1.确定某个我们感兴趣的系统功能是否为RPC方式调用。2.分析定位出这个系统功能是怎么调用的,从而我们可以通过代码实现该RPC功能的调用。现在以Explorer "以管理员身份运行" 某个EXE这个功能为例子开始分析:
我的具体思路:
1.首先要定位该功能所在的进程,这里我们随便用一个窗口查看工具就可以定位到右键弹出菜单是Explorer进程的窗口.
2.对所有的RPC关键函数下特殊的条件断点(NdrClientCall3,NdrClientCall2,NdrAsyncClientCall,Ndr64AsyncClientCall,NdrDcomAsyncClientCall,Ndr64DcomAsyncClientCall),只要当我们点击 "以管理员身份运行" 按钮时该断点立刻断下,并且我们F5后立刻弹出UAC窗口,则我们就可以大概率定位断下来的的。
3.我们通过代码模仿我们定位到的系统RPC调用,如果同样能弹出UAC窗口,则反向印证我们分析定位的没有问题.
开始实战:
通过工具可定位到该右键菜单为Explorer进程,我们直接通过windbg附加到Explorer进程,并对RPC函数下断。这时候重点来了:我们会发现Explorer进程并不是像我们想象的那样,只有在点击 "以管理员身份运行" 按钮时才会触发我们下的RPC断点,Explorer进程无时无刻不在频繁的进行着RPC调用,我们的断点会被频繁的断下。我的思路就是尽量想办法排除干扰项,在我没有点击 "以管理员身份运行" 按钮时产生的RPC请求一定都是干扰项。我们只关注点击后产生的RPC请求。因此我们需要一个条件断点。
在设置条件断点之前我们需要先了解一下RPC函数:
1 2 3 4 5 |
|
1 2 3 4 5 6 |
|
所有的RPC函数都可以归类成上边这两种类型,这两种类型函数的第一个参数都是用来描述远端服务器信息的,其中就包含了RPC服务的GUID标识符。第二个参数是表示要调用远端RPC服务器的哪个函数,"pFormat"用于详细解释函数的参数的各种属性,"nProcNum"则表示是RPC服务器导出的第几个函。两种函数他们的参数的目的都一样,只不过参数的类型有所不同。
所以我们需要一个条件断点,这个条件断点有两个功能:1.根据第一个参数里的GUID和第二个参数来决定哪些是"干扰项",从而跳过"干扰项".2.如果断下来,则打印出:1.RPC服务的GUID.2.pFormat或nProcNum.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
我编写好的可以直接使用的已经排除了大部分的"干扰项"的脚本文件MyRPCBreakPoint.txt已经放在了附件里以供直接使用。具体windbg断点语法不是本文重点,可以通过微软官网了解,会有详细的说明。上边是断点脚本文件的Demo演示:
第2行和第3行申请了两个伪寄存器t0 t1,用于存放第一个和第二个参数。
第4行".if(@rdx > 0x1000)"用来判断RPC函数类型,如果小于等于1000则说明第二个参数是函数序号,如果大于1000则说明是函数格式。
第6行和第21行申请了伪寄存器t2,从t0中取出GUID指针,并将GUID指针放入t2中,由于两种函数的参数不一样,一个是PMIDL_STUB_DESC 类型,一个是MIDL_STUBLESS_PROXY_INFO,所以取GUID指针的方式也不一样。
第7行和第28行是重点,如果满足条件则说明是"干扰项"则会 "gc" 也就是继续运行,否则会打印出:1.RPC服务的GUID。2."pFormat函数格式" 或者是 "nProcNum函数序号"。
第42行开始是清理除了(NdrClientCall3,NdrClientCall2,NdrAsyncClientCall,Ndr64AsyncClientCall,NdrDcomAsyncClientCall,Ndr64DcomAsyncClientCall)这几个RPC函数之外的函数断点,由于我们下的是批量断点,所以也会误下一部分命中我们名字规则的断点。请注意,这里每个人的机器肯定是不一样的,所以读者需要根据自身下断点情况来自己清理断点。
接下来我实际演示一遍如何将"干扰项"放入 ".if" 判断中,从而排除干扰项:
首先Windbg命令行输入"$$>a<C:\MyRPCBreakPoint.txt",运行脚本文件,之后在没点击"以管理员身份运行" 时断点也会不停的断下来,每断下来一个,我们就把这个"干扰项"的两个参数保存放入 ".if" 判断中.
如上图,输入脚本文件路径,然后F5跑起来,windbg立刻会断下。脚本输出"Format"表示函数是两种类型的第一种。0xe60c73e6是RPC服务GUID的前4个字节,为了简单我的脚本文件并没有完整判断GUID的16个字节,只判断了前4个字节。0x4000000006800是函数PFORMAT_STRING的前8个字节,为了简单也是只取了前8个字节。将0xe60c73e6和0x4000000006800放入第7行的".if"判断中,即可排除此"干扰项"。
将"干扰项"加入后继续F5:
脚本输出"nNumber"表示函数是两种类型的第二种,0xb18fbab6是RPC服务GUID的前4个字节,0x0A是函数序号。将上面的0xb18fbab6和0x0A放入第28行的.if判断中,即可排除此"干扰项"。
按照上边的方法即可慢慢排除所有干扰项,直到在点击"以管理员身份运行"按钮前不会有任何RPC函数断下.这样的方法缺点就是比较耗时,但是优点就是方法比较通用,大多数RPC功能都可使用此方法定位,而且可以用来确定一个功能是否是通过RPC调用的。
接下来我们点击右键弹出菜单的"以管理员身份运行",Windbg立刻断下:
根据我们的脚本输出我们可以看到GUID为201ef99a开头:{201ef99a-7fa0-444c-9399-19ba84f12a1a},函数序号为0的的RPC请求断下。查看堆栈信息,我们可以看到是模块名称为windows_storage.dll的AicLaunchAdminProcess函数调用过来的。再随便翻看一下函数参数,我们发现函数的第6个参数刚好就是我们"以管理员身份运行"的EXE的路径,再看函数名称也很像我们的目标函数。那么这个RPC调用被我们列为重点怀疑对象。
先不急去验证,我们继续F5,直到弹出UAC窗口才算分析结束,windbg再次断下:
这次的GUID为{e1af8308-5d1f-11c9-91a4-08002b14a0fa},函数序号为0x08,我们查看函数堆栈,发现是上面的AicLaunchAdminProcess函数调用Ndr64AsyncClientcall函数后其内部又再次调用的RPC请求,这可能会让人有些迷惑,其实这是RPC协议中规定好的一个基础RPC服务,名字叫"Endpoint Mapper",它的作用就是保存好已经注册的其他的RPC服务的端口号。因为RPC服务在注册时是可以不填写端口号的,这样系统会为该RPC服务生成随机端口号并将其保存在"Endpoint Mapper"中。该RPC服务不是我们本次讨论的重点,这个留给读者作为第一个练手用的RPC服务。
继续F5,UAC窗口成功弹出,这期间一共调用了两次RPC请求,已知第二个RPC请求是"Endpoint Mapper"请求,第一个RPC根据函数名字和参数都有很大的可能性是"以管理员身份运行"按钮对应的RPC请求。
接下来我们需要使用在Github上的一个叫做"RpcView"的开源工具,下载地址是https://github.com/silverf0x/rpcview。它可以将目标RPC服务的所有接口导出到IDL文件中,具体使用方法如图:
管理员方式运行RpcView.exe,然后CTRL+F搜索GUID是0x201ef99a开头的RPC服务。右键Decompile,此RPC服务的接口就会以文本的方式被导出。接下来我们根据函数声明和windbg调试,就可以依葫芦画瓢的填写好我们的参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
其中wcsExePath是要运行的EXE路径,wcsParam是运行参数,ProcInfo是PROCESS_INFORMATION结构,用于返回进程创建信息。具体调用方式请参考附件中的源码。我们运行测试代码,成功以管理员权限运行了notepad.exe程序并传递参数,反向印证了我们的猜测:第一个RPC请求就是"以管理员身份运行"按钮对应的RPC请求。
Good!我们的工作已经完成了一大半,接下来我们要通过NTAlpc*函数实现我们的RPC请求:
为了方便起见,就不再调试Explorer来查看NTAlpc函数参数了,我们直接调试只有一个RPC请求以管理员方式运行Notepad.exe的RPCRunAdminProcess工程。像第二章节一样老样子,windbg打开RPCRunAdminProcess工程,"bm ntdll!alpc*" 对alpc相关函数批量下断点,然后F5:
1 2 3 4 5 6 7 8 9 |
|
windbg断下,查看端口号,是"Endpoint Mapper"服务的端口号,上边已经讲过,对于随机端口的RPC服务,要先通过RPC请求"Endpoint Mapper"服务来获取目标RPC服务的端口号。这不是本文的重点,留给读者用来练手。
我们一路F5,跳过和"Endpoint Mapper"相关的Alpc操作,直到再次断在AlpcConnect函数:
其端口号是"LRPC-50ddf8142f3561885f" 刚好和我们上面通过RpcView.exe查询到的RPC服务GUID对应的端口号一致。
F5,函数断在了NtAlpcQueryInformation上,不重要,继续F5,断在函数NtAlpcSendWaitReceivePort上,这次是Bind操作,我们第二章已经分析过,跳过,再次F5,再次断在函数NtAlpcSendWaitReceivePort上,这次是Request请求:
第二章节已经讲过Request请求里面的的参数是如何编码的了,这里我就不一一讲解了,我只说第二章没有遇到的参数类型。如上图,wcsExePath和wcsParam两个字符串数组前边还有红色框标注的4个字节的数据。我们查看IDL文件对该函数的声明:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
这两个字符串数组前增加了[unique]标记,该标记表示该参数是"unique pointers",这东西没有明确的中文翻译,本文我就称其为唯一指针。它的作用是表示该指针指向的对象仅有这一个指针指向它。它的好处是可以降低系统对参数编码时的处理量。红框中的数据是唯一指针的"Referent Identifier"引用标识.引用标识由两个WORD构成,第一个WORD是0x4=0,1x4=4,2x4=8...nx4=4n。n表示是函数的第几个指针。第二个WORD固定为0x0002。
蓝色框内的Struct22是个结构体,它的编码方式其实很简单,就是内部各个元素的集合,总长度为所有元素加起来的长度,一共占用0x30个字节。
单步运行到NtAlpcSendWaitReceivePort函数返回处,系统会弹出UAC窗口,我们点击"是",可以看到Notepad进程被创建:
如图,命令行是我们输入的命令行,PID是7744(0x1E40),主线程TID是23268(0x5AE4)。这时候我们再查看NtAlpcSendWaitReceivePort函数返回的数据的内容:
我们可以看到0x28偏移开始是PDU header的数据,第一个字节0x03表示的是这是RESPONSE 请求返回数据。从第0x40开始是PDU body的数据,蓝框标识的是需要返回的第一个参数ProcInfo,我们可以看到ProcInfo里的PID和TID也刚好和我们通过工具查看的Notepad的PID和TID对应上了。红色框是需要返回的第二个参数arg_12,这是一个long类型参数。
还没结束,Notepad被创建的时候被挂起了,通过IDA查看windows.storage.dll的AicLaunchAdminProcess函数是怎么处理的:
PROCESS_INFORMATION 输出到了this+0x178
然后从0x180处取得主线程的Handle,调用ResumeThread恢复主线程的运行。
关于通用性:以运行管理员进程这个RPC功能为例,我测试了win7和win10最新版本(笔者写文章时最新),都可以成功调用,winxp系统不存在管理员运行这个功能,也没有Alpc相关函数,所以肯定不可以。 我又看了几个RPC,在GUID和Version都相同的情况下,向外导出的接口仍然可能一样也可能会不一样(微软还真是任性),这个需要读者自己去具体RPC具体分析了.
比如"saloyun"同学提出的DNS解析RPC,看下面的截图,虽然GUID都是{45776b01-5956-4485-9f80-f428f7d60129},Version都是(2.0),但是函数定义完全不一样:
关于附件的源码:
RpcDemo解决方案:
MyRPCServer工程(x86编译)是一个最简单的RPC服务程序,用于入门学习。
MyRPCClient工程(x86编译)是一个最简单的RPC客户端程序,向MyRPCServer发送HelloProc()请求。
RPC_IDL工程(x86编译)仅仅用来将IDL文件编译成STUB文件。
RPCRunAdminProcess工程(x86编译)是通过RPC弹出UAC窗口创建管理员进程。
AlpcDemo解决方案:
AlpcDoMyRPCClient工程(x64编译)是通过Alpc的方式模拟RPC请求向MyRPCServer发送HelloProc()请求。
AlpcDoRunAdminProcess工程(x64编译)是通过Alpc的方式模拟RPC请求弹出UAC窗口创建管理员进程。这里要注意,工程中" #define EXECUAC_ALPC_NAME L"\RPC Control\LRPC-50ddf8142f3561885f "定义的Alpc端口号读者一定要自己通过RpcView获取(获取方式文章中有详细讲解)或者通过Endpoint Mapper这个RPC服务自己动态获取,这个端口号每次开机都不一样,是系统随机生成的。
附件密码: pediy
看到有人不理解绕过了EDR的什么,我这里额外补充一下:正如第一章中所说,系统中有大量的调用其底层实现是通过RPC实现的,DCOM,服务的加载,关机消息的发送,等等等。以我们已经分析过的"以管理员身份运行"这个功能为例,EDR可以HOOK NdrXXClientCallXX系列函数,解析出第一个参数内的GUID是201ef99a-7fa0-444c-9399-19ba84f12a1a,第二个参数是0或者是对应的函数Format,就可以判断该进程正调用"以管理员身份运行"这个功能,并从之后的参数中获得进程的路径和参数。所以所有这些底层依赖RPC的系统功能理论上都能被EDR过滤到。
最后于 21小时前 被走起来编辑 ,原因: