之前MSRC微软放出了CVE-2022-26809
这个漏洞,当时的评分有9.8那么高,我十分好奇这么高评分的漏洞到底是个怎么样子的洞,所以对其进行了简单的分析,不过一通分析过后,在个人有限的水平下,分析出来的结果是感觉这个漏洞好像挺理论洞的。。
为了能够更好的描述这个漏洞,首先要了解Windows下的RPC调用模式,所以这里可能要分成两个部分来讲漏洞。前面可能更加倾向于介绍RPC本身,摆出一堆术语,之后才能比较好的介绍漏洞本身。
RPC 基础知识
RPC为远程过程调用,分为Server端和Client端。其调用模式如图
由于RPC代码在编写过程中存在很多基本模式以及很多需要遵守的规则,所以一般开发平台提供一个叫做MIDL( Microsoft Interface Definition Language )
的定义语言来生成Server和Client的对应接口,其后缀为.idl
。当定义语言写好之后,使用midl.exe
即可生成对应的桩(Stub)
文件。桩(Stub)
不做真正的工作,在RPC中它负责将调用的数据重新组织好,并且将数据传输到指定的远程主机侧完成系统调用。
在这里有很多微软提供的参考代码,可以通过这边学习一下整个RPC的调用过程。文章后面的内容也会从里面选取其中的Hello
项目进行介绍。
实例:MIDL,与Stub的关系
假设interface文件如下
1 | [ uuid (f691b703-f681-47dc-afcd-034b2faab911), // You must change this when you change the interface |
我们通过输入
1 | midl -oldnames -cpp_opt "-E" hello.idl |
可以生成如下的文件
1 | hello.h // 通用接口文件 |
可以简单看一下内容
hello.h
1 |
|
基本上是定义了一些基本的变量和对应的接口函数类型。其中
1 | extern RPC_IF_HANDLE hello_ClientIfHandle; |
为RPC调用中会使用到的接口句柄,其本质为RpcInterfaceInformation
,也就是RPC接口信息句柄,在之后注册RPC调用的时候会用到。
client侧
Client侧生成的文件信息如下(节选)
1 | static const RPC_CLIENT_INTERFACE hello___RpcClientInterface = |
可以看到,hello_ClientIfHandle
在这边被hello___RpcClientInterface
赋值。这个变量中记录的了一些在定义过程中能够知道的值:当前接口的接口IDInterface ID以及当前NDR
(在RPC调用过程中用于描述传输单位的数据)使用的传输语法 TransferSyntax。传输语法可以定义当前NDR
使用的语法。在MIDL中可以使用/protocol
对其进行指定。结构体后方的变量会在运行时逐渐填充。
1 | extern const MIDL_STUB_DESC hello_StubDesc; |
xxx_StubDesc
变量中的xxxx
为当前IDL文件中定义的接口的名字。这个变量存放了针对每个Stub
的一些基本定义的。包括用于分配对象和释放对象的MIDL_user_allocate
和MIDL_user_free
。这两个程序需要在主程序中声明,用于对对象进行内存管理。通过暴露这个接口,也方便后期进行数据的追踪。同时将前文的hello___RpcClientInterface
绑定在Stub
中,表明hello_StubDesc
描述的是hello___RpcClientInterface
接口句柄指向的Stub
。hello_IfHandle
则为前文提到的,用于表示当前Stub
的原始句柄。在通常情况下与hello___RpcClientInterface
是等价的。但是当在接口中指明使用当权句柄的时候,一般会使用_IfHandle
进行接口绑定。
1 |
|
hello__MIDL_ProcFormatString
被称为格式化字符串(类似与printf用的的那个字符串),使用特定的数值来描述当前调用函数中接口的各种属性。包括当前接口类型(用flag表示)参数数量等。如果存在参数的画,则会在描述完接口之后跟着描述对应的参数类型,会描述参数的大小,种类等等。
hello__MIDL_TypeFormatString
用于描述当前使用的一些函数的参数种类等
hello_FormatStringOffsetTable
则用于描述hello__MIDL_ProcFormatString
中每个接口的起始地址。
完成这些定义之后,最终就能声明接口函数
1 |
|
可以看到Client端的HelloProc
和Shutdown
函数定义本质上只是调用了一个叫做NdrClientCall2
的API,这个API由RPCRT4.dll
提供,根据生成的hello_StubDesc
,hello__MIDL_ProcFormatString.Format[0]
以及hello__MIDL_ProcFormatString.Format[36]
进行函数调用接口和参数的一些定义。之后个根据这种特殊的格式化字符串形式,根据需要传入参数。
Server侧
server侧大部分关于接口的定义等同Client侧,但是接口的实现需要由自己完成,同时会多出如下的几个变量:
1 |
|
首先,server侧的hello___RpcServerInterface
定义了DispatchTable
。这个变量会在之后提到的PDU
中由procnum
指定的操作数指定调对应分发函数。然后对应的NdrServerCall2
则会去寻找hello___RpcServerInterface
中注册的hello_ServerInfo
指定的hello_ServerRoutineTable
,最终形成一种对应关系,找到需要调用的相关函数。在NdrServerCall2
调用过程中,中途会根据之前注册的接口信息,再合适的时候进行内存管理(之后漏洞会详细分析部分),从而保证传入Server的API中的变量为我们需要的形式。
同时,Server侧需要实现接口:
1 | void HelloProc(IN RPC_BINDING_HANDLE hBinding,unsigned char * pszString) |
这边就没什么特别的了,就按照正常的API编写即可。
关于RPC的注册机制
[TODO]
特殊类型:Pipe
RPC接口中,支持很多常见的数据类型,例如int, long,char
等等,同时也支持类似结构体的格式。详情可以看官方文档,介绍了所有可以用的类型。
这里我们要额外介绍一种特殊的数据类型:Pipe
。Pipe
这种数据类型能够实现如下的能力
The pipe type constructor is a highly efficient mechanism for passing large amounts of data, or any quantity of data that is not all available in memory at one time. By using a pipe, RPC run time handles the actual data transfer, eliminating the overhead associated with repeated remote procedure calls.
概括来讲就是:Pipe中的数据可以想在管道中流通一下,无数次的从某个特定的API中读入or输出。当发生读入or输出的动作的时候,传输的数据无需马上准备好,程序可以根据需要同步or异步的进行数据的输入。
这种类型在MIDL中的声明如下:
1 |
|
首先我们需要使用typedef pipe long
将pipe类型指定为一个我们新的变量类型上,表明当前管道中的pipe中,传输的元素全部都是long类型的变量。变量前的[in]
表示被调用者(Server)将会用这个接口,从调用者(Client)**拖拽(Pull)数据。而[out](后跟指针类型为自行需要)
,表示被调用者将会用这个接口往调用者处推送(Push)**数据。如果[in,out]
都用,则表示这个接口中的数据可能极有可能发生pull
也可能发生push
。
头文件中的新增特征
观察生成头文件:
1 |
|
删除部分无用变量
可以看到,生成的Server Stub文件中,多出来一些针对Pipe的特殊声明。我们能够使用这里的特征,对所有使用了RPC调用的binary进行搜索,检查其中是否包含pipe
类型。其中这里可以稍微关注一下pipe的属性
[TODO:考虑删掉,改成逆向结果]
1 |
|
Pipe会在InitPipeStateWithType
函数中被初始化。其中flag
最高位会表示当前大小AnotherSize和targetSize
是两个字节还是四个字节。默认情况下的targetSize
会在NdrReadPipeElements
被抬高为64
字节(这也就是GetCoalescedBuffer
在后期攻击的时候,为什么不会被重复调用的理由)。但是当设置flag
最高位为1的场合,这个值最大可以设置为0x7fffff00
。不过此时AnotherSize
也需要保持一样的大小。
Server侧代码编写
1 |
|
再编写Server侧代码的时候,首先注意,当前传入的LONG_PIPE
对象无需我们初始化,因为实际上pipe
对象具体要怎么做是交给用户态来定义的。
InPipe
接口中,我们调用了pull
接口
1 | while (actual_transfer_count > 0) /* Loop to get all |
state
用于描述当前管道中的状态值,这里我们用它代表了下标(client侧体现)local_pipe_buf
用于存放当前用于存放收入数据的缓冲区count
表示pipe单次接受的pipe
中元素大小,这里也就是能接受count个long类型actual_transfer_count
表示实际接受了多少个元素
当我们发现接收到的数据大小为0的时候,此时停止循环,完成pipe数据读取。
OutPipe
接口也类似
1 | while (elementsToSend > 0) /* Loop to send pipe data elements */ |
只不过这次我们会按照一定的比率进行数据的输送。
Client侧
Client侧的逻辑稍微复杂,且需要和Server侧颠倒
1 | void PipeAlloc(rpc_ss_pipe_state_t stateInfo, |
首先可以看到,这里首先定义了三个和pipe相关的函数。之后这三个函数会分别被赋值给传入Client
侧的InPipe
和OutPipe
中的LONG_PIPE
对象中。这里先解释其作用
PipeAlloc
会在每次pipe_pull
和pipe_push
被调用的时候,给allocatedBuffer
申请变量,而且必须在allocatedSize
给出反馈。注意这个allocatedBuffer
只需要在用户态可用即可,未规定一定要是malloc
出来的内容。例子中就简单的使用全局变量进行了分配PipePull
中传入的inputBuffer
为PipeAlloc
中申请的Buffer,然后我们往Buffer中写入我们需要传入的内容。这里的stateInfo
其实是一个整数变量,用于维护Pipe的状态,我们在这里用于表示当前Pipe
的下标,记录传输的状态。完成传输之后,需要将要发送的数据数量存入sizeToSend
PipePush
中buffer
同样为PipeAlloc
申请来的数据大小。这边而是表示此时有numberOfElements
个对象需要接受,此时只需要将数据传入对应state
表示的下标即可
完成上述准备,此时可以编写用于发送数据和接受数据的函数
1 | void SendLongs() |
可以看到,这两个函数调用过程中,都有一个针对当前的LONG_PIPE
对象赋值的过程。此过程其实是为了在调用InPipe
和OutPipe
中,此时的pipe类型将会怎么使用。这个函数在自动生成的Stub
文件中作为普通的参数传入,不过其在RPC调用过程中存在特殊的处理过程(和之后漏洞相关)。此时根据Server的实现情况,其会反向调用来自用户态的函数,也就是最后会反过来找用户态的push
和pull
函数。在用户态完成调用之后,其会调用NdrClientCall2
重新使用socket协议将数据发送出去,从而实现RPC。
总结
本文主要介绍了RPC的一些基础知识(主要也是MSDN的翻译),下一篇文章中将会详细介绍漏洞的相关详情
参考链接
https://docs.microsoft.com/en-us/windows/win32/rpc/rpc-start-page
https://github.com/microsoft/Windows-classic-samples/tree/main/Samples/Win7Samples/netds