命名管道实务 ── 从设计到安全的 Windows 标准 IPC

· · Windows, 进程间通信, Windows 开发, C#, C++, 安全性, Win32 API

「希望常驻服务和设置界面交换命令。」「希望只把需要管理员权限的工作隔离到另一个进程。」「希望同一台 PC 上的工具互相传递数据。」——在 Windows 上需要这类进程间通信(IPC)时,应首先考虑的标准方案就是命名管道

Windows 进程间通信选型的文章把命名管道定位为「同一机器 IPC 的第一候选」。本文是各论。它们为何是第一候选、如何选择模式和服务器形态、特权服务使用时必须守住什么——面向在 Windows 上编写业务应用和服务的开发者,根据一手资料整理这些设计判断的材料。

1. 先说结论

  • 命名管道是带有 \\.\pipe\name 形式命名空间的双向进程间通道。可以在同一名称下创建多个实例,同时接受多个客户端。1
  • 它成为同一机器 IPC 第一候选的原因是安全模型。可以用 ACL 控制谁来连接,服务器可以检查并借用(模拟)客户端的 Windows 账户。localhost TCP 两者都没有。2
  • 若希望按「一次写入=一条消息」处理,用消息模式;若已有自成帧,用字节模式。即便在消息模式下,缓冲区过短时的拆分读取(ERROR_MORE_DATA)仍需处理。3
  • 用「多个实例 + 重叠 I/O」或「.NET async/await」处理多个客户端。官方示例展示了单线程处理多个实例的形态。4
  • 安全底线是四点:拒绝远程(PIPE_REJECT_REMOTE_CLIENTS)、明确 ACL、用 FILE_FLAG_FIRST_PIPE_INSTANCE 检测劫持,以及在客户端侧把模拟级别降到最低必要。56
  • ImpersonateNamedPipeClient,检查返回值是生命线。忽略失败,处理就会以服务器权限继续跑。6

2. 命名管道是什么 ── 命名空间、实例与连接方式

命名管道是用 \\.\pipe\MyCompany.MyApp.Control 这类名称标识的通道。服务器用 CreateNamedPipe 创建,客户端用 CreateFile 打开同一名称。打开之后,双方都用 ReadFile / WriteFile 读写——特点是可以用和文件 I/O 相同的形态来使用。1

重要概念是实例。同一名称的管道可以创建多个实例,一个实例就是与一个客户端的一条通道。第一次调用 CreateNamedPipe 时决定最大实例数(或无限制)。3

客户端侧的连接有一套标准做法。当所有实例都在使用时,CreateFile 会以 ERROR_PIPE_BUSY 失败,于是用 WaitNamedPipe 等待空闲后再重试。另外,打开时指定的访问必须与服务器创建时的方向一致——双向管道可以用读或写任一方式打开,但服务器只写的出站管道必须只读打开,服务器只读的入站管道必须只写打开,否则 CreateFile 会失败。7

管道方向与客户端的访问指定客户端可以用读或写打开双向管道,但必须把服务器只写的出站管道以只读打开,把服务器只读的入站管道以只写打开双向出站入站服务器创建的方向?读或写都可以以只读打开以只写打开

图 1: 方向与访问指定不一致会变成 CreateFile 失败。调查连接错误时先看这里。

命名管道的基本结构服务器创建同名的多个管道实例并用 ConnectNamedPipe 等待连接;各客户端用 CreateFile 打开该名称,与一个实例形成一对一双向通道服务器实例 1实例 2实例 3客户端 A客户端 B客户端 C

图 2: 持有同一名称的多个实例,一台服务器就能同时与多个客户端一对一通信。

命名管道也可以经 SMB 远程打开(\\server\pipe\name),但在现代设计中几乎没有主动使用的理由;问题反而是不用时不要敞着(第 5 章)。

3. 字节模式与消息模式

管道有两种传输模式。3

  • 字节模式(PIPE_TYPE_BYTE):和 TCP 一样的「没有断点的字节流」。由你自己决定一条消息在哪里结束(自己设计长度前缀等成帧)。
  • 消息模式(PIPE_TYPE_MESSAGE + PIPE_READMODE_MESSAGE):一次写入被当作一条消息,读取方按该单位接收。这对请求/响应交换更轻松。

消息模式还有一个方便的搭档 TransactNamedPipe,一次调用就能发送请求并接收响应。8 但有陷阱。若接收缓冲区小于整条消息,读取会返回 ERROR_MORE_DATA,变成拆分读取。不要以为消息模式就等于「一次 Read 总能拿到全部」;仍需写循环去读剩余部分。注意读取模式是按句柄设置的,CreateNamedPipe 只在服务器一侧决定它。客户端在 CreateFile 之后用 SetNamedPipeHandleState 指定(在 .NET 中是连接后的 ReadMode)。7

消息模式的拆分读取循环若 ReadFile 成功则消息完整;若返回 ERROR_MORE_DATA 则读取未装入缓冲区的剩余部分并拼接;其他错误视为断开成功ERROR_MORE_DATA其他错误用 ReadFile 读取结果?消息完整读取剩余部分并拼接视为断开

图 3: 即便在消息模式下也需要「读剩余循环」;没有它,只有大消息会坏。

字节模式与消息模式的差异字节模式下三次写入变成没有断点的字节流,接收方必须自行拆分;消息模式保留每次写入的单位,原样到达接收方字节模式:写入 AAA、BB、CCCC作为字节流 AAABBCCCC 接收自己设计成帧消息模式:同样三次写入作为三条消息接收:AAA、BB、CCCC写入单位被保留

图 4: 消息模式保留「一次写入的单位」并送达。成帧设计变得不必要;只是别忘了处理拆分读取。

实务上怎么选,规则很简单。若交换形态是「请求与响应」,用消息模式若承载的形式本身已内建成帧(带长度前缀的序列化数据或流传输),用字节模式。在 .NET 中,指定 PipeTransmissionMode.Message 对应前者。9

4. 服务器设计 ── 每客户端一条线程,还是重叠 I/O?

服务器的基本操作是循环「创建实例 → 用 ConnectNamedPipe 等待客户端 → 读写 → 断开并接下一个客户端」。同时与多个客户端通信有两种形态。

同步,每个实例一条线程。给每个实例分配一条线程,各自用同步 I/O 与自己的客户端通信。代码直观,但每个客户端消耗一条线程,整体关闭时还需要一种办法打断阻塞 I/O。

重叠(异步)。FILE_FLAG_OVERLAPPED 创建实例,异步发出 ConnectNamedPipe / ReadFile / WriteFile,用少量线程处理所有实例的完成。Microsoft 官方示例展示了一台用 WaitForMultipleObjects 等待事件数组、在单线程上处理多个实例的服务器。4 异步 I/O 的一般说明见I/O 系列文章,更大规模时也可以挂上 IOCP 或线程池 I/O。

重叠服务器的结构各实例异步操作的完成由事件数组接收,少量线程用 WaitForMultipleObjects 等待并推进已完成的实例,使线程数与客户端数解耦实例 1 的异步操作事件数组实例 2 的异步操作实例 3 的异步操作用 WaitForMultipleObjects 等待完成推进已完成的实例

图 5: 重叠形态把线程数与客户端数解耦。官方示例用单线程转动这个循环。

.NET 几乎消掉了这一选择。NamedPipeServerStreamWaitForConnectionAsync / ReadAsync / WriteAsyncasync/await 一起用,就能以接近同步形态的直观代码获得重叠的效率。9

// C#: skeleton of a server that accepts multiple clients
while (!token.IsCancellationRequested)
{
    var server = new NamedPipeServerStream(
        "MyCompany.MyApp.Control",
        PipeDirection.InOut,
        NamedPipeServerStream.MaxAllowedServerInstances,
        PipeTransmissionMode.Message,
        PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

    try
    {
        await server.WaitForConnectionAsync(token);
    }
    catch
    {
        await server.DisposeAsync();        // dispose yourself when leaving before a connection
        throw;
    }
    _ = HandleClientAsync(server, token);   // ownership after connect goes to the handler
}

PipeOptions.CurrentUserOnly 是「只允许同一用户的进程连接」的指定,是省去自己写 ACL 的方便且安全的默认。10 它不能用于跨用户的配置(服务 ↔ 用户会话中的应用等),那种情况要进入下一章的 ACL 设计。

.NET 异步服务器的接受循环接受循环创建 NamedPipeServerStream,用 WaitForConnectionAsync 等待连接,到达后异步拆出客户端处理并立即回到下一次接受,从而用直观代码处理并发连接创建服务器流用 WaitForConnectionAsync 等待连接到达异步拆出客户端处理

图 6: 接受循环坚持「等待 → 拆出 → 下一个」的循环,各客户端的处理并行推进。

5. 安全 ── 特权服务使用管道时的四件必须做的事

命名管道成为同一机器 IPC 第一候选的最大原因是安全模型,但前提是配置正确。尤其在「管理员权限服务 + 低权限 UI 应用」这种代理(broker)设计里,管道本身就是权限边界。要钉死四点。

(1) 拒绝远程。本意是本地 IPC 的管道却能从网络打开,这本身就是攻击面。在 CreateNamedPipe 上指定 PIPE_REJECT_REMOTE_CLIENTS,远程客户端连接会被自动拒绝。5

(2) 明确 ACL。SECURITY_ATTRIBUTES 中传入安全描述符,收窄允许连接的用户和组。不要给客户端 GENERIC_WRITE——其中包含的 FILE_CREATE_PIPE_INSTANCE 权限会让已授权客户端自己创建同名服务器实例并抢走后续连接。把读和写作为单独权限授予,不要传递实例创建权限。11

(3) 防止名称劫持。管道名称是先到先得。若恶意进程先创建同名管道并等待,客户端就会连上伪服务器。服务器在创建第一个实例时指定 FILE_FLAG_FIRST_PIPE_INSTANCE,保证「我是第一个」,若失败就怀疑劫持并停止。这个标志只用于声明名称的第一个实例;加在第二及以后的实例上会使创建失败。3

(4) 客户端把模拟级别降到所需最低。这是为对方是伪服务器的情况做准备。若客户端在 CreateFile 上指定 **SECURITY_SQOS_PRESENT SECURITY_IDENTIFICATION,服务器可以识别客户端,但不能借用那些权限去行动2 不过这与模拟工作流是权衡——在服务器以客户端权限执行实际访问的代理设计中,识别级别不足以让模拟成功,需要允许 SECURITY_IMPERSONATION。这一允许的条件是确信已连上真正的服务器**。服务器侧的防劫持只是通过启动失败来察觉的机制;若真正的服务不在、攻击者先创建了同名管道,客户端仍可能连上伪服务器。只有能通过有保证的服务启动或连接后的相互认证确认对方时,才允许它。

服务器侧的身份检查与权限借用是 ImpersonateNamedPipeClient。从管道读取请求后调用它,调用线程就开始在最后读取消息的发送者的安全上下文中运行。以客户端权限打开文件,访问检查就针对客户端——这就是特权服务执行「所请求的操作,用请求者的权限」的机制。6 使用它的绝对条件是检查返回值。模拟失败后继续,后续操作就会以服务器自身的高权限运行。官方文档明确写了「失败时不得执行客户端的请求」。连同工作后的 RevertToSelf模拟令牌文章中的做法原样适用。

使用模拟的请求处理流程服务器从管道读取请求,确认 ImpersonateNamedPipeClient 成功后以客户端权限执行操作,再用 RevertToSelf 回到自身上下文。若模拟失败则拒绝请求而不执行服务器客户端服务器客户端失败时拒绝而不执行请求发送请求读取请求ImpersonateNamedPipeClient以客户端权限执行操作用 RevertToSelf 恢复原上下文回复结果

图 7: 确认模拟成功与可靠的 RevertToSelf 是一套。失败后继续就会以服务器权限运行。

保护特权服务管道的四点服务器侧用远程拒绝、明确 ACL 和首实例保证加固入口;客户端侧指定所需最低模拟级别,使伪服务器无法借用权限(若设计不让服务器借用权限,则收窄到识别级别)客户端侧指定最低必要模拟级别服务器侧PIPE_REJECT_REMOTE_CLIENTS用 ACL 限制连接者FIRST_PIPE_INSTANCE(仅首实例)作为权限边界的管道

图 8: 在管道就是权限边界的设计中,把服务器侧三点加上客户端侧一点作为一套来实现。

6. 实务陷阱

启动顺序上的竞态。若客户端在服务器创建管道之前来连接,会得到「管道不存在」错误。客户端侧要内建「不存在 → 稍等再重试」。反过来,服务器侧的原则是在客户端启动之前就开始用 ConnectNamedPipe 监听。8

客户端连接重试流程用 CreateFile 打开管道;若管道不存在则稍等再重试;若所有实例都在使用(ERROR_PIPE_BUSY)则用 WaitNamedPipe 等待空闲后再重试;成功后进入通信成功管道不存在ERROR_PIPE_BUSY用 CreateFile 打开结果?开始通信稍等(服务器未启动)用 WaitNamedPipe 等待空闲实例

图 9: 客户端连接处理区分「不存在」和「已满」两类失败,并把两者都接回重试。

检测断开。对方退出时,Read/Write 会以 ERROR_BROKEN_PIPE 等失败。那不是异常,而是日常通信。服务器检测到断开后对实例执行 DisconnectNamedPipe,为下一次连接做准备;客户端重新连接——睡眠/恢复文章中所说的「幂等重连」思路在这里也适用。

关于消息大小的假设。在消息模式的拆分读取(第 3 章)之上,若协议里不定「一条消息最多多少字节」,恶意(或有缺陷)的对端可以用超大消息浪费你的内存。定一个上限,超出就断开,这才是安全做法。

写入完成与对端收到是两回事。WriteFile 成功并不表示对端应用已经处理了数据。需要确定性的操作,要用响应消息确认、并把请求与响应的对应关系写进协议这类设计来托底。

7. 小结

  • 命名管道是同一机器 IPC 的第一候选。原因是与文件 I/O 相同的易用性,以及与 ACL、模拟这一 Windows 安全模型的整合。
  • 模式选择是「请求与响应用消息模式,已有自成帧则用字节模式」。即便在消息模式下仍需处理拆分读取(ERROR_MORE_DATA)。
  • 多客户端是多个实例 + 重叠,或 .NET async/await。新工作用 .NET 异步形态最直观。
  • 在作为权限边界的管道上,把远程拒绝、明确 ACL、FIRST_PIPE_INSTANCE(仅首实例)以及客户端侧模拟级别最小化作为一套来做。
  • ImpersonateNamedPipeClient,检查返回值和 RevertToSelf 是生命线。
  • 把启动顺序、断开、消息上限、响应确认这些「通信的日常」织进协议设计。

命名管道是旧 API,但对于「在同一台机器上让进程互相交谈、同时尊重 Windows 账户边界」这一用途,它仍是最自然的工具。设计判断点几乎都收在本文范围内。之后,先把自家协议写在一张纸上,再开始实现。

相关文章

相关咨询领域

小村软件有限公司承接涉及进程间通信的设计与实现——例如将服务与 UI 应用分离、隔离管理员权限等——将既有 IPC(共享内存、自研套接字、COM 等)替换为命名管道,以及对特权服务管道通信的安全评审。从协议设计的讨论开始咨询即可。

参考链接

  1. Microsoft Learn, Named Pipes. 关于命名管道是管道服务器与一个或多个管道客户端之间的单向或双向通道;每个实例共享同一名称但拥有独立的缓冲区和句柄;以及可从本地和远程进程使用。  2

  2. Microsoft Learn, Impersonating a Named Pipe Client. 关于模拟让服务器线程在客户端权限范围内操作;默认模拟级别是 SecurityImpersonation;以及客户端可以在 CreateFile 时用 SECURITY_SQOS_PRESENT 标志控制模拟级别(SECURITY_IDENTIFICATION 仅允许识别)。  2

  3. Microsoft Learn, CreateNamedPipeW function (namedpipeapi.h). 关于管道方向(入站、出站、双向)、字节类型与消息类型(PIPE_TYPE_BYTE / PIPE_TYPE_MESSAGE)及读取模式(PIPE_READMODE_MESSAGE)、最大实例数(PIPE_UNLIMITED_INSTANCES)、经 FILE_FLAG_OVERLAPPED 的异步模式、经 FILE_FLAG_FIRST_PIPE_INSTANCE 的首实例保证,以及 WaitNamedPipe 的默认超时。  2 3 4

  4. Microsoft Learn, Named Pipe Server Using Overlapped I/O. 关于用重叠操作处理与多个客户端同时连接的单线程服务器官方示例。关于用 WaitForMultipleObjects 等待各实例的 OVERLAPPED 结构和事件并推进已完成实例的状态机,以及用 GetOverlappedResult 确认挂起 I/O 的完成。  2

  5. Microsoft Learn, CreateNamedPipeW function (namedpipeapi.h). 关于两种远程客户端模式:PIPE_ACCEPT_REMOTE_CLIENTS(接受远程连接并对照安全描述符检查)和 PIPE_REJECT_REMOTE_CLIENTS(自动拒绝远程客户端连接)。  2

  6. Microsoft Learn, ImpersonateNamedPipeClient function (namedpipeapi.h). 关于服务器侧线程开始在从管道读取的最后一条消息的客户端安全上下文中模拟;完成后用 RevertToSelf 返回;以及模拟失败后继续会导致在服务器进程自身(特权)上下文中执行,因此必须始终检查返回值,失败时不得执行客户端的请求。  2 3

  7. Microsoft Learn, Named Pipe Client. 关于客户端用 CreateFile 打开管道;所有实例都在使用时的 ERROR_PIPE_BUSY,用 WaitNamedPipe 等待空闲;以及打开的句柄默认是字节读取、阻塞、非重叠,可用 SetNamedPipeHandleState 改为消息读取模式。  2

  8. Microsoft Learn, Named Pipe Operations. 关于经 ReadFileEx / WriteFileEx 的重叠操作、经 PeekNamedPipe 的不消耗读取、TransactNamedPipe 在消息类型双向管道上一次调用完成请求发送与响应接收,以及客户端启动前的阻塞读取可能造成竞态。  2

  9. Microsoft Learn, How to: Use Named Pipes for Network Interprocess Communication (.NET). 关于用 NamedPipeServerStream / NamedPipeClientStream 连接与读写、经 PipeTransmissionMode.Message 的消息单位传输,以及用异步方法处理多个客户端。  2

  10. Microsoft Learn, PipeOptions Enum (System.IO.Pipes). 关于用 Asynchronous 启用异步 I/O,以及 CurrentUserOnly 可以只允许与同一用户(以及同一提升级别)的进程连接。 

  11. Microsoft Learn, Named Pipe Security and Access Rights. 关于命名管道访问权限的构成;GENERIC_WRITE 包含 FILE_CREATE_PIPE_INSTANCE,因此给客户端通用写入也会允许创建服务器实例;以及数据的读和写应作为单独访问权限授予。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

命名管道和 TCP(localhost 套接字)该怎么选?
同一台机器上的进程间通信,命名管道是第一候选。原因在于安全模型。管道可以用 Windows 安全描述符(ACL)在操作系统层控制「谁可以连接」,服务器还能用 ImpersonateNamedPipeClient 检查并借用连接对方的 Windows 账户。localhost 的 TCP 端口则谁都能连上,必须用自建认证来确认对方是谁。另一方面,若日后很可能发展成远程通信、还要和其他操作系统上的进程对话,或想复用 gRPC 等既有协议资产,则 TCP 方案更有利。这一判断也整理在 Windows 进程间通信选型的文章里。
该用字节模式还是消息模式?
如果希望把「一次写入=一个意义单位」来处理,消息模式(PIPE_TYPE_MESSAGE + PIPE_READMODE_MESSAGE)更方便。接收方可以按发送方写入的单位读取,不必自己管理边界。字节模式和 TCP 一样是「没有断点的字节流」,必须自己设计成帧方式,例如长度前缀。如果承载的协议本身已有成帧(例如带长度前缀的序列化格式),用字节模式即可。注意:即便在消息模式下,接收缓冲区小于消息时也会发生拆分读取(ERROR_MORE_DATA),仍需处理。此外,读取模式是按句柄设置的,CreateNamedPipe 只设置服务器一侧。客户端必须在 CreateFile 之后用 SetNamedPipeHandleState 指定 PIPE_READMODE_MESSAGE。在 .NET 中,服务器指定 PipeTransmissionMode.Message,客户端则在连接后把 NamedPipeClientStream.ReadMode 设为 Message。
同时与多个客户端通信的服务器该怎么做?
命名管道可以在同一名称下创建多个实例,一个实例对应一个客户端。有两种形态。一种是按客户端分配线程的同步设计,实现直观,但每个客户端消耗一条线程。另一种是用 FILE_FLAG_OVERLAPPED 做异步 I/O,用少量线程处理所有实例的 ConnectNamedPipe、ReadFile、WriteFile;Microsoft 官方示例也展示了单线程处理多个实例的实现。在 .NET 中,用 NamedPipeServerStream.WaitForConnectionAsync 和 async/await,可以用接近同步写法的直观程度写出异步形态。没有特殊理由时,新实现我推荐 .NET 异步形态。
命名管道安全方面至少该做什么?
四点。第一,若不需要远程连接,指定 PIPE_REJECT_REMOTE_CLIENTS,明确拒绝经网络的连接。第二,用 SECURITY_ATTRIBUTES 设置合适的 ACL,收窄可连接的用户和组(默认 ACL 对某些用途过宽)。第三,创建第一个实例时指定 FILE_FLAG_FIRST_PIPE_INSTANCE,以便检测「同名管道被抢先创建」的名称劫持(第二及以后的实例不要加这个标志)。第四,只希望服务器识别自己身份的客户端,应在 CreateFile 上指定 SECURITY_SQOS_PRESENT|SECURITY_IDENTIFICATION,避免伪服务器借用(模拟)其权限。在服务器以客户端权限执行实际访问的代理(broker)设计中,必须允许模拟,因此是否施加这一限制,取决于设计是否让服务器借用权限。
使用 ImpersonateNamedPipeClient 有哪些注意点?
最重要的是检查返回值。若模拟失败后仍继续处理,后续操作会以服务器进程自身(往往较高)的权限运行,本不该允许客户端做的操作也会通过。官方文档也明确写了:失败时不得执行客户端的请求。还必须在读取了某些内容之后才调用——模拟是在「从管道读取的最后一条消息」的上下文中进行的——并且工作完成后用 RevertToSelf 可靠地回到原来的上下文。模拟相关机制(令牌、模拟级别、SeImpersonatePrivilege)在模拟令牌的文章中有详细说明。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表