进程间通信 (IPC,Inter-Process Communication)
进程间通信 (IPC) 是一种在进程之间建立连接的机制,在两台计算机或一台多任务计算机上运行,以允许数据在这些进程之间流动。进程间通信 (IPC) 机制通常用于客户端/服务器环境,并在不同程度上受到不同 Microsoft Windows 操作系统的支持。进程间通信技术包括消息传递、同步、共享内存和远程过程调用。
用于进程间通信的众多模型中的两个
Windows 平台支持的 IPC 机制
-
命名管道
-
邮槽
-
网络BIOS
-
Windows 套接字
-
远程过程调用 (RPC)
-
本地过程调用 (LPC/ALPC)
-
网络动态数据交换 (NetDDE)
-
分布式组件对象模型 (DCOM)
本节课将涵盖以下主题:
-
命名管道
-
LPC
-
ALPC
-
RPC
命名管道(Named Pipe)
介绍
命名管道起源于 OS/2 时代,是一种进程间通信机制,可在两台计算机上的进程之间提供可靠的、面向连接的双向通信。命名管道是 Microsoft Windows 操作系统和应用程序中客户端/服务器通信的一种形式。
虽然管道这个名字听起来有点奇怪好像很复杂的样子,但管道却是一种非常基本且简单的技术,可以在两个进程之间实现通信和共享数据,其中术语管道描述了这两个进程使用的共享内存段。
有两种类型的管道:
-
命名管道
-
匿名管道
大多数时候,在引用管道时,可能指的是命名管道,因为命名管道提供了较为完整的功能。管道通信可以在同一系统上的两个进程之间进行(使用命名管道和匿名管道),其中匿名管道主要用于父子进程通信,但也可以跨机器边界进行(只有命名管道可以跨机器边界进行通信),由于命名管道更有价值,本节课将仅关注命名管道。
命名管道消息传递
让我们来分析一下命名管道的内部结构。通过管道一词我们可以把这种通信技术想象成一根空心的管子,如果我们对着一端灌水,那么另一端就会流出来,如果我们对着一端说话,那么另一端的人就会听到所说的话。没错,这就是管道所做的一切,它将信息从一端传输到另一端,勤勤恳恳,任劳任怨......
如果你是Linux用户,那么你肯定不经意间已经使用过管道了。例如:cat file.txt | wc -l
,把file.txt的内容输出,但不是将输出显示到STDOUT(一般是终端窗口上),而是将输出重定向(“管道”)到第二个命令的输入wc -l
,从而计算文件的行数。这便是一个匿名管道的例子。
基于Windows的命名管道就像上面的例子一样容易理解。在Windows上,命名管道只是一个对象,更具体地说是一个FILE_OBJECT,它由一个特殊的文件系统管理,命名管道文件系统(NPFS)
当我们创建命名管道时,假设我们将其称为“fpipe”,在底层下,正在一个名为“管道”的特殊设备驱动器上创建一个名为“fpipe”(因此:命名管道)的 FILE_OBJECT。
调用 WinAPI 函数CreateNamedPipe来创建命名管道
HANDLE serverPipe = CreateNamedPipe( L"\\\\.\\pipe\\fpipe", PIPE_ACCESS_DUPLEX, PIPE_TYPE_MESSAGE, 1, 2048, 2048, 0, NULL );
调用中有意思的部分是\\\\.\\pipe\\fpipe
,因为需要对斜线进行转义,所以实际上等于是\\.\pipe\fpipe
,\.
指的是全局根目录,“pipe”是指向 NamedPipe 设备的符号链接。
由于命名管道对象是 FILE_OBJECT,访问我们刚刚创建的命名管道就等于访问一个“普通”文件。因此,从客户端连接到命名管道就跟调用CreateFile
一样简单
HANDLE hPipeFile = CreateFile(L"\\\\127.0.0.1\\pipe\\fpipe", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
连接后,从管道读取只需要调用ReadFile
ReadFile(hPipeFile, pReadBuf, MESSAGE_SIZE, pdwBytesRead, NULL);
从管道读取数据之前,可以向它写入一些数据。那么这是通过调用谁来实现的——相信你能够猜到,没错,答案就是WriteFile
WriteFile(serverPipe, message, messageLenght, &bytesWritten, NULL);
一般情况下,用户层调用WriteFile
被分派到内核层的NtWriteFile
,它实现了写操作的所有细节,例如哪个设备对象与给定的文件相关联,写操作是否应该同步,最终将你的数据写入文件。但写入管道时,指定的数据没有写入磁盘上的实际文件,而是写入由CreateNamedPipe
返回的文件句柄引用的共享内存部分
命名管道还可以在跨系统边界的网络连接上使用
调用远程命名管道服务器不需要额外的实现,只需确保对CreateFile
的调用指定了IP或主机名
调用远程管道服务器时会使用SMB网络协议与远程服务器建立SMB连接,默认情况下,由SMB协商方言,以确定网络认证协议
SMB(Server Message Block)
服务器消息块,也称为 SMB,是由 Microsoft、IBM 和 Intel 联合开发的高级文件共享协议,用于在网络上的计算机之间传递数据。Microsoft Windows 和 OS/2 使用服务器消息块 (SMB)。许多UNIX 操作系统也支持它。
通过 SMB 协议,客户端应用程序可以在各种网络环境下读、写服务器上的文件,以及对服务器程序提出服务请求。此外通过 SMB 协议,应用程序可以访问远程服务器端的文件、以及打印机、邮件槽(mailslot)、命名管道(named pipe)等资源。
在 TCP/IP 环境下,客户机通过 NetBIOS over TCP/IP(或 NetBEUI/TCP 或 SPX/IPX)连接服务器。一旦连接成功,客户机可发送 SMB 命令到服务器上,从而客户机能够访问共享目录、打开文件、读写文件,以及一切在文件系统上能做的所有事情
网络身份验证协议是在客户端和服务器之间通过 SMB 协议协商的
与其它IPC机制不同,无法以编程方式控制网络认证协议,因为这永远是通过SMB来协商。从客户端的角度来看,可以通过选择连接到主机名或 IP 来有效地选择身份验证协议。由于Kerberos的设计,它不能很好地处理IP,因此,如果选择连接到一个IP地址,协商的结果总是NTLM(v2)。反之,当连接到主机名时,很可能最终总是使用Kerberos。如果你想强制使用更强的 Kerberos 协议(只能在服务器主机上禁用 NTLM)
一旦身份验证完成,SMB处理这些操作就像处理其他文件操作一样,例如启动如下所示的“创建请求文件”请求
数据传输模式
命名管道提供两种基本通信模式:字节模式和消息模式
在字节模式下,数据以连续字节流的形式在客户与服务器之间流动。这也就意味着对于客户机应用和服务器应用在任何一个特定的时间段内都无法准确知道有多少字节从管道中读出或写入。在这种通信模式中,一方在向管道写入某个数量的字节后并不能保证管道的另一方能读出等量的字节,这可以让客户端和服务器在不关心数据大小的情况下传输数据。
在消息模式下,客户机和服务器则是通过一系列不连续的数据包进行数据的收发。从管道发出的每一条消息都必须作为一条完整的消息读入。
重叠管道 I/O、阻塞模式和输入/输出缓冲区
从安全性的角度来看,重叠I/O、阻塞模式和输入/输出缓冲区并不是特别重要。但是意识到它们的存在以及它们的含义可以帮助理解命名管道。
重叠 I/O
几个与命名管道相关的函数,例如ReadFile
, WriteFile
, TransactNamedPipe
和ConnectNamedPipe
可以同步执行管道操作,这意味着执行线程在继续之前等待操作完成。或者异步,这意味着执行线程触发操作并继续执行,而不等待操作完成。
需要注意的是,异步管道操作只能在允许重叠I/O的管道(服务器)上进行,方法是在CreateNamedPipe
调用中设置FILE_FLAG_OVERLAPPED
。
异步调用可以通过指定OVERLAPPED结构为上面提到的一些管道操作API的最后一个参数来实现,例如ReadFile
BOOL ReadFile( [in] HANDLE hFile, [out] LPVOID lpBuffer, [in] DWORD nNumberOfBytesToRead, [out, optional] LPDWORD lpNumberOfBytesRead, [in, out, optional] LPOVERLAPPED lpOverlapped );
或者通过指定COMPLETION_ROUTINE作为扩展API的最后一个参数,例如ReadFileEx
BOOL ReadFileEx( [in] HANDLE hFile, [out, optional] LPVOID lpBuffer, [in] DWORD nNumberOfBytesToRead, [in, out] LPOVERLAPPED lpOverlapped, [in] LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine );
OVERLAPPED是基于事件的,必须创建一个事件对象,等待IO操作完成的信号。而COMPLETION_ROUTINE是基于回调的,回调例程被传递给执行线程,该线程排队并在有信号后执行
阻塞模式
阻塞模式是在使用CreateNamedPipe
设置命名管道服务器时定义的,方法是使用dwPipeMode
参数中的一个标志
下面两个dwPipeMode标志定义了服务器的阻塞模式:
PIPE_WAIT(0x00000000):阻塞模式已启用。当在ReadFile
、WriteFile
或 ConnectNamedPipe
函数中指定管道句柄时 , 直到有数据要读取、所有数据都已写入或客户端已连接时,操作才会完成。使用此模式可能意味着在某些情况下无限期地等待客户端进程执行操作。
PIPE_NOWAIT(0x00000001):非阻塞模式已启用。在这种模式下,ReadFile
、WriteFile
和 ConnectNamedPipe
总是立即返回
输入/输出缓冲区
命名管道服务器的输入和输出缓冲区,在调用CreateNamedPipe
时创建,由nInBufferSize
和nOutBufferSize
参数设置缓冲区的大小
当执行读写操作时,命名管道服务器使用非分页内存(即物理内存)临时存储要读写的数据。如果攻击者能控制已创建服务器的这些值,他可能会恶意滥用这些值,通过选择大的缓冲区来潜在地导致系统崩溃,或者通过选择小的缓冲区(例如0)使管道操作延迟:
Large buffers:由于输入/输出缓冲区是非分页的,如果设置的太大,服务器将耗尽内存。但是,系统不会“一味地”接受nInBufferSize和nOutBufferSize参数。上限由系统相关常数定义;这篇文章表明 x64 Windows7 系统大约为 4GB,win10应该比这个大
Small buffers:
对于将nInBufferSize和nOutBufferSize设为0,咋一看,好像缓冲区变成0代表是一个不存在的缓冲区,如果系统会严格执行它被告知的内容,将不能向管道写入任何东西。但系统还是有点聪明,可以理解你正在要求最小缓冲区,因此会将分配的实际缓冲区扩展为它接收的大小,但这会对性能产生影响。缓冲区大小为 0 意味着每个字节都必须由管道另一端的进程读取(从而清空缓冲区),然后才能将新数据写入缓冲区,因此,大小为 0 的缓冲区可能会导致服务器延迟。
命名管道安全
当想要使命名管道变得安全时,唯一能做的就是为命名管道服务器设置一个安全描述符,作为CreateNamedPipe调用的最后一个参数(lpSecurityAttributes)。
设置此安全描述符是可选的;可以通过为lpSecurityAttributes参数指定NULL来设置默认安全描述符。Windows 文档定义了默认安全描述符对命名管道服务器的作用:
命名管道的默认安全描述符中的 ACL 将完全控制权授予 LocalSystem 帐户、管理员和创建者所有者。他们还授予Everyone 组成员和匿名帐户的读取权限。
Source:https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createnamedpipea#parameters
所以在默认情况下,如果你没有指定安全描述符,每个人都可以从你的命名管道服务器读取,不管读取的客户端是不是在同一台机器上
模拟(Impersonation) 模仿 冒充 扮演 coser
安全上下文就是一个进程允许做什么的权限集合
模拟是线程在与拥有该线程的进程的安全上下文不同的安全上下文中执行的能力。模拟通常应用于客户端-服务器体系结构,其中客户端连接到服务器,服务器可以(如果需要)模拟客户端。模拟使服务器(线程)能够代表客户端执行操作,但在客户端访问权限的范围内。
一个典型的场景是假设用户想要删除远程文件共享上的文件。现在托管文件的服务器需要确定是否允许用户这样做。服务器无法访问用户的访问令牌,因为该令牌在远程服务器的内存中不可访问(该令牌存储在请求删除文件的用户的计算机上)。服务器可以做的是从 Active Directory (AD) 中查询用户的帐户和组信息,并手动检查是否允许用户删除文件,但此任务很繁琐且容易出错。因此,实施了另一种方法,称为“模拟”。
基本思想是服务器假装是请求用户并执行请求的操作,就好像服务器是用户一样。所以服务器所做的就是复制用户的Impersonation令牌,并使用复制的令牌请求操作(例如删除文件)
模拟是一个强大的功能,它使一个进程能够伪装成其他人
所以你产生了大胆的想法没有:模拟高特权用户的令牌并获得权限提升!
Windows的访问控制模型有两个主要的组成部分,访问令牌(Access Token)和安全描述符(Security Descriptor),它们分别是访问和被访问者拥有的东西。通过访问令牌和安全描述符的内容,Windows可以确定持有令牌的访问者能否访问持有安全描述符的对象
土豆系列的提权原理主要是诱导高权限访问低权限的系统对象,导致低权限的对象可以模拟高权限对象的访问令牌(Access Token),进而可以用访问令牌创建进程,达到代码执行。
例如国内教程经常提到的烂土豆(Rotten Potato)提权MS16-075
备注:在过去的时候,RottenPotato、RottenPotatoNG或Juicy Potato等工具使利用 Windows 上的模拟特权在脚本小子中非常流行。不过,最近操作系统的变化有意或无意地降低了这些技术在Windows 10和Server 2016/2019上的能力。一些可怜的渗透小子恐怕就止步于此了。但在我们理解了命名管道后,我们可以自己编写工具再次轻松地利用这些特权。
想法不错,但显然没有那么简单。因为这里有两个障碍:
第一个障碍是实际上并非每个令牌都是”有价值的“,这意味着每个模拟令牌都有一个名为Impersonation Level的属性,该属性可以具有以下值之一:
-
匿名级别- 服务器可以模拟客户端,但令牌不包含客户端的任何信息。匿名级别仅支持进程间通信(例如命名管道)。
-
识别级别- 这是默认值。服务器可以获取客户端的身份以便进行 ACL 检查。可以使用该令牌来读取有关模拟用户的信息或检查资源 ACL,但你无法访问该资源(读/写/执行)。
-
模拟级别- 服务器可以模拟客户端的安全上下文以访问本地资源。
-
委托级别- 最强大的模拟级别。服务器可以模拟客户端的安全上下文来访问本地或远程资源。
因此,你需要得到的是一个模拟或委托级别模拟令牌,以便能够搞一些事情
遇到的第二个障碍是,为了能够复制(模拟)另一个令牌,需要特殊的权限。该特权是TOKEN_DUPLICATE(在访问令牌对象的访问权限中指定)。如果不拥有此特权,复制令牌的请求不会被拒绝,仍然会得到一个复制的令牌,但其模拟级别较低(识别级别)。
模拟命名管道客户端
让我们快速了解一下如果服务器模拟客户端,实际底层会发生什么
-
第 1 步:服务器等待来自客户端的传入连接,然后调用ImpersonateNamedPipeClient函数。
-
第 2 步:此调用导致调用NtCreateEvent(创建回调事件)和NtFsControlFile,这是执行模拟的函数。
-
第 3 步:NtFsControlFile是一个通用函数,其操作由参数指定,模拟时为FSCTL_PIPE_Impersonate。
-
第 4 步:再往下,调用NpCommonFileSystemControl,其中FSCTL_PIPE_IMPERSONATE作为参数传递,并在 switch-case 指令中使用以确定要做什么。
-
第 5 步:NpCommonFileSystemControl调用NbAcquireExeclusiveVcb来锁定对象,并在给定服务器的管道对象和客户端发出的 IRP(I/O 请求对象)的情况下调用 NpImpersonate。
-
第 6 步:然后NpImpersonate依次调用SeImpersonateClientEx并使用从客户端 IRP 中获得的客户端安全上下文作为参数。
-
第 7 步:SeImpersonateClientEx依次使用服务器的线程对象和客户端的安全令牌调用PsImpersonateClient,该安全令牌是从客户端的安全上下文中提取的
-
第 8 步:然后将服务器的线程上下文更改为客户端的安全上下文。
-
第 9 步:服务器在客户端的安全上下文中执行的任何操作和服务器调用的任何函数都是使用客户端的身份进行的,从而模拟客户端。
-
第 10 步:如果服务器在作为客户端时完成了它打算做的事情,则服务器调用RevertToSelf以切换回它自己的原始线程上下文。
Attack
客户端模拟
攻击场景
当你获得允许你指定或控制对文件的访问的服务、程序或例程时(不管它是否允许您进行读或写访问或两者兼而有之),使用命名管道的模拟最可能被滥用。由于命名管道本质上是 FILE_OBJECT, 并且操作与普通文件的访问函数(ReadFile, WriteFile, CreateFile,…)相同,因此你可以指定一个命名管道而不是普通的文件名,让受害进程连接到你控制下的命名管道,比如模拟令牌:,这也是命名管道中常见的一种手法,一般可以用来提权操作,msf中的getsystem也就是这个原理。
前提条件
在尝试模拟客户端时,需要检查两个重要方面。
第一个方面是检查客户端如何实现文件访问,更具体地说,客户端在调用CreateFile时是否指定了 SECURITY_SQOS_PRESENT 标志?
如果没有与SECURITY_SQOS_PRESENT标志一起指定其他标志,那么默认在模拟级别(SECURITY_IMPERSONATION)模拟客户端
因此一个容易受攻击的CreateFile调用是这样的:
hFile = CreateFile(pipeName, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
而一个安全的CreateFile调用是这样的:
//调用API时参数带有明确的SECURITY_IMPERSONATION_LEVEL标志 hFile = CreateFile(pipeName, GENERIC_READ, 0, NULL, OPEN_EXISTING, SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION , NULL); //调用API时参数没有明确的SECURITY_IMPERSONATION_LEVEL标志 hFile = CreateFile(pipeName, GENERIC_READ, 0, NULL, OPEN_EXISTING, SECURITY_SQOS_PRESENT, NULL);
默认情况下,没有明确指定 SECURITY_IMPERSONATION_LEVEL的调用是使用 SecurityAnonymous(匿名级别) 的模拟级别。
如果设置了 SECURITY_SQOS_PRESENT 标志而没有任何额外的模拟级别 (IL) 或 IL 设置为 SECURITY_IDENTIFICATION 或 SECURITY_ANONYMOUS,则无法模拟客户端
要检查的第二个重要方面是文件名,也就是给CreateFile的lpFileName参数,这是调用本地命名管道和调用远程命名管道之间的重要区别。
对本地命名管道的调用由文件位置\\.\pipe\<SomeName>
定义。只有当SECURITY_SQOS_PRESENT标志显式设置为高于SECURITY_IDENTIFICATION的模拟级别时,才能模拟对本地管道的调用。因此,容易受攻击的调用是这个样子:
hFile = CreateFile(L"\\.\pipe\fpipe", GENERIC_READ, 0, NULL, OPEN_EXISTING, SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION, NULL);
所以,对本地管道的安全调用如下所示:
hFile = CreateFile(L"\\.\pipe\fpipe", GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
另一方面,远程命名管道是由以主机名或IP开头的lpFileName定义的,例如:\\ServerA.domain.local\pipe\<SomeName>
现在重点来了:
当 SECURITY_SQOS_PRESENT 标志不存在并且调用远程命名管道时,模拟级别由运行命名管道服务器的用户特权定义。
这意味着当您调用没有 SECURITY_SQOS_PRESENT 标志的远程命名管道时,运行该管道的攻击者用户必须持有SeImpersonatePrivilege ( SE_IMPERSONATE_NAME ) 才能模拟客户端。如果用户不拥有此特权,模拟级别将设置为SecurityIdentification(该级别允许您识别用户,但不能模拟用户)。同样,这也意味着,如果你的用户拥有SeEnableDelegationPrivilege ( SE_ENABLE_DELEGATION_NAME ),则模拟级别设置为 SecurityDelegation(委托级别),emmm....
除外,还有一个BUG是:
可以通过指定对在同一台机器上运行的命名管道进行远程管道调用
\\127.0.0.1\pipe\<SomeName>
舒服了吧。。。。。。。。。。。
综上所述:
-
如果没有设置SECURITY_SQOS_PRESENT,那么你至少具有SE_IMPERSONATE_NAME权限的用户,才可以模拟客户端,但是对于在同一台机器上运行的命名管道,你需要通过远程管道的方式
\\127.0.0.1\pipe\...
绕过这种限制 -
如果设置了 SECURITY_SQOS_PRESENT ,则只有同时设置了高于SECURITY_IDENTIFICATION 的模拟级别,你才能模拟客户端(无论是在本地还是远程调用命名管道)
实现
//创建命名管道服务器 serverPipe = CreateNamedPipe( pipeName, PIPE_ACCESS_DUPLEX, PIPE_TYPE_MESSAGE, 1, 2048, 2048, 0, NULL ); //等待管道连接 BOOL bPipeConnected = ConnectNamedPipe(serverPipe, NULL); //模拟客户端 BOOL bImpersonated = ImpersonateNamedPipeClient(serverPipe); // 这里打开的线程令牌现在是客户端的 BOOL bSuccess = OpenThreadToken(GetCurrentThread(), TOKEN_ALL_ACCESS, FALSE, &hToken); //成功打开后,恢复到自己的线程环境 bSuccess = RevertToSelf(); //现在复制客户端的Primary令牌 bSuccess = DuplicateTokenEx(hToken, TOKEN_ALL_ACCESS, NULL, SecurityImpersonation, TokenPrimary, &hDuppedToken ); // 使用该复制的令牌创建一个进程 CreateProcessWithTokenW(hDuppedToken, LOGON_WITH_PROFILE, command, NULL, CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);
实例创建条件竞争
命名管道实例被创建并存在于名称管道文件系统 (NPFS) 设备驱动器内的全局“命名空间”中(实际上从技术上讲,没有命名空间,但这有助于理解所有命名管道都存在于同一片空间中)。此外,同一空间里可以存在多个具有相同名称的命名管道。
那么,如果应用程序创建了一个已经存在的命名管道,会发生什么情况呢? 如果你不设置正确的标志,实际什么都不会发生,不会给你报错。但是你不会得到客户端连接,这是因为命名管道实例组织在FIFO (First in First Out)堆栈中。
这种设计使得命名管道容易受到实例创建条件竞争漏洞的攻击。
攻击场景
利用这种竞争条件的攻击场景如下:服务器创建一个命名管道用于与客户端应用程序通信,服务器应用程序在服务器管道创建后会触发客户端连接。你得弄清楚服务器何时以及如何启动以及它创建的管道的名称。弄清楚后,你可以编写一个程序,在服务器应用程序创建其管道实例之前创建一个具有相同名称的命名管道。如果服务器的命名管道创建不安全,它不会注意到同名的命名管道已经存在,不会发生任何错误。由于管道内部实例位于FIFO堆栈中,创建第一个管道实例的应用程序将获得第一个管道客户端,您可以读取或写入其数据或尝试模拟客户端。
前提条件
要使此攻击起作用,您需要目标服务器不检查是否已经存在同名的命名管道。通常,服务器不会编写额外的代码来检查是否已经存在同名的管道—因为一个朴素的思维是,如果我的管道名称已经存在,那么再创建应该得到一个错误,对吧?但这不会发生,因为两个具有相同名称的命名管道实例绝对有效……不讲道理。
但是为了应对这种攻击,微软添加了FILE_FLAG_FIRST_PIPE_INSTANCE标志,可以在通过CreateNamedPipe创建命名管道时指定该标志。当这个标志被设置后,如果已经存在相同名称的命名管道,那么你的创建调用将返回一个INVALID_HANDLE_VALUE,这将导致后面的ConnectNamedPipe调用时出错。
如果目标服务器没有指定FILE_FLAG_FIRST_PIPE_INSTANCE标志,那么很可能会受到攻击。但是对于攻击者,还有一件额外的事情需要注意。当通过CreateNamedPipe创建命名管道时,有一个nMaxInstances参数:
可以为此管道创建的最大实例数。管道的第一个实例可以指定这个值;必须为管道的其他实例指定相同的编号。可接受的值在 1 到PIPE_UNLIMITED_INSTANCES (255) 的范围内。来源:CreateNamedPipe
也就是我们可以对管道服务器可以并行连接的管道客户端数量限制,从1到255的范围。
因此,如果你将其设置为“1”,那么说明你的脑子是真的有泡。要利用实例创建条件竞争漏洞,请将其设置为 PIPE_UNLIMITED_INSTANCES。
实现
你需要做的就是在正确的时间使用正确的名称创建一个命名管道实例。
未得到答复的管道连接
未得到答复的管道连接是客户端发出的那些连接请求没有得到服务端的应答,因为客户端请求的管道不存在。这里的利用场景非常明确和简单:如果客户端想要连接到不存在的管道,我们可以创建一个客户端可以连接的管道通过恶意通信操纵客户端或冒充客户端以获得额外权限。
这个漏洞有时也被称为多余的管道连接(但我觉得这个术语不能知名见义)。
这里摆在面前的问题是:我们如何找到这样的客户端应用?
显而易见我们可以通过Procmon搜索失败的 CreateFile系统调用。可是事与愿违,Procmon并不会列出这些对管道的调用……也许那是因为该工具仅通过 NTFS 驱动程序检查/侦听文件操作
因此我们选择另一款工具IO Ninja,使用它工具集里的管道监视器( Pipe Monitor)可以轻松的完成这项任务。
使用搜索功能查找“Cannot open”:
杀死管道服务器
如果找不到未响应的管道连接尝试,但发现了一个想与之通信或模拟的有趣管道客户端,则获取客户端连接的另一种选择是终止其当前的管道服务器。
在前面部分我们已经得知了在同一个“命名空间”中可以有多个具有相同名称的命名管道。
所以你可以创建第二个具有相同名称的命名管道服务器并将自己置于队列中以服务客户端。只要原始管道服务器正在服务,你这边不会收到任何客户端调用,因此这种攻击的想法是破坏或杀死原始管道服务器以介入你的恶意服务器。
杀死原始管道服务器的手段有很多,主要决于谁在运行目标服务器以及你的访问权限和用户特权。
在分析目标管道服务器的终止技术时,试着跳出固有的思维模式,不仅仅是向进程发送关闭信号TerminateProcess
。像可能存在导致服务器关闭或重新启动的错误条件也可以利用(因为你在队列中——重新启动可能让你进入最前面的位置拿到控制权)。
另请注意,管道服务器只是一个在虚拟 FILE_OBJECT 上运行的实例,因此一旦它们的句柄引用计数达到 0(句柄是由连接到它的客户端打开的),命名管道服务器将被终止。因此,也可以通过杀死所有句柄来杀死服务器,当然这也涉及到句柄的保护技术,详情请参见前面课程驱动部分--驱动7-内核对象的保护
和免杀部分--利用句柄泄露Kill火绒
章节
配置不当任意读取
有时候,你可能对管道通信的数据感兴趣,而不是对操纵或模拟管道客户端感兴趣。
前提条件
在前面部分已经提到过,在保护命名管道时唯一能做的就是使用安全描述符作为CreateNamedPipe调用的最后一个参数 ( lpSecurityAttributes ) 。这是唯一能防止你访问任意命名管道实例的手段。因此,在搜索目标时,只需要检查此参数是否已设置好。
实现
当找到合适的目标时,还需要记住一件事:如果你使用ReadFile从命名管道中读取数据,则你正在从管道服务器和客户端的共享内存中删除数据,后面尝试从管道读取的人将找不到任何数据并可能引发错误。但是可以使用PeekNamedPipe函数来查看数据,而无需将其从共享内存中删除。
const int MESSAGE_SIZE = 512; BOOL bSuccess; LPCWSTR pipeName = L"\\\\.\\pipe\\fpipe"; HANDLE hFile = NULL; LPWSTR pReadBuf[MESSAGE_SIZE] = { 0 }; LPDWORD pdwBytesRead = { 0 }; LPDWORD pTotalBytesAvail = { 0 }; LPDWORD pBytesLeftThisMessage = { 0 }; // connect to named pipe hFile = CreateFile(pipeName, GENERIC_READ, 0, NULL, OPEN_EXISTING, SECURITY_SQOS_PRESENT | SECURITY_ANONYMOUS, NULL); // sneak peek data bSuccess = PeekNamedPipe( hFile, pReadBuf, MESSAGE_SIZE, pdwBytesRead, pTotalBytesAvail, pBytesLeftThisMessage );
参考:
关于命名管道安全的论文:https://www.blakewatts.com/namedpipepaper.html
绕过防火墙:https://www.secpulse.com/archives/164049.html
Remote Procedure Call (RPC)
介绍
远程过程调用(Remote Procedure Calls, RPC)是一种使客户端和服务器之间能够跨进程和机器边界进行数据通信(网络通信)的技术。因此 RPC 是一种进程间通信 ( IPC ) 技术。
顾名思义,RPC用于调用远程服务器以交换/传递数据或触发远程例程。但术语“远程”在这里是有点误导人的。因为RPC技术的目标是让开发者只要按照设计好的函数原型发起调用,就既可以调用本地的函数实现,也可以调用远程的函数实现。RPC技术提供了一种透明调用机制让使用者不必显式的区分本地调用和远程调用。
所以RPC 服务器不必非要位于远程机器上,理论上甚至不必位于不同的进程中(可以在dll中实现RPC服务器和客户端,将它们加载到相同的进程中互相通信)。
历史:https://kganugapati.wordpress.com/tag/msrpc/
RPC工作模型
RPC 是一种客户端-服务器技术,其消息传递体系结构类似于 COM,宏观上由以下三个组件组成:
-
负责注册 RPC 接口和相关绑定信息的服务器和客户端进程
-
负责转换传入和传出数据的服务器和客户端存根
-
服务器和客户端的 RPC 运行时库 (rpcrt4.dll),它获取存根数据并使用指定的协议通过网络发送它们
RPC 协议序列
RPC 协议序列是一个常量字符串,它定义了 RPC 运行时应该使用哪种RPC协议、传输协议来传输消息。
RPC的传输层有很多种选择,比如命名管道、TCP、UDP、IPX、SPX、LPC等。
在传输层上面,RPC层存在多种协议,目前,Microsoft 支持以下三种 RPC 协议:
-
面向连接的协议,全称为面向连接的网络计算架构 (NCACN)
-
数据报文协议, 全称为数据报文网络计算架构 (NCADG)
-
本地远程过程调用,全称为本地远程过程调用网络计算架构 (NCALRPC)
在大多数跨系统边界进行连接的场景中,你会发现NCACN,相比而言,本地RPC通信一般用NCALRPC。。
在使用RPC时,RPC双方必须明确RPC协议和传输层协议。所以协议序列就是用来标识不同的协议组合,例如ncacn_ip_tcp,用于基于TCP 数据包的面向连接的通信。
可以在以下位置找到 RPC 协议序列常量的完整列表:https://docs.microsoft.com/en-us/windows/win32/rpc/protocol-sequence-constants
最常用的协议序列如下所示:
常数/值 | 描述 |
---|---|
ncacn_ip_tcp | 面向连接的传输控制协议/互联网协议 (TCP/IP) |
ncacn_http | 使用 Microsoft Internet Information Server 作为 HTTP 代理的面向连接的 TCP/IP |
ncacn_np | 面向连接的命名管道(通过 SMB。) |
ncadg_ip_udp | 数据报(无连接) 用户数据报协议/互联网协议 (UDP/IP) |
ncalrpc | 本地过程调用(ALPC) |
RPC 接口
为了建立通信通道,RPC 运行时需要知道您的服务器提供了哪些方法(也称为“函数”)和参数,以及您的客户端正在发送哪些数据。这些信息在所谓的“接口”中定义。
接口是在接口定义语言 (IDL) 文件中定义的。然后由 Microsoft IDL 编译器 (midl.exe) 将其中的定义编译成服务器和客户端使用的头文件和源代码文件。
定义 RPC 接口的 IDL 文件示例:
[ // UUID: 每个接口都与一个 128 位或 16 字节的通用唯一标识符 (UUID) 相关联。 uuid(9510b60a-2eac-43fc-8077-aaefbdf3752b), // 这是该接口的1.0版本 version(1.0), // 使用一个名为sec的隐式句柄 implicit_handle(handle_t sec) ] interface Test //接口命名为Test { //接受以零结尾的字符串的函数 void fn1([in, string] const char* szString); void fn2(); }
上面显示了正在公开的接口的 UUID、接口名称 (Test),与该接口交互时可以调用的方法以及交互的参数。
该接口可以被认为是 RPC 客户端和服务器之间的桥梁。RPC 客户端必须实现该接口,而 RCP 服务器必须公开完全相同的接口,否则将无法进行通信。
注:fn1函数的参数定义中的[in, string]语句不是强制性的,只是有助于理解该参数的用途。
RPC 绑定
一旦客户端连接到一个 RPC 服务器(我们稍后会介绍如何完成),就创建了 Microsoft 所谓的“绑定句柄”。或者用微软的话来说:
绑定是在客户端程序和服务器程序之间创建逻辑连接的过程。构成客户端和服务器之间绑定的信息由称为绑定句柄的结构表示。
存在三种类型的绑定句柄:
-
隐式
-
显式
-
自动
隐式绑定句柄允许您的客户端连接到特定的 RPC 服务器并与之通信(由 IDL 文件中的 UUID 指定)。缺点是隐式绑定不是线程安全的,因此多线程应用程序应该使用显式绑定。隐式绑定句柄在 IDL 文件中定义,如上面的示例 IDL 代码中所示。
显式绑定句柄允许您的客户端连接到多个 RPC 服务器并与之通信。一般建议使用显式绑定句柄,因为它是线程安全的,并且允许多个连接。
对于懒惰的开发人员来说,自动绑定是介于两者之间的一种解决方案,让RPC在运行时确定需要什么也不失了一种好的方案。
到这你可能会问,为什么我需要绑定句柄?
把绑定句柄想象成客户端和服务器之间通信通道的表示,就像罐头电话中的电线一样,你手里有一个通信通道(“线”),那么你可以给这个通信通道添加属性,比如给你的线外面套上一层胶管使他不容易被别人扯断,从而保证一定的安全。
与此类似,绑定句柄允许你保护客户端和服务器之间的连接(你可以给绑定句柄添加安全性的东西),从而形成Microsoft术语中的“经过身份验证的”绑定。
匿名和认证绑定
假设您正在运行一个简单普通的 RPC 服务器,现在一个客户端连接到您的服务器。如果你没有指定任何东西(稍后会说),那么客户端和服务器之间的这种连接被称为匿名绑定或未验证绑定,因为你的服务器根本不知道是谁连接的。
所以为了避免任何客户端连接,并提高服务器的安全性,你可以采取三种措施:
-
您可以在注册服务器接口时设置注册标志
-
您可以使用自定义例程设置安全回调,以检查请求客户端是否应该被允许或拒绝
-
您可以设置与绑定句柄相关联的身份验证信息,以指定安全服务提供者和表示RPC服务器的SPN
让我们一步一步来看看这三种办法:
注册标志
首先,当您创建服务器时,您需要注册您的接口,例如调用RpcServerRegisterIf2。在RpcServerRegisterIf2的第四个参数,您可以指定接口注册标志,例如 RPC_IF_ALLOW_LOCAL_ONLY 以仅允许本地连接。
RPC_STATUS rpcStatus = RpcServerRegisterIf2( Example1_v1_0_s_ifspec, // Interface to register. NULL, // NULL type UUID NULL, // Use the MIDL generated entry-point vector. RPC_IF_ALLOW_LOCAL_ONLY, // Only allow local connections RPC_C_LISTEN_MAX_CALLS_DEFAULT, // Use default number of concurrent calls. (unsigned)-1, // Infinite max size of incoming data blocks. NULL // No security callback. );
安全回调
可以以你喜欢的任何方式自行实现过滤机制
RPC_STATUS CALLBACK SecurityCallback(RPC_IF_HANDLE hInterface, void* pBindingHandle) { return RPC_S_OK; //这里表示允许任何连接 }
使用的话只需将RpcServerRegisterIf2函数的最后一个参数设置为安全回调函数的名称
RPC_STATUS rpcStatus = RpcServerRegisterIf2( Example1_v1_0_s_ifspec, // Interface to register. NULL, // Use the MIDL generated entry-point vector. NULL, // Use the MIDL generated entry-point vector. RPC_IF_ALLOW_LOCAL_ONLY, // Only allow local connections RPC_C_LISTEN_MAX_CALLS_DEFAULT, // Use default number of concurrent calls. (unsigned)-1, // Infinite max size of incoming data blocks. SecurityCallback // No security callback. );
认证绑定
最后一个措施是一组额外的Windows API,它可以让服务器和客户端验证您的绑定
认证绑定结合正确的注册标志 (RPC_IF_ALLOW_SECURE_ONLY) 使您的 RPC 服务器能够确保只有经过身份验证的用户才能连接;并且——如果客户端允许的话——能够使服务器通过模拟客户端来确定谁连接到它。
虽然也可以使用 SecurityCallback 拒绝任何匿名客户端连接,但自行实现过滤机制不太容易。
在服务器端验证绑定:
RPC_STATUS rpcStatus = RpcServerRegisterAuthInfo( pszSpn, // 服务器主体名称 RPC_C_AUTHN_WINNT, // 使用NTLM作为身份验证服务提供者 NULL, NULL );
在客户端验证绑定:
RPC_STATUS status = RpcBindingSetAuthInfoEx( hExplicitBinding, // 客户端的绑定句柄 pszHostSPN, // 服务器的服务主体名称(SPN) RPC_C_AUTHN_LEVEL_PKT, // 身份验证级别PKT RPC_C_AUTHN_WINNT, // 使用NTLM作为身份验证服务提供者 NULL, // 使用当前线程凭据 RPC_C_AUTHZ_NAME, // 基于提供的SPN授权 &secQos // QOS结构体 );
客户端的有趣之处在于,您可以使用经过身份验证的绑定句柄设置安全服务质量 (QOS)结构。例如,可以在客户端使用此 QOS 结构来确定模拟级别
这里需要注意:在服务器端设置验证绑定,不会强制客户端进行身份验证。如果在服务器端没有设置标志或者只设置了RPC_IF_ALLOW_CALLBACKS_WITH_NO_AUTH,未经身份验证的客户端仍然可以连接到RPC服务器。但是,设置RPC_IF_ALLOW_SECURE_ONLY标志可以防止未经身份验证的客户端绑定,因为客户端不能在不创建验证绑定的情况下设置身份验证级别
知名端点 & 动态端点
当你启动 RPC 服务器时,服务器会注册一个接口(RpcServerRegisterIf2),它还需要定义它想要侦听的协议序列(例如' ncacn_ip_tcp ', ' ncacn_np ',…),好像就完了是不是?但此时在服务器中指定的协议序列字符串不足以打开 RPC 端口连接。
假设你指定了“ncacn_ip_tcp”作为你的协议序列,这意味着你指示你的服务器打开一个通过TCP/IP接受连接的RPC连接…但是…服务器应该在哪个TCP端口上打开连接呢?所以为了解决这个问题,与ncacn_ip_tcp类似,其他协议序列也需要更多关于在何处打开连接对象的信息:
-
ncacn_ip_tcp 需要一个 TCP 端口号,例如 9999
-
ncacn_np 需要一个命名管道名称,例如 “\pipe\FRPC-NP”
-
ncalrpc 需要一个 ALPC 端口名称,例如“\RPC Control\FRPC-LRPC”
假设将ncacn_np指定为协议序列并设置命名管道名称为“\pipe\FRPC-NP”。
RPC 服务器将愉快地启动并等待客户端连接。另外,客户端需要知道它应该连接到哪里。你告诉客户端你的服务器的名称,指定协议序列为ncacn_np并将命名管道名称设置为你在服务器中定义的相同名称(“\pipe\FRPC-NP”)。
然后客户端成功连接,正如你已经建立了一个RPC客户端和服务器基于一个众所周知的端点\pipe\FRPC-NP
使用知名的RPC 端点只意味着你预先知道所有绑定信息(协议序列和端点地址),并且如果您愿意的话,还可以在客户端和服务器中对这些信息进行硬编码。使用知名端点是建立你的第一个 RPC 客户端/服务器连接的最简单方法。
那么什么是动态端点,为什么要使用它们?如果我们现在选择ncacn_ip_tcp作为协议序列,我们如何知道哪些TCP端口仍然是可用的? 好吧,我们可以指定我们的程序需要9999端口才能正常工作,并且要确保这个端口没有被使用,但我们也可以要求 Windows 系统为我们分配一个空闲的端口。是的,这就是动态端点。十分简单的一个概念。
那么我们动态地分配了一个端口,客户端如何知道连接到哪里?...这是动态端点的另一个特点:如果你选择动态端点,你需要有人告诉你的客户端你使用的是什么端口,这个家伙便是RPC Endpoint Mapper服务(在Windows系统上默认是运行的)。如果服务器使用动态端点,它需要调用 RPC 端点映射器来告诉它注册的接口和函数(在 IDL 文件中指定)。一旦客户端尝试创建绑定,它将查询服务器的RPC端点映射器来匹配接口,端点映射器将填充缺失的信息(例如TCP端口)来创建绑定
动态端点的主要优点是在端点地址空间有限时自动找到可用的端点地址。
知名端点实现
RPC_STATUS rpcStatus; // 创建绑定信息 rpcStatus = RpcServerUseProtseqEp( (RPC_WSTR)L"ncacn_np", // 使用命名管道协议序列 RPC_C_PROTSEQ_MAX_REQS_DEFAULT, // 等待队列长度,使用 RPC_C_PROTSEQ_MAX_REQS_DEFAULT 指定默认值 (RPC_WSTR)L"\\pipe\\FRPC-NP", // 命名管道名称 NULL // 没有Secuirty描述符 ); // 注册接口 rpcStatus = RpcServerRegisterIf2(...) // 可选:注册认证信息 rpcStatus = RpcServerRegisterAuthInfo(...) // 监听收到的客户端连接 rpcStatus = RpcServerListen( 1, //建议的最小线程数 RPC_C_LISTEN_MAX_CALLS_DEFAULT, //建议的最大线程数。 FALSE //立刻开始监听 );
动态端点实现
RPC_STATUS rpcStatus; RPC_BINDING_VECTOR* pbindingVector = 0; // 创建绑定信息 rpcStatus = RpcServerUseProtseq( (RPC_WSTR)L"ncacn_ip_tcp", // tcp/ip RPC_C_PROTSEQ_MAX_REQS_DEFAULT, // 等待队列长度,使用 RPC_C_PROTSEQ_MAX_REQS_DEFAULT 指定默认值 NULL // 没有Secuirty描述符 ); // 注册接口 rpcStatus = RpcServerRegisterIf2(...) // 可选:注册认证信息 rpcStatus = RpcServerRegisterAuthInfo(...) // 获取服务器绑定句柄的向量 rpcStatus = RpcServerInqBindings(&pbindingVector); // 添加本地端点映射数据库中的服务器地址信息 rpcStatus = RpcEpRegister( Example1_v1_0_s_ifspec, //通过IDL定义的接口 pbindingVector, //绑定句柄的向量 0, //不用uuid (RPC_WSTR)L"MyDyamicEndpointServer" //注释 ); // 监听收到的客户端连接 rpcStatus = RpcServerListen( 1, //建议的最小线程数 RPC_C_LISTEN_MAX_CALLS_DEFAULT, //建议的最大线程数。 FALSE //立刻开始监听 );
RPC 通信流程
综上所述,通信流程可以总结如下:
-
服务器注册接口,例如使用RpcServerRegisterIf2
-
服务器使用RpcServerUseProtseq和RpcServerInqBindings创建绑定信息(RpcServerInqBindings对于知名端点是可选的)
-
服务器使用RpcEpRegister注册 Endpoints (对于知名端点是可选的)
-
服务器 可以使用RpcServerRegisterAuthInfo注册身份验证信息(可选)
-
服务器使用RpcServerListen监听客户端连接
-
客户端创建一个绑定句柄,使用RpcStringBindingCompose & RpcBindingFromStringBinding
-
客户端RPC 运行时库通过查询服务器主机系统上的 Endpoint Mapper 找到服务器进程(仅动态端点需要)
-
客户端 可以使用RpcBindingSetAuthInfo验证绑定句柄(可选)
-
客户端通过调用使用的接口中定义的函数之一进行 RPC 调用
-
客户端RPC 运行时库在 NDR 运行时的帮助下以NDR格式编组参数并将它们发送到服务器,
-
服务器的RPC 运行时库将编组的参数提供给存根,存根将它们解组,然后将它们传递给服务器例程。
-
当服务器例程返回时,存根获取 [out] 和 [in, out] 参数(在接口 IDL 文件中定义)和返回值,对它们进行编组,并将编组后的数据发送到服务器的 RPC 运行时库,它将它们传输回客户端。
实现
.idl
//uuid可以使用VS工具生成 [ uuid("00000001-EAF3-4A7A-A0F2-BCE4C30DA77E"), version(1.0) ] interface HelloWorld { int intAdd(int x, int y); }
.acf(对RPC接口进行配置)
[ implicit_handle(handle_t test_Binding) ] interface HelloWorld { }
server.cpp
#include <windows.h> #include <stdlib.h> #include <stdio.h> #include "HelloWorld_h.h" int intAdd(int x, int y) { printf("%d + %d = %d\n", x, y, x + y); return x + y; } int main(int argc, wchar_t* argv[]) { // 采用tcp协议,13521端口 RpcServerUseProtseqEp((RPC_WSTR)L"ncacn_ip_tcp", RPC_C_PROTSEQ_MAX_REQS_DEFAULT, (RPC_WSTR)L"13521", NULL); // 注册,HelloWorld_v1_0_s_ifspec定义域头文件test.h // 注意:从Windows XP SP2开始,增强了安全性的要求,如果用RpcServerRegisterIf()注册接口,客户端调用时会出现 // RpcExceptionCode() == 5,即Access Denied的错误,因此,必须用RpcServerRegisterIfEx带RPC_IF_ALLOW_CALLBACKS_WITH_NO_AUTH标志 // 允许客户端直接调用 RpcServerRegisterIfEx(HelloWorld_v1_0_s_ifspec, NULL, NULL, RPC_IF_ALLOW_CALLBACKS_WITH_NO_AUTH, 0, NULL); // 开始监听,本函数将一直阻塞 RPC_STATUS result = RpcServerListen(1, 20, FALSE); printf("end-------------RPC_STATUS: %d\n", result); return 0; } // 下面的函数是为了满足链接需要而写的,没有的话会出现链接错误 void __RPC_FAR* __RPC_USER midl_user_allocate(size_t len) { return(malloc(len)); } void __RPC_USER midl_user_free(void __RPC_FAR* ptr) { free(ptr); }
client.cpp
#include <windows.h> #include <stdlib.h> #include <stdio.h> #include <locale.h> #include "HelloWorld_h.h" int wmain(int argc, wchar_t* argv[]) { if (argc < 2) { _wsetlocale(LC_ALL, L"chs"); wprintf_s(L"请输入ip地址,格式为 testClient.exe xxx.xxx.xxx.xxx\n"); Sleep(2000); return 0; } wprintf_s(L"server ip: %s\n", argv[1]); RPC_WSTR pszStringBinding = NULL; int x, y, rval; RpcStringBindingCompose( NULL, (RPC_WSTR)L"ncacn_ip_tcp", (RPC_WSTR)argv[1] /*NULL*/, (RPC_WSTR)L"13521", NULL, &pszStringBinding ); // 绑定接口,这里要和 test.acf 的配置一致,那么就是test_Binding RpcBindingFromStringBinding(pszStringBinding, &test_Binding); // 下面是调用服务端的函数了 RpcTryExcept { while (1) { printf("Input two integer: "); scanf_s("%d %d", &x, &y); rval = intAdd(x, y); printf("%d\n", rval); Sleep(2000); } } RpcExcept(1) { printf("RPC Exception %d\n", RpcExceptionCode()); Sleep(2000); } RpcEndExcept // 释放资源 RpcStringFree(&pszStringBinding); RpcBindingFree(&test_Binding); return 0; } // 下面的函数是为了满足链接需要而写的,没有的话会出现链接错误 void __RPC_FAR* __RPC_USER midl_user_allocate(size_t len) { return(malloc(len)); } void __RPC_USER midl_user_free(void __RPC_FAR* ptr) { free(ptr); }
Attack
寻找目标
在我们探讨在RPC有哪些漏洞和攻击方法之前,我们首先需要先研究一下如何在系统上找到我们的攻击目标:RPC服务器和客户端
RPC 服务器
一般来说,构建服务器需要通过指定所需信息(协议序列和端点地址)并调用特定的Windows API。所以,逆向思维,在本地系统上查找 RPC 服务器的方法便是查找导入这些 RPC Windows API 的程序。那么一种简单的方法是使用现在随 Visual Studio 一起提供的DumpBin实用程序。
在C:\Windows\System32\
下搜索RPC服务器如下,此代码段将可执行文件的名称打印到控制台,并将整个 DumpBin 输出打印到文件RpcServerListen.txt
Get-ChildItem -Path "C:\Windows\System32\" -Filter "*.exe" -Recurse -ErrorAction SilentlyContinue | % { $out=$(C:\"Program Files (x86)"\"Microsoft Visual Studio 14.0"\VC\bin\dumpbin.exe /IMPORTS:rpcrt4.dll $_.VersionInfo.FileName); If($out -like "*RpcServerListen*"){ Write-Host "[+] Exe starting RPC Server: $($_.VersionInfo.FileName)"; Write-Output "[+] $($_.VersionInfo.FileName)`n`n $($out|%{"$_`n"})" | Out-File -FilePath RpcServerListen.txt -Append } }
另一种查找感兴趣的 RPC 服务器的方法是在本地或任何远程系统上查询 RPC Endpoint Mapper,Microsoft 有一个叫为PortQry的工具可以用来执行此操作(注意,只有RPC接口注册到目标的Endpoint Mapper才能查询出来,而知名端点是不必通知Endpoint Mapper有关其接口的信息的)
PortQry.exe -n <HostName> -e 135
RpcView枚举
还可以通过调用RpcMgmtEpEltInqBegin并通过RpcMgmtEpEltInqNext遍历接口来直接查询
/* * Copyright (c) BindView Development Corporation, 2001 * See LICENSE file. * Author: Todd Sabin <tsabin@razor.bindview.com> */ #include <windows.h> #include <winnt.h> #include <stdio.h> #include <rpc.h> #include <rpcdce.h> static int verbosity; int try_protocol (char *protocol, char *server) { unsigned char *pStringBinding = NULL; RPC_BINDING_HANDLE hRpc; RPC_EP_INQ_HANDLE hInq; RPC_STATUS rpcErr; RPC_STATUS rpcErr2; int numFound = 0; // // Compose the string binding // rpcErr = RpcStringBindingCompose (NULL, protocol, server, NULL, NULL, &pStringBinding); if (rpcErr != RPC_S_OK) { fprintf (stderr, "RpcStringBindingCompose failed: %d\n", rpcErr); return numFound; } // // Convert to real binding // rpcErr = RpcBindingFromStringBinding (pStringBinding, &hRpc); if (rpcErr != RPC_S_OK) { fprintf (stderr, "RpcBindingFromStringBinding failed: %d\n", rpcErr); RpcStringFree (&pStringBinding); return numFound; } // // Begin Ep enum // rpcErr = RpcMgmtEpEltInqBegin (hRpc, RPC_C_EP_ALL_ELTS, NULL, 0, NULL, &hInq); if (rpcErr != RPC_S_OK) { fprintf (stderr, "RpcMgmtEpEltInqBegin failed: %d\n", rpcErr); RpcStringFree (&pStringBinding); RpcBindingFree (hRpc); return numFound; } // // While Next succeeds // do { RPC_IF_ID IfId; RPC_IF_ID_VECTOR *pVector; RPC_STATS_VECTOR *pStats; RPC_BINDING_HANDLE hEnumBind; UUID uuid; unsigned char *pAnnot; rpcErr = RpcMgmtEpEltInqNext (hInq, &IfId, &hEnumBind, &uuid, &pAnnot); if (rpcErr == RPC_S_OK) { unsigned char *str = NULL; unsigned char *princName = NULL; numFound++; // // Print IfId // if (UuidToString (&IfId.Uuid, &str) == RPC_S_OK) { printf ("IfId: %s version %d.%d\n", str, IfId.VersMajor, IfId.VersMinor); RpcStringFree (&str); } // // Print Annot // if (pAnnot) { printf ("Annotation: %s\n", pAnnot); RpcStringFree (&pAnnot); } // // Print object ID // if (UuidToString (&uuid, &str) == RPC_S_OK) { printf ("UUID: %s\n", str); RpcStringFree (&str); } // // Print Binding // if (RpcBindingToStringBinding (hEnumBind, &str) == RPC_S_OK) { printf ("Binding: %s\n", str); RpcStringFree (&str); } if (verbosity >= 1) { unsigned char *strBinding = NULL; unsigned char *strObj = NULL; unsigned char *strProtseq = NULL; unsigned char *strNetaddr = NULL; unsigned char *strEndpoint = NULL; unsigned char *strNetoptions = NULL; RPC_BINDING_HANDLE hIfidsBind; // // Ask the RPC server for its supported interfaces // // // Because some of the binding handles may refer to // the machine name, or a NAT'd address that we may // not be able to resolve/reach, parse the binding and // replace the network address with the one specified // from the command line. Unfortunately, this won't // work for ncacn_nb_tcp bindings because the actual // NetBIOS name is required. So special case those. // // Also, skip ncalrpc bindings, as they are not // reachable from a remote machine. // rpcErr2 = RpcBindingToStringBinding (hEnumBind, &strBinding); RpcBindingFree (hEnumBind); if (rpcErr2 != RPC_S_OK) { fprintf (stderr, ("RpcBindingToStringBinding failed\n")); printf ("\n"); continue; } if (strstr (strBinding, "ncalrpc") != NULL) { RpcStringFree (&strBinding); printf ("\n"); continue; } rpcErr2 = RpcStringBindingParse (strBinding, &strObj, &strProtseq, &strNetaddr, &strEndpoint, &strNetoptions); RpcStringFree (&strBinding); strBinding = NULL; if (rpcErr2 != RPC_S_OK) { fprintf (stderr, ("RpcStringBindingParse failed\n")); printf ("\n"); continue; } rpcErr2 = RpcStringBindingCompose (strObj, strProtseq, strcmp ("ncacn_nb_tcp", strProtseq) == 0 ? strNetaddr : server, strEndpoint, strNetoptions, &strBinding); RpcStringFree (&strObj); RpcStringFree (&strProtseq); RpcStringFree (&strNetaddr); RpcStringFree (&strEndpoint); RpcStringFree (&strNetoptions); if (rpcErr2 != RPC_S_OK) { fprintf (stderr, ("RpcStringBindingCompose failed\n")); printf ("\n"); continue; } rpcErr2 = RpcBindingFromStringBinding (strBinding, &hIfidsBind); RpcStringFree (&strBinding); if (rpcErr2 != RPC_S_OK) { fprintf (stderr, ("RpcBindingFromStringBinding failed\n")); printf ("\n"); continue; } if ((rpcErr2 = RpcMgmtInqIfIds (hIfidsBind, &pVector)) == RPC_S_OK) { unsigned int i; printf ("RpcMgmtInqIfIds succeeded\n"); printf ("Interfaces: %d\n", pVector->Count); for (i=0; i<pVector->Count; i++) { unsigned char *str = NULL; UuidToString (&pVector->IfId[i]->Uuid, &str); printf (" %s v%d.%d\n", str ? str : "(null)", pVector->IfId[i]->VersMajor, pVector->IfId[i]->VersMinor); if (str) RpcStringFree (&str); } RpcIfIdVectorFree (&pVector); } else { printf ("RpcMgmtInqIfIds failed: 0x%x\n", rpcErr2); } if (verbosity >= 2) { if ((rpcErr2 = RpcMgmtInqServerPrincName (hEnumBind, RPC_C_AUTHN_WINNT, &princName)) == RPC_S_OK) { printf ("RpcMgmtInqServerPrincName succeeded\n"); printf ("Name: %s\n", princName); RpcStringFree (&princName); } else { printf ("RpcMgmtInqServerPrincName failed: 0x%x\n", rpcErr2); } if ((rpcErr2 = RpcMgmtInqStats (hEnumBind, &pStats)) == RPC_S_OK) { unsigned int i; printf ("RpcMgmtInqStats succeeded\n"); for (i=0; i<pStats->Count; i++) { printf (" Stats[%d]: %d\n", i, pStats->Stats[i]); } RpcMgmtStatsVectorFree (&pStats); } else { printf ("RpcMgmtInqStats failed: 0x%x\n", rpcErr2); } } RpcBindingFree (hIfidsBind); } printf ("\n"); } } while (rpcErr != RPC_X_NO_MORE_ENTRIES); // // Done // RpcStringFree (&pStringBinding); RpcBindingFree (hRpc); return numFound; } char *protocols[] = { "ncacn_ip_tcp", "ncadg_ip_udp", "ncacn_np", "ncacn_nb_tcp", "ncacn_http", }; #define NUM_PROTOCOLS (sizeof (protocols) / sizeof (protocols[0])) void Usage (char *app) { printf ("Usage: %s [options] <target>\n", app); printf (" options:\n"); printf (" -p protseq -- use protocol sequence\n", app); printf (" -v -- increase verbosity\n", app); exit (1); } int main (int argc, char *argv[1]) { int i, j; char *target = NULL; char *protseq = NULL; for (j=1; j<argc; j++) { if (argv[j][0] == '-') { switch (argv[j][1]) { case 'v': verbosity++; break; case 'p': protseq = argv[++j]; break; default: Usage (argv[0]); break; } } else { target = argv[j]; } } if (!target) { fprintf (stderr, "Usage: %s <server>\n", argv[0]); exit (1); } if (protseq) { try_protocol (protseq, target); } else { for (i=0; i<NUM_PROTOCOLS; i++) { if (try_protocol (protocols[i], target) > 0) { break; } } } return 0; }
RPC 客户端
相比较于服务器可以查询Endpoint Mapper找到它而言,Windows上是没有一个管理程序知道当前正在运行哪些 RPC 客户端的,因此只有两个选择来寻找客户端:
-
查找使用客户端RPC api的可执行文件/进程
-
通过客户端的特定行为
查找导入客户端 RPC API 的本地可执行文件类似于我们使用DumpBin查找服务器。一个特点什么鲜明的 Windows API 是RpcStringBindingCompose:
Get-ChildItem -Path "C:\Windows\System32\" -Filter "*.exe" -Recurse -ErrorAction SilentlyContinue | % { $out=$(C:\"Program Files (x86)"\"Microsoft Visual Studio 14.0"\VC\bin\dumpbin.exe /IMPORTS:rpcrt4.dll $_.VersionInfo.FileName); If($out -like "*RpcStringBindingCompose*"){ Write-Host "[+] Exe creates RPC Binding (potential RPC Client) : $($_.VersionInfo.FileName)"; Write-Output "[+] $($_.VersionInfo.FileName)`n`n $($out|%{"$_`n"})" | Out-File -FilePath RpcClients.txt -Append } }
查找 RPC 客户端的另一种选择是在它们连接到目标时发现它们。Wireshark 有一个“DCERPC”过滤器,可用于发现连接。
绑定请求是我们可以查找的用于标识客户端的东西之一。选中包,我们可以看到客户端尝试绑定到 UUID 为“d6b1ad2b-b550-4729-b6c2-1651f58480c3”的服务器接口。
未授权访问
你可以实现自己的客户端来尝试是否可以未经授权连接到服务器。
通过前面的学习,我们已经知道服务器通过调用RpcServerRegisterAuthInfo及其 SPN 和指定的服务提供者来设置身份验证信息,请注意,经过身份验证的服务器绑定并不会强制客户端使用经过身份验证的绑定。换句话说:仅仅因为服务器设置了身份验证信息,并不意味着客户端需要通过经过身份验证的绑定进行连接
请记住,默认情况下,安全性是可选的来源:https ://docs.microsoft.com/en-us/windows/win32/api/rpcdce/nf-rpcdce-rpcserverregisterifex
连接到服务器后,“下一步要做什么?”的问题出现了......好吧,可以调用接口函数,但坏消息是:需要首先识别函数名称和参数,这归结为对目标服务器进行逆向工程。如果你不是在搞一个纯粹的 RPC 服务器,而是一个 COM 服务器(COM,尤其是 DCOM,在后台使用 RPC),该服务器可能带有一个类型库 (.tlb),你可以使用它来查找接口函数。其外的使用Rpcview就好了(这个工具需要自己编译)。
客户端模拟
模拟客户端的方法如下:
-
需要一个 RPC 客户端连接到服务器
-
客户端必须使用经过身份验证的绑定(否则就没有可以模拟的安全信息)
-
客户端不得在SecurityImpersonation下设置 Impersonation Level 身份验证绑定----下面会解释
模拟的过程很简单:
-
从服务器接口函数中调用RpcImpersonateClient
-
如果该调用成功,服务器的线程上下文将更改为客户端的安全上下文,您可以调用GetCurrentThread和OpenThreadToken来接收客户端的模拟令牌。
-
调用DuplicateTokenEx将 Impersonation 令牌转换为主令牌后,您可以通过调用RpcRevertToSelfEx愉快地返回到原始服务器线程上下文
-
最后,您可以调用CreateProcessWithTokenW使用客户端的令牌创建一个新进程。
如上面的步骤中所述,您只需要一个连接到您的服务器的客户端,并且该客户端必须使用经过身份验证的绑定。如果客户端未对其绑定进行身份验证,则对RpcImpersonateClient的调用将导致错误 1764 (RPC_S_BINDING_HAS_NO_AUTH)。
找到可以连接到服务器的合适客户端是这个漏洞利用链中的棘手部分,我不能在这里就如何找到这些连接给出一般性建议。原因之一是它取决于客户端使用的协议序列,像ncacn_ip_tcp就去找未应答的 TCP 调用,ncacn_np就去找未应答的命名管道连接尝试。
好了,为啥客户端不得在*SecurityImpersonation下设置 Impersonation Level ?
还记得在创建经过身份验证的绑定时可以在客户端设置服务质量(QOS)结构吗? 正如在验证绑定一节中所说的,在连接到服务器时,可以使用该结构来确定模拟级别。有趣的是,如果您不设置任何 QOS 结构,则默认值为 SecurityImpersonation,它允许任何服务器模拟RPC客户端,只要客户端不显式设置低于SecurityImpersonation的模拟级别
利用服务器模拟失败
前面我们介绍了模拟客户端时涉及的步骤,这些步骤同样适用于 RPC 模拟(以及所有其他类似技术),其中以下两个步骤特别有趣:
>> 第 8 步:然后将服务器的线程上下文更改为客户端的安全上下文。>> 第 9 步:服务器在客户端的安全上下文中执行的任何操作和服务器调用的任何函数都是使用客户端的身份进行的,从而模拟客户端。
根据章节标题,你现在可能已经猜到了……如果模拟失败并且服务器不检查怎么办?
对RpcImpersonateClient的调用返回模拟操作的状态,服务器检查该状态至关重要。如果模拟成功,那么之后您将位于客户端的安全上下文中,但如果失败,您将处于调用RpcImpersonateClient的相同旧安全上下文中。现在,RPC 服务器可能是在一个高安全性的上下文中,而请求的客户端在较低的安全性上下文,所以它可能会尝试模拟其客户端以级别更低的客户端安全性上下文中运行客户端操作。那么作为攻击者,可以通过强制服务器端进行失败的模拟尝试,从而导致服务器在更高安全性的服务器上下文中执行客户端操作,从而实现权限提升。
这种攻击场景的方法很简单:
-
您需要一个模拟其客户端的服务器,并且在执行进一步操作之前不仔细检查RpcImpersonateClient的返回状态。
-
从您的客户端的角度来看,在模拟尝试后服务器所采取的操作必须是可利用的。
-
需要强制模拟尝试失败。
如果您阅读了前面的部分并记下了如何使用DumpBin ,那么查找试图模拟客户端的本地服务器是一项简单的任务。
找到可利用的服务器也什么简单,通过procmon、processhacker、wireshark等一系列工具监测一些事件动作网络连接从而筛选出目标。一个相当简单但功能强大的示例可能是服务器执行的文件操作;也许你可以使用连接在一个写保护权限的系统路径中创建一个文件
清单上的最后一项是导致服务器的模拟尝试失败,这是工作中最简单的部分。有两种方法可以实现这一点:
-
可以从未经身份验证的绑定进行连接
-
可以从经过身份验证的绑定连接并将 QOS 结构的模拟级别设置为SecurityAnonymous
这些操作中的任何一个都将安全地导致模拟尝试失败。
其实这个安全问题,微软也RpcImpersonateClient函数的备注部分特别提醒了这一点 :
如果对 RpcImpersonateClient 的调用由于任何原因失败,则不会模拟客户端连接,并且客户端请求是在进程的安全上下文中发出的。如果该进程作为高权限帐户(例如 LocalSystem)或作为管理组的成员运行,则用户可能能够执行否则会被禁止的操作。因此,始终检查调用的返回值很重要,如果失败,则引发错误;不要继续执行客户端请求。来源:RpcImpersonateClient:安全备注
参考:
What is RPC?https://www.youtube.com/watch?v=MdaGuP6-bKs
Microsoft 的 RPC 文档:https ://docs.microsoft.com/en-us/windows/win32/rpc/overviews
xpn:https ://blog.xpnsec.com/analysing-rpc-with-ghidra-neo4j/
rpc编写https://www.codeproject.com/Articles/4837/Introduction-to-RPC-Part-1#IDLRPCandyou1
从防御的角度看RPChttps://ipc-research.readthedocs.io/en/latest/subpages/RPC.html#identifying-the-rpc-servers
ALPC
介绍
在本地使用RPC就是使用ALPC
在讨论了可以在远程和本地使用的两种进程间通信 ( IPC ) 协议之后,即命名管道和RPC,我们再来研究一种只能在本地使用的技术。关于ALPC名称有两种说法:一种是Advanced Local Procedure Call(增强) ,另一种是Asynchronous Local Procedure Call(异步)。
LPC(Local Inter-Process Communication)
在深入ALPC之前,首先让我们了解一下什么是LPC。本地过程调用机制(严格来说,更像是一种消息通信机制)是在 1993-94 年与原始 Windows NT 内核一起引入的,作为同步进程间通信工具。它的同步特性意味着客户端/服务器必须等待消息发送并采取行动,然后才能继续执行。这是 ALPC 旨在取代的主要缺陷之一,也是一些人将 ALPC 称为异步LPC 的原因。ALPC 是在 Windows Vista 中出现的,至少从 Windows 7 开始,LPC 已从 NT 内核中完全删除。为了不破坏遗留应用程序并允许向后兼容,微软保留了用于创建 LPC 端口的函数,但函数调用被重定向为不创建 LPC,而是创建 ALPC 端口。
所以我们不会讨论已经被历史扫进垃圾堆里的LPC,只关注ALPC。
回到 ALPC。
ALPC 是一种快速、非常强大并且在 Windows 操作系统(内部)内非常广泛使用的进程间通信工具,但它没有公开出来,不打算供外界开发人员使用。因为对微软来说,ALPC是一个内部IPC工具,这意味着ALPC是没有文档化的,只被用作其他文档化的、旨在为开发人员使用的消息传输协议的底层传输技术,例如RPC。所以一种间接的方式是通过微软公开的RPC编程接口来使用ALPC
然而,ALPC没有文档记录的这一事实并不意味着 ALPC 是一个完全的黑匣子,因为许多聪明人已经对它的工作原理和它具有哪些组件进行了逆向工程。但是如果你作为开发者显然你不应该在生产开发中使用,不应该直接使用 ALPC 来构建软件。因为这个东西完全是逆向出来的,有很多可能导致安全性或稳定性问题的非显而易见的隐患。
此外,这篇文章中的所有信息也不一定100% 准确,因为 ALPC 没有记录在案。
ALPC 内部结构
ALPC通信的主要组件是ALPC端口对象。ALPC端口对象是一个内核对象,它的使用类似于网络套接字的使用,服务器打开一个套接字,客户机可以连接到它来交换消息。
启动WinObj,会发现在每个Windows操作系统上都有很多ALPC端口,一小部分可以在根路径下找到:
大多数 ALPC 端口都位于“RPC Control”路径下(记住,RPC在底层使用ALPC):
要开始进行ALPC通信,服务器需要打开一个客户端可以连接的ALPC端口,该端口称为ALPC 连接端口。然而,这并不是在ALPC通信流期间创建的唯一ALPC端口,另外还要创建了两个ALPC端口,分别用于客户端和服务器,以便向其传递消息(后面解释)所以,首先要记住的是:
-
总共有 3 个 ALPC 端口(2个在服务器端,1个在客户端)参与 ALPC 通信。
-
在上面的WinObj截图中看到的端口是ALPC连接端口,这是客户端可以连接到的端口。
尽管在一次 ALPC 通信中总共使用了 3 个 ALPC 端口,并且它们都以不同的名称引用(例如“ALPC 连接端口”),但只有一个ALPC端口内核对象,在ALPC通信中使用的所有三个端口都实例化它。这个 ALPC 内核对象的结架如下所示:
//0x1d8 bytes (sizeof) struct _ALPC_PORT { struct _LIST_ENTRY PortListEntry; //0x0 struct _ALPC_COMMUNICATION_INFO* CommunicationInfo; //0x10 struct _EPROCESS* OwnerProcess; //0x18 VOID* CompletionPort; //0x20 VOID* CompletionKey; //0x28 struct _ALPC_COMPLETION_PACKET_LOOKASIDE* CompletionPacketLookaside; //0x30 VOID* PortContext; //0x38 struct _SECURITY_CLIENT_CONTEXT StaticSecurity; //0x40 struct _EX_PUSH_LOCK IncomingQueueLock; //0x88 struct _LIST_ENTRY MainQueue; //0x90 struct _LIST_ENTRY LargeMessageQueue; //0xa0 struct _EX_PUSH_LOCK PendingQueueLock; //0xb0 struct _LIST_ENTRY PendingQueue; //0xb8 struct _EX_PUSH_LOCK DirectQueueLock; //0xc8 struct _LIST_ENTRY DirectQueue; //0xd0 struct _EX_PUSH_LOCK WaitQueueLock; //0xe0 struct _LIST_ENTRY WaitQueue; //0xe8 union { struct _KSEMAPHORE* Semaphore; //0xf8 struct _KEVENT* DummyEvent; //0xf8 }; struct _ALPC_PORT_ATTRIBUTES PortAttributes; //0x100 struct _EX_PUSH_LOCK ResourceListLock; //0x148 struct _LIST_ENTRY ResourceListHead; //0x150 struct _EX_PUSH_LOCK PortObjectLock; //0x160 struct _ALPC_COMPLETION_LIST* CompletionList; //0x168 struct _CALLBACK_OBJECT* CallbackObject; //0x170 VOID* CallbackContext; //0x178 struct _LIST_ENTRY CanceledQueue; //0x180 LONG SequenceNo; //0x190 LONG ReferenceNo; //0x194 struct _PALPC_PORT_REFERENCE_WAIT_BLOCK* ReferenceNoWait; //0x198 union { struct { ULONG Initialized:1; //0x1a0 ULONG Type:2; //0x1a0 ULONG ConnectionPending:1; //0x1a0 ULONG ConnectionRefused:1; //0x1a0 ULONG Disconnected:1; //0x1a0 ULONG Closed:1; //0x1a0 ULONG NoFlushOnClose:1; //0x1a0 ULONG ReturnExtendedInfo:1; //0x1a0 ULONG Waitable:1; //0x1a0 ULONG DynamicSecurity:1; //0x1a0 ULONG Wow64CompletionList:1; //0x1a0 ULONG Lpc:1; //0x1a0 ULONG LpcToLpc:1; //0x1a0 ULONG HasCompletionList:1; //0x1a0 ULONG HadCompletionList:1; //0x1a0 ULONG EnableCompletionList:1; //0x1a0 } s1; //0x1a0 ULONG State; //0x1a0 } u1; //0x1a0 struct _ALPC_PORT* TargetQueuePort; //0x1a8 struct _ALPC_PORT* TargetSequencePort; //0x1b0 struct _KALPC_MESSAGE* CachedMessage; //0x1b8 ULONG MainQueueLength; //0x1c0 ULONG LargeMessageQueueLength; //0x1c4 ULONG PendingQueueLength; //0x1c8 ULONG DirectQueueLength; //0x1cc ULONG CanceledQueueLength; //0x1d0 ULONG WaitQueueLength; //0x1d4 };
正如上面所看到的,ALPC内核对象是一个相当复杂的内核对象,它引用各种其他的对象类型。
ALPC 消息传递
为了更深入地研究ALPC,我们将研究ALPC消息流,以理解消息是如何发送的以及它们是什么样子的。首先,我们已经了解到在ALPC通信场景中涉及3个ALPC端口对象,第一个是ALPC 连接端口,由服务器进程创建,客户机可以连接到它(类似于网络套接字)。一旦客户端连接到服务器的ALPC连接端口,内核将创建两个新端口,称为ALPC 服务器通信端口和ALPC 客户端通信端口。
一旦建立了服务器和客户端通信端口,双方就可以使用ntdll.dll公开的函数NtAlpcSendWaitReceivePort
相互发送消息。这个函数的名字听起来像是同时做三件事—发送、等待和接收—而这正是它的真正含义。服务器和客户端使用这个单独的函数在它们的ALPC端口上等待消息、发送消息和接收消息。
在这个单独的函数中,你还可以指定你想要发送什么样的消息(有不同的类型,有不同的含义),以及你想要与你的消息一起发送哪些属性,这些我们都将在后面讨论。
到目前为止,这听起来相当简单:服务器打开一个端口,客户端连接到它,两者都接收到一个通信端口的句柄,并通过单个函数NtAlpcSendWaitReceivePort
发送消息……嗯,这很简单。
我们站在高处看,这一切都很容易理解的,但站得高虽然可以望得远却看不清,所以让我们怀着一颗谦卑的心从高处慢慢走下来,拉近距离去看清“细节”:
-
服务器进程使用选定的 ALPC 端口名称(例如“ *CSALPCPort ”)调用
NtAlpcCreatePort
,并可选地使用安全描述符来指定谁可以连接到它。然后内核创建一个ALPC端口对象,并将这个对象的句柄返回给服务器,这个端口被称为ALPC连接端口 -
服务器调用
NtAlpcSendWaitReceivePort
,将句柄传递给其先前创建的连接端口,以等待客户端连接 -
客户端调用
NtAlpcConnectPort
:-
服务器ALPC端口的名称(CSALPCPort)
-
(可选)发送给服务器的消息
-
(可选)服务器的SID,以确保客户机连接到预期的服务器
-
(可选)与客户端连接请求一起发送的消息属性
-
-
然后将此连接请求传递给服务器,服务器调用
NtAlpcAcceptConnectPort
来接受或拒绝客户端的连接请求。(没看错,虽然该函数叫作NtAlpcAccept…但这个函数也可以用来拒绝客户端连接。这个函数的最后一个参数是一个布尔值,它指定连接是被接受(如果设置为true)还是被拒绝(如果设置为false))。服务器可以选择:
-
(可选)向客户端返回一条消息,接受或拒绝连接请求
-
(可选)向消息添加消息属性
-
(可选)分配一个自定义结构,例如一个唯一的ID,附加到服务器的通信端口,以便识别客户端--如果服务器接受连接请求,服务器和客户端分别接收到一个通信端口的句柄
-
-
客户端和服务器现在可以通过
NtAlpcSendWaitReceivePort
相互发送和接收消息,其中:-
客户端监听新消息并将其发送到其通信端口
-
服务器监听新消息并将其发送到其连接端口
-
客户端和服务器都可以指定在监听新消息时要接收哪些消息属性
-
这里有一些奇怪?为什么服务器是在连接端口而不是通信端口上发送/接收数据呢?
因为服务器在其通信端口上监听和发送消息是LPC (ALPC的前身)的工作方式。但是,这种设计将迫使您在服务器接受的每个新客户端上监听越来越多的通信端口。假设一个服务器有100个客户端与它通信,那么服务器需要监听100个通信端口来获取客户机消息,这通常会导致创建100个线程,其中每个线程将与不同的客户端通信。这是非常低效的,一个更有效的解决方案是让一个线程在服务器的连接端口上监听(和发送),所有消息都被发送到这个连接端口。
这意味着:服务器接收客户端连接,接收到客户端通信端口的句柄,但仍然在调用NtAlpcSendWaitReceivePort
时使用服务器的连接端口句柄,以便从所有连接的客户端发送和接收消息。
这是否意味着服务器的通信端口过时了?也不是。
服务器的每个客户端通信端口由操作系统在内部使用,将由特定客户端发送的消息绑定到该客户端的特定通信端口。操作系统会将一个特殊的上下文结构绑定到每个客户端通信端口,用于标识客户端。这个特殊的上下文结构是PortContext,它在接受连接时分配给这个客户端。
这意味着:当服务器监听它的连接端口时,它接收来自所有客户端的消息。如果它想知道哪个客户端发送消息,服务器可以获取它分配给的端口上下文结构来判断。
我们可以得出结论,服务器的每个客户端通信端口对于操作系统仍然很重要,并且在 ALPC 通信结构中仍然具有其位置和作用。但是,这并不能回答为什么服务器实际上需要每个客户端通信端口的句柄的问题(因为可以从使用连接端口句柄接收的消息中提取客户端的PortContext )。
答案便是模拟。当服务器想要模拟客户端时,它需要将客户端的通信端口传递给NtAlpcImpersonateClientOfPort. 这样做的原因是执行模拟所需的安全上下文信息被绑定(如果客户端允许)到客户端的通信端口。将这些信息附加到连接端口是没有意义的,因为所有客户端都使用此连接端口,而每个客户端都有自己的每个服务器的唯一通信端口。因此:如果您想模拟您的客户端,您需要保留每个客户的通信端口句柄。
.......好了,OK。越说越远了,让我们回过头来。
回头看看上面的消息流,我们可以看出客户端和服务器正在使用各种函数调用来创建ALPC端口,然后通过单个函数NtAlpcSendWaitReceivePort
发送和接收消息。虽然这包含了大量关于消息流的信息,但重要的是要始终注意服务器和客户端没有直接的点对点连接,而是通过内核来转发所有消息,内核负责将消息放置在消息队列中,通知接收到的消息的每一方,以及验证消息和消息属性等其他事情
ALPC 消息传递细节
一条 ALPC 消息总是由一个所谓的PORT_HEADER或PORT_MESSAGE 组成,后面跟着你想要发送的实际消息,例如一些文本、二进制内容或任何其他内容。
发送消息代码示例:
使用以下两个结构定义 ALPC 消息:
typedef struct _ALPC_MESSAGE { PORT_MESSAGE PortHeader; BYTE PortMessage[100]; // 使用大小为100的字节数组来存储实际的消息 } ALPC_MESSAGE, * PALPC_MESSAGE; typedef struct _PORT_MESSAGE { union { struct { USHORT DataLength; USHORT TotalLength; } s1; ULONG Length; } u1; union { struct { USHORT Type; USHORT DataInfoOffset; } s2; ULONG ZeroInit; } u2; union { CLIENT_ID ClientId; double DoNotUseThisField; }; ULONG MessageId; union { SIZE_T ClientViewSize; ULONG CallbackId; }; } PORT_MESSAGE, * PPORT_MESSAGE;
发送消息:
// 指定消息结构并清空它 ALPC_MESSAGE pmSend, pmReceived; RtlSecureZeroMemory(&pmSend, sizeof(pmSend)); RtlSecureZeroMemory(&pmReceived, sizeof(pmReceived)); // 获取指向消息数组的指针 LPVOID lpPortMessage = pmSend->PortMessage; LPCSTR lpMessage = "Hello World!"; int lMsgLen = strlen(lpMessage); // 将消息复制到消息字节数组中 memmove(lpPortMessage, messageContent, lMsgLen); // 指定消息的长度 pMessage->PortHeader.u1.s1.DataLength = lMsgLen; // 指定ALPC消息的总长度 pMessage->PortHeader.u1.s1.TotalLength = sizeof(PORT_MESSAGE) + lMsgLen; // 发送ALPC消息 NTSTATUS lSuccess = NtAlpcSendWaitReceivePort( hCommunicationPort, // 客户端通信端口句柄 ALPC_MSGFLG_SYNC_REQUEST, // 消息标志:同步消息(发送和接收消息) (PPORT_MESSAGE)&pmSend, // ALPC消息 NULL, // 发送消息属性 (PPORT_MESSAGE)&pmReceived, // ALPC消息缓冲区接收消息 &ulReceivedSize, // 接收消息的大小 NULL, // 接收消息属性 0 // Timeout参数,0表示不想超时 );
此代码段将发送一条“Hello World!”的 ALPC 消息到我们连接的服务器。我们将消息指定为带有ALPC_MSGFLG_SYNC_REQUEST
标志的同步消息,这意味着该调用将等待(阻塞),直到在客户端的通信端口上接收到消息。
当然,我们不必等到有新消息进来,而是将在那之前的时间用于其他任务(记住 ALPC 被设计为异步、快速和高效的)。为了实现这一点,ALPC提供了三种不同的消息类型:
-
同步请求:如上所述,同步消息阻塞,直到有新消息进入(所以在使用同步消息调用
NtAlpcSendWaitReceivePort
时必须指定一个接收ALPC消息缓冲区) -
异步请求:异步发送消息,不用等待或处理任何收到的消息
-
数据报请求:数据报请求类似于UDP包,它们不期待应答,因此在发送数据报请求时,内核不会阻塞等待接收到的消息
因此,基本上你可以选择发送一条期待回复的消息或不期待回复的消息,当你选择前者时,你可以进一步选择等待,直到回复到来,或者不等待,并在此期间利用宝贵的CPU时间做其他事情。与此同时。如果选择了最后一个选项而不是在NtAlpcSendWaitReceivePort
函数调用中等待(异步请求),那么将面临如何接收回复的问题?
同样也有3个选择:
-
你可以使用ALPC完成列表,在这种情况下,内核不会通知你(作为接收方)收到了新数据,而是简单地将数据复制到进程内存中。由你(作为接收方)来意识到这个新数据的存在。例如,这可以通过使用在你和ALPC服务器之间共享的通知事件来实现,一旦服务器发出事件信号,你就知道新数据已经到达
-
你可以使用 I/O 完成端口
-
你可以接收一个内核回调来获取回复 - 但仅当你的进程位于内核领域时才能这样做
由于你可以选择不直接接收消息,因此不太可能有多个消息传入并等待获取。为了处理不同状态下的多个消息,ALPC使用队列来处理和管理堆积在服务器上的大量消息。有五个不同的消息队列:
-
主队列:消息已发送,客户端正在处理它。
-
待处理队列:消息已发送,调用者正在等待回复,但尚未发送回复。
-
大消息队列:消息已发送,但调用者的缓冲区太小而无法接收。调用者有另一个机会分配更大的缓冲区并再次请求消息。
-
已取消队列:已发送到端口但此后已被取消的消息。
-
直接队列:发送时附带事件的消息。
最后,关于 ALPC 的消息传递细节,还有最后一件事还没有详细说明,那就是消息如何从客户端传输到服务器的问题。前面我们已经提到可以发送什么样的消息,消息的结构是什么样的,存在什么机制来同步和停止消息,但到目前为止还没有详细说明消息是如何从一个进程到另一个进程的。你有两个选择:
-
双缓冲机制:这种方法在发送方和接收方的(虚拟)内存空间中分配一个消息缓冲区,然后将消息从发送方的(虚拟)内存复制到内核的(虚拟)内存中,再从内核的(虚拟)内存复制到接收方的(虚拟)内存中。它被称为双缓冲区,因为包含消息的缓冲区被分配和复制两次(发送者 -> 内核 & 内核 -> 接收者)。
-
内存映射机制:除了分配缓冲区来存储消息,客户端和服务器也可以分配一个共享内存段,它可以被双方访问,映射该段的一个视图,复制消息到映射视图,并最终将该视图作为消息属性发送给接收者。接收方可以通过消息属性提取一个指向发送方使用的同一视图的指针,并从该视图读取数据。
使用'内存映射机制'的主要原因是为了发送较大的消息,因为通过' 双缓冲机制'发送的消息长度有一个硬编码的大小限制,即65535字节。如果在消息缓冲区中超过此限制,则会引发错误。函数AlpcMaxAllowedMessageLength()
可用于获取最大消息缓冲区大小,这在未来的Windows版本中可能会改变。
ALPC 消息属性
在发送和接收消息时,通过NtAlpcSendWaitReceivePort
,客户端和服务器都可以指定一组他们想要发送和/或接收的属性。想要发送的这些属性集和想要接收的属性集在NtAlpcSendWaitReceivePort
两个额外参数中指定
NTSTATUS NTAPI NtAlpcSendWaitReceivePort( _In_ HANDLE PortHandle, _In_ ULONG Flags, _In_reads_bytes_opt_(SendMessage->u1.s1.TotalLength) PPORT_MESSAGE SendMessage, _Inout_opt_ PALPC_MESSAGE_ATTRIBUTES SendMessageAttributes, //发送消息属性 _Out_writes_bytes_to_opt_ *,*BufferLength PPORT_MESSAGE ReceiveMessage, _Inout_opt_ PSIZE_T BufferLength, _Inout_opt_ PALPC_MESSAGE_ATTRIBUTES ReceiveMessageAttributes, // 接收消息属性 _In_opt_ PLARGE_INTEGER Timeout )
可以发送或接收以下消息属性:
安全属性:安全属性包含安全上下文信息,例如可以用来模拟消息的发送者。此信息由内核控制和验证。该属性的结构如下:
typedef struct _ALPC_SECURITY_ATTR { ULONG Flags; PSECURITY_QUALITY_OF_SERVICE pQOS; HANDLE ContextHandle; } ALPC_SECURITY_ATTR, * PALPC_SECURITY_ATTR;
视图属性:此属性用于传递指向共享内存部分的指针,接收方可以使用该指针从该内存部分读取数据。该属性的结构如下:
typedef struct _ALPC_DATA_VIEW_ATTR { ULONG Flags; HANDLE SectionHandle; PVOID ViewBase; SIZE_T ViewSize; } ALPC_DATA_VIEW_ATTR, * PALPC_DATA_VIEW_ATTR;
上下文属性:上下文属性存储指向已分配给特定客户端(通信端口)或特定消息的用户指定上下文结构的指针。上下文结构可以是任何任意结构,例如唯一编号,用于标识客户端。服务器可以提取和引用端口结构,以唯一地标识发送消息的客户端。此消息属性总是可以由消息的接收方提取,发送方不必指定此属性,也不能阻止接收方访问此属性。该属性的结构如下:
typedef struct _ALPC_CONTEXT_ATTR { PVOID PortContext; PVOID MessageContext; ULONG Sequence; ULONG MessageId; ULONG CallbackId; } ALPC_CONTEXT_ATTR, * PALPC_CONTEXT_ATTR;
句柄属性:句柄属性可用于将句柄传递给特定对象,例如文件。接收者可以使用这个句柄来引用对象,例如在一个ReadFile调用中。内核将验证传递的句柄是否有效,否则将引发错误。该属性的结构如下:
typedef struct _ALPC_MESSAGE_HANDLE_INFORMATION { ULONG Index; ULONG Flags; ULONG Handle; ULONG ObjectType; ACCESS_MASK GrantedAccess; } ALPC_MESSAGE_HANDLE_INFORMATION, * PALPC_MESSAGE_HANDLE_INFORMATION;
令牌属性:令牌属性可用于传递有关发送方的令牌信息。该属性的结构如下:
typedef struct _ALPC_TOKEN_ATTR { ULONGLONG TokenId; ULONGLONG AuthenticationId; ULONGLONG ModifiedId; } ALPC_TOKEN_ATTR, * PALPC_TOKEN_ATTR;
直接属性:直接属性可用于将创建的事件与消息关联起来。接收方可以检索发送方创建的事件并发出信号,让发送方知道已接收到发送消息(这对数据报请求特别有用)。该属性的结构如下:
typedef struct _ALPC_DIRECT_ATTR { HANDLE Event; } ALPC_DIRECT_ATTR, * PALPC_DIRECT_ATTR;
Attack
确定目标
关于如何识别,通常有三种途径:
-
识别ALPC端口对象,然后通过端口找到所属的进程
-
检查进程是否使用了ALPC
-
使用Windows事件跟踪(ETW)来列出ALPC事件
查找 ALPC 端口对象
识别ALPC端口对象的最直接的方法,那就是启动WinObj并通过“类型”列找到ALPC对象。
WinObj不能给我们更多的细节,所以我们转向WinDbg内核调试器来检查这个ALPC端口对象。如果在看文章跟着做的您不是学员,这是你第一次使用 WinDbg的话(或者你像我一样容易忘记某些命令的含义),你可以随时使用 WinDbg 的帮助菜单kd:> .hh
在上述命令中,我们使用 Windbg 的!object命令在对象管理器中查询指定路径中的命名对象。这已经隐含地告诉我们只能使用WinObj来查找ALPC 连接端口的ALPC 服务器进程。
说到服务器进程:如上所示,你可以使用WinDbg没有记录的!alpc命令来显示我们刚刚标识的alpc端口的信息。输出包括非常多的有用信息,例如端口的所属服务器进程,在本例中为svchost.exe。
alpc用法:
现在我们知道了ALPC Port对象的地址,我们可以再次使用! ALPC命令来显示这个ALPC连接端口的活动连接:
此外,还可以愉快的通过googleprojectzero提供的脚本来搜索 ALPC 端口对象
查找使用ALPC的进程
与之前方法类似,可以使用dumpbin.exe实用程序列出可执行文件的导入函数,并在其中搜索特定于ALPC的函数调用。
以下两个 PowerShell 可用于查找创建或连接到 ALPC 端口的.exe和.dll文件:
## Get ALPC Server processes (those that create an ALPC port) Get-ChildItem -Path "C:\Windows\System32\" -Include ('*.exe', '*.dll') -Recurse -ErrorAction SilentlyContinue | % { $out=$(C:\"Program Files (x86)"\"Microsoft Visual Studio 14.0"\VC\bin\dumpbin.exe /IMPORTS:ntdll.dll $_.VersionInfo.FileName); If($out -like "*NtAlpcCreatePort*"){ Write-Host "[+] Executable creating ALPC Port: $($_.VersionInfo.FileName)"; Write-Output "[+] $($_.VersionInfo.FileName)`n`n $($out|%{"$_`n"})" | Out-File -FilePath NtAlpcCreatePort.txt -Append } } ## Get ALPC client processes (those that connect to an ALPC port) Get-ChildItem -Path "C:\Windows\System32\" -Include ('*.exe', '*.dll') -Recurse -ErrorAction SilentlyContinue | % { $out=$(C:\"Program Files (x86)"\"Microsoft Visual Studio 14.0"\VC\bin\dumpbin.exe /IMPORTS:ntdll.dll $_.VersionInfo.FileName); If($out -like "*NtAlpcConnectPor*"){ Write-Host "[+] Executable connecting to ALPC Port: $($_.VersionInfo.FileName)"; Write-Output "[+] $($_.VersionInfo.FileName)`n`n $($out|%{"$_`n"})" | Out-File -FilePath NtAlpcConnectPort.txt -Append } }
可以附加到正在运行的进程,查询该进程的打开句柄并过滤指向 ALPC 端口的句柄。
#windbg !handle 0 2 0 ALPC Port
使用processhacker也可以
使用 Windows 事件跟踪
虽然ALPC没有文档记录,但有一些ALPC事件被Windows公开了,可以通过Windows事件跟踪(ETW)捕获这些事件。帮助处理ALPC事件的好工具是由zodiacon开发的ProcMonXv2
模拟
这个不用多讲了,和之前一样意思
未释放的消息对象
如ALPC 消息属性部分所述,客户端或服务器可以随消息一起发送多个消息属性。其中一个属性是ALPC_DATA_VIEW_ATTR,可用于向通信的另一方发送关于映射视图的信息。这可以用于在共享视图中存储较大的消息或数据,并将该共享视图的句柄发送给另一方,而不是使用双缓冲区消息传递机制将数据从一个内存空间复制到另一个内存空间。
这里有趣的一点是,当在ALPC_DATA_VIEW_ATTR属性中被引用时,共享视图(或称为节)被映射到接收方的进程空间。然后接收者就可以对这个部分做一些事情,但是最后,消息的接收方必须确保映射视图从它自己的内存空间中释放出来,这需要一定数量的步骤,而这些步骤有可能无法正确地执行。那么如果接收者未能释放一个映射视图,更或者是它从一开始就没有期望接收到一个视图,那么发送者可以发送越来越多的带有任意数据的视图,以用任意数据的视图填充接收者的内存空间,这就变成了堆喷射攻击。
最后
ALPC 未记录且相当复杂,但作为一种好处是:ALPC 内部的漏洞利用可能变得非常强大,因为 ALPC 在 Windows 操作系统中无处不在,所有内置的高特权进程都使用 ALPC。我们在未来还会在很多地方接触到它
参考:
LPC、RPC 和ALPC演讲https://youtu.be/UNpL5csYC1E
深入解析Windows操作系统卷2https://item.jd.com/13094235.html
processhackerhttps://processhacker.sourceforge.io/doc/ntlpcapi_8h.html