0x00 前言
在研究IO管理器(IO Manager)中的访问模式不匹配问题时,我发现了命名管道中的一个有趣特性,服务端可以利用该特性查询已连接的客户端PID。该特性从Vista开始引入,可以通过GetNamedPipeClientProcessId API来使用,我们只要向该API传入管道服务端的句柄(handle),就能拿到已连接客户端的PID信息。
在Windows中,肯定有一些应用会出于安全考虑来使用客户端PID,然而我并没有找到任何已安装的官方应用会根据PID来执行安全检查操作。对于第三方应用,情况有所不同,有研究人员发现有些第三方应用(比如Check Point Anti-Virus)会使用PID来避免不受信任的调用方访问特权操作。由于对PID的依赖本身就是比较危险的一个处理逻辑,因此这里我决定重点关注如何伪造PID值,这样就能避免开发者将其作为一种安全机制,同时也会给大家演示这种技术的利用场景。
在如下C#代码所示,我们可以利用客户端PID来执行安全检查。这段代码会创建一个命名管道服务端,等待新的连接,然后调用GetNamedPipeClientProcessId
API。如果API调用成功,那么就会继续调用SecurityCheck
来验证PID。如果SecurityCheck
返回true
,那么就会正常处理客户端发起的调用。
using (var pipe = new NamedPipeServerStream("ABC"))
{
pipe.WaitForConnection();
if (!GetNamedPipeClientProcessId(pipe.SafePipeHandle, out int pid))
{
Console.WriteLine("Error getting PID");
return;
}
else
{
Console.WriteLine("Connection from PID: {0}", pid);
if (SecurityCheck(pid))
{
HandleClient(pipe);
}
}
}
SecurityCheck
的处理逻辑并不是本文的关注重点。比如,服务端可能通过PID来打开该进程,查询主执行文件,然后对该文件执行签名校验。这里比较关键的是客户端能否伪造PID值,将GetNamedPipeClientProcessId
的返回值指向其他进程,这样就能绕过安全检查,利用目标服务。
0x01 PID来源
在介绍PID伪造技术之前,我们先来了解一下为什么能通过调用GetNamedPipeClientProcessId
来拿到PID值。当建立新的客户端连接时,命名管道文件系统驱动(NPFS)会设置PID值。对于Windows 10而言,该过程由NpCreateClientEnd
函数负责处理,该函数内部的实现大致如下所示:
NTSTATUS NpCreateClientEnd(PFILE_OBJECT ServerPipe,
KPROCESSOR_MODE AccessMode, PFILE_FULL_EA_INFORMATION EaBuffer) {
// ...
if (!EaBuffer) {
DWORD value = PsGetThreadProcessId();
NpSetAttributeInList(ServerPipe, PIPE_ATTRIBUTE_PID, &value);
value = PsGetThreadSessionId();
NpSetAttributeInList(ServerPipe, PIPE_ATTRIBUTE_SID, &value);
} else {
if (AccessMode != KernelMode)
return STATUS_ACCESS_DENIED;
LPWSTR computer_name;
NpLocateEa(EaBuffer, "ClientComputerName", &computer_name);
NpSetAttributeInList(ServerPipe, PIPE_ATTRIBUTE_NAME, computer_name);
DWORD value;
NpLocateEa(EaBuffer, "ClientProcessId", &value);
NpSetAttributeInList(ServerPipe, PIPE_ATTRIBUTE_PID, &value);
NpLocateEa(EaBuffer, "ClientSessionId", &value);
NpSetAttributeInList(ServerPipe, PIPE_ATTRIBUTE_SID, &value);
}
// ...
}
系统会通过NpSetAttributeInList
函数来设置PID值(以及相关的会话ID和计算机名),这些值存放在属性列表中,我们可以通过未公开的FSCTL_PIPE_GET_CONNECTION_ATTRIBUTE
代码,向服务端命名管道发送File System Control(文件系统控制)请求来获取这些值。
设置这些属性时可以采用两种方式。第一种方式,如果文件创建请求中没有指定Extended Attribute(EA)缓冲区,那么PID及会话ID会从当前进程获取。在进程中创建客户端连接时这是非常正常的一个操作。本地SMB服务端会使用第二种方式,设置EA缓冲区后,驱动程序就可以允许SMB服务端指定连接信息(比如客户端的计算机名以及其他PID和会话ID)。由于正常用户模式下的进程也可以指定任意的EA缓冲区,因此代码还会检查该操作是否由内核模式所发起。这种模式检查机制应该可以避免普通用户模式下的应用程序伪造相关值。
0x02 伪造技术
在了解PID的设置过程后,我们来研究一下伪造PID值的一些技术。这里每种技术在使用时都有些优缺点,后面我们会详细解释。我已经在Windows 10 1903上验证过这些技术,如果没有额外说明,这些技术应该也适用于较低版本。
通过本地SMB以及NTFS挂载点打开管道
我在分析IO管理器的前一篇文章中提到过,如果我们能找到合适的启动器,将上一次访问模式设置为KernelMode
,那么就能绕过NPFS为防止伪造连接属性所部署的检查机制。在CVE-2018-0749被修复之前,我们有可能任意设置本地NTFS挂载点,将所有本地SMB请求重定向到任何设备(包括NPFS)。而在正常情况下,如果文件已经处于直接打开状态,我们无法完成该操作,因为内核会拒接链接到非卷(non-volume)类型的目标。由于SMB文件打开请求同样可以指定任意EA缓冲区,因此通过这种方式,本地客户端可以随意伪造相关值(包括PID)来打开命名管道连接。
如果CVE-2018-0749已经被修复,那么从技术角度来看我们再也无法利用这种方式。不幸的是,从Windows 10 1709开始,内核在NTFS挂载点目标处理上有所变化,可以允许重解析到命名管道设备以及更加传统的文件系统卷上,因此我们还是有可能使用本地SMB服务端、挂载点以及合适的EA缓冲区来伪造任意PID。在如下C#示例代码中,在打开ABC
命名管道时,我们可以将客户端PID伪造为1234
。这里我们需要引用我开发的NtApiDotNet库来使用某些类型。
EaBuffer ea = new EaBuffer();
ea.AddEntry("ClientComputerName", "FAKE\0", EaBufferEntryFlags.None);
ea.AddEntry("ClientProcessId", 1234, EaBufferEntryFlags.None);
ea.AddEntry("ClientSessionId", new byte[8], EaBufferEntryFlags.None);
using (var m = NtFile.Create(@"\??\c:\pipes", null,
FileAccessRights.GenericWrite | FileAccessRights.Delete,
FileAttributes.Normal, FileShareMode.All,
FileOpenOptions.DirectoryFile | FileOpenOptions.DeleteOnClose,
FileDisposition.Create, null))
{
m.SetMountPoint(@"\??\pipe", "");
using (var p = NtFile.Create(@"\??\UNC\localhost\c$\pipes\ABC",
FileAccessRights.MaximumAllowed,
FileShareMode.None,
FileOpenOptions.None, FileDisposition.Open,
ea))
{
Console.WriteLine("Opened Pipe");
}
}
通过这种技术,我们还可以利用前文提到的第一种方法在NPFS中设置PID,此时如果没有设置EA缓冲区,那么就会使用当前的PID值。由于SMB服务端运行在System
进程中,因此我们可以将客户端PID值伪装成4
。由于我们已经可以将PID设置成任意值时,也可以不选择4
这个值。
优点:
- 可以伪造任意PID(如果需要的话,还可以伪造会话ID及计算机名)
缺点:
- 需要依赖挂载点,访问本地SMB服务端,因此无法从沙箱中利用
- 只适用于Windows 10 1709及以上系统
通过本地SMB打开管道
如果我们运行的系统版本早于Windows 10 1709,那么并非完全没有希望。大家可能会认为,如果使用正确的方法,通过本地SMB服务端打开命名管道(比如访问\\localhost\pipe\ABC
路径),那么SMB服务端就不会设置PID属性。快速查看服务器驱动后,我们发现服务端其实会设置这个值,更准确一点,服务端会将其设置为固定的一个值。在Windows 10 1903上,这个值为65279
(即0xFEFF
)。
这个固定值来自于SMB2协议头,该头部由客户端发送。微软公开了这个头部结构,但在官方文档中提到,包含PID值的这个字段为“保留字段(4字节):客户端应当将该字段设置为0
。服务端在接收数据时可能会忽略掉这个字段”。幸运的是,Wireshark提供的文档更有参考价值,该文档中指出这个字段为PID值,默认为0xFEFF
。我们可以在打开命名管道时,通过Wireshark来抓包,查看SMB流量,提取这个固定值,如下所示:
由于服务端似乎并不会检查这个值,因此我们可以将其设置为任意值。然而内置的Windows客户端并不允许我们将该字段改成除0xFEFF
的其他值,我们能否在不开发自定义SMB2客户端的基础上来修改该字段值,或者是否可以使用现有的解决方案(如IMPacket)?由于Windows系统会复用PID值,因此我们可以滥用这一点,创建匹配正确PID的进程,这样就能满足安全检查要求。
需要注意的是,我们无法创建PID为65279
的进程,因为在当前所有版本的Windows中,都会将PID按4的倍数对齐。如果服务端使用65279
来调用OpenProcess,那么就会向下取整,打开PID为65276
的进程,因此我们可以利用这个PID值。此外还需注意的是,线程ID与PID共享同一个分配池,因此如果我们运气不好,可能会创建ID匹配的线程。枚举PID可能需要很长时间,现代Windows系统采用了半随机的PID分配模式,因此这个过程可能会更加困难,但至少这是一种可行的方法。
循环枚举PID的示例代码如下所示:
while (true)
{
using (var p = Process.Start("target.exe"))
{
if (p.Id == 65276)
{
break;
}
p.Kill();
}
}
一旦成功创建ID为65276
的进程,我们就可以通过SMB服务端连接命名管道,如果服务端打开PID,就会碰到我们伪造的进程。
优点:
- 适用于所有版本的Windows
- 如果我们重新实现SMB2协议,还可以任意伪造PID值
缺点:
- 需要访问本地SMB服务端,因此无法在沙箱中利用。即使我们重新实现了客户端,也不一定能在App Container中访问
localhost
,或者无法能够拿到正确的认证凭据 - 只有服务端在安全检查机制中使用
OpenProcess
来处理PID,并且没有将其与正在运行的PID号进行比较时才能使用这种方法 - 进程需要分配到正确的ID才能绕过服务端安全检查,这个过程可能非常缓慢或者非常困难
通过不同的进程打开及使用管道
当客户端连接处于打开状态时,PID值便会被固定下来,并且读取管道和写入管道的进程PID值可以不同。因此我们可以利用这一点,在一个进程中创建管道客户端,启动一个新的子进程,将句柄复制到该子进程中。如果打开管道的进程结束运行,那么对应的PID就会被释放,此时我们又可以再次执行PID轮询攻击。一旦成功复用PID,那么子进程就可以按照我们的需求来执行管道操作。打开管道的代码如下所示,这段C#代码中进程创建过程中采用了Inheritable
模式,因此可以传递管道句柄:
using (var pipe = new NamedPipeClientStream(".", "ABC",
PipeAccessRights.ReadWrite,
PipeOptions.None, TokenImpersonationLevel.Impersonation,
HandleInheritability.Inheritable))
{
int pid = Process.GetCurrentProcess().Id;
IntPtr handle = pipe.SafePipeHandle.DangerousGetHandle();
ProcessStartInfo start_info = new ProcessStartInfo();
start_info.FileName = "program.exe";
start_info.Arguments = $"{handle} {pid}";
start_info.UseShellExecute = false;
Process.Start(start_info);
}
然后在子进程中,采用如下代码等待父进程退出,再循环枚举PID,直到获取合适的PID,然后再使用管道:
int ppid = int.Parse(args[1]);
Process.GetProcessById(ppid).WaitForExit();
RecycleProcessId(ppid);
var handle = new SafePipeHandle(new IntPtr(int.Parse(args[0])), true);
using (var pipe = new NamedPipeClientStream(PipeDirection.InOut,
false, true, handle))
{
pipe.WriteByte(0);
}
这种方法有个比较大的问题,需要依赖于服务在哪个位置检查PID。如果在创建连接后立刻检查,那么我们可能没有足够的时间来回收PID。然而,如果在向管道发送请求后才执行检查(比如向管道写入数据),那么我们可以推迟检查操作,直到我们成功获取所需的PID。
与SMB服务端设置的固定值不同,我们有可能使用不同的PID来创建多个不同的连接,最大化提高获取正确PID值的概率。至于我们可以创建多少个连接,需要取决于服务端支持多少个并发管道实例。
优点:
- 适用于所有版本的Windows
- 如果能在沙箱中打开命名管道,那么应该也可以使用这种方法
- 可能创建不同PID的多个管道,最大化提高PID循环成功率
缺点:
- 服务端不能在连接建立后立刻检查PID,相反需要在我们执行初始读/写操作后再检查,因此这限制了我们可以利用的服务数量
0x03 总结
如果某个服务在安全机制中依赖于命名管道客户端PID,那么我们可以通过这些方式来攻击该服务,可以根据具体情况选择其中一种方案。即使攻击者无法任意伪造PID值,服务端也不能依赖于PID来部署安全机制,因为PID并不一定对应实际操作的客户端,仅仅对应打开该管道的进程而已。
发表评论
您还未登录,请先登录。
登录