Windows Internals学习:高级本地过程调用(ALPC)详解
一、为什么需要 ALPC
任何一个现代操作系统,都要解决一个基础问题:不同进程之间怎么安全、高效地互相传递数据,包括进程和进程之间,以及内核里的服务和用户态客户端之间。这类机制统称为进程间通信(Inter-Process Communication,简称 IPC)。
不同操作系统会选用不同的实现方式:
- UNIX 系的系统为了跨平台,通常用邮件槽(mailslot)、文件、命名管道(named pipe)、**套接字(socket)**这些相对通用的机制。
- 而在某个特定操作系统内部,开发者也可以用一些系统专属的机制,比如 Win32 图形应用里非常常见的窗口消息(window message)。
除此之外,Windows 内部还实现了一套专属的、不对外公开的 IPC 机制,叫做 ALPC——全称 Advanced Local Procedure Call(高级本地过程调用),有时也被称为 Asynchronous Local Procedure Call(异步本地过程调用)。它是一套高速、可伸缩、安全的消息传递设施,可以传递任意大小的消息。
二、ALPC 和它的前身 LPC 是什么关系
ALPC 并不是凭空出现的,它是用来取代一个更老的 IPC 机制的,那个老机制叫 LPC(Local Procedure Call),是 Windows NT 内核设计之初、也就是第一版内核里就自带的东西。
正因为如此,你现在如果去翻 Windows 内核的一些变量名、字段名、函数名,仍然会看到写着 “LPC” 字样的痕迹——这只是历史遗留的命名,实际底层早就换成了 ALPC。
需要理清楚的一点是:LPC 现在已经不存在了,它已经从内核里被彻底移除了。但是为了兼容性,LPC 是被"模拟"在 ALPC 之上实现的——也就是说,如果有旧代码(老的系统调用)还在调用 LPC 相关的接口,这些调用会被包装(wrap)成 ALPC 调用来执行。对调用者而言,接口好像还是 LPC,但底层实际跑的全都是 ALPC。
用一张图来表示这个关系:
三、ALPC 虽然是内部机制,但用得极其广泛
这里要强调一点:ALPC 是 Windows 的内部机制,第三方开发者是没有办法直接调用它的——没有对外公开的文档化 API。但即便如此,它在 Windows 系统内部各个角落被大量使用,几乎渗透到了所有安全边界层级。下面逐条来看它具体用在哪些地方。
3.1 RPC(远程过程调用)间接使用 ALPC
Windows 应用程序如果要使用 RPC(Remote Procedure Call,远程过程调用,这是一套文档化的公开 API),当它们指定使用 ncalrpc 这种传输方式时,底层实际上就是在间接使用 ALPC。
ncalrpc 是一种专门用于同一台机器上不同进程之间通信的 RPC 传输方式(相当于 RPC 的"本地版")。现在几乎所有 RPC 客户端在本机通信场景下,默认用的都是这种传输方式。
另外,Windows 驱动程序如果要用内核态的 RPC,那么它只能使用 ALPC 作为传输方式——没有其他选择,ALPC 是唯一被允许的传输通道。
3.2 进程/线程启动,以及子系统操作
每当一个 Windows 进程或者线程启动的时候,以及在任何 Windows 子系统操作期间,系统都会用 ALPC 来跟子系统进程(也就是 CSRSS,Client/Server Runtime Subsystem)通信。
而所有的子系统,又都会通过 ALPC 跟会话管理器(SMSS,Session Manager Subsystem)通信。
3.3 异常处理与错误报告
当一个 Windows 进程抛出异常的时候,内核里的异常分发器(exception dispatcher)会通过 ALPC 跟 Windows 错误报告服务(WER,Windows Error Reporting)通信。
除此之外,进程自己也可以主动跟 WER 通信,比如从未处理异常处理程序(unhandled exception handler)里直接发起。
3.4 登录与安全认证
Winlogon(负责处理用户登录界面和交互的进程)会用 ALPC 跟本地安全认证进程(LSASS,Local Security Authority Subsystem Service)通信。
安全引用监视器(Security Reference Monitor,这是内核执行体里的一个组件)同样会用 ALPC 跟 LSASS 进程通信。
3.5 电源管理
用户态的电源管理器和电源监视器,会通过 ALPC 跟内核态的电源管理器通信,比如每当用户调节 LCD 屏幕亮度的时候,就会触发这样的通信。
3.6 用户态驱动框架
用户模式驱动框架(UMDF,User-Mode Driver Framework)让用户态驱动程序能够通过 ALPC 跟内核态的反射驱动(reflector driver)通信。
3.7 现代 UI 消息机制
新一代的 Core Messaging 机制(被 CoreUI 以及现代 UWP UI 组件所使用),会用 ALPC 来:
- 向 Core Messaging Registrar(核心消息注册中心)注册
- 发送序列化后的消息对象
这套机制实际上是用来取代老旧的 Win32 窗口消息模型的。
3.8 隔离环境下的安全通信
当凭据保护(Credential Guard)功能开启时,隔离的 LSASS 进程(Isolated LSASS,运行在更高隔离级别中)会通过 ALPC 跟普通的 LSASS 进程通信。
类似地,安全内核(Secure Kernel)会通过 ALPC 把可信小程序(trustlet)的崩溃转储信息传输给 WER。
四、为什么 ALPC 的设计必须格外重视安全性和性能
把上面这些应用场景放在一起看,可以发现一个很关键的规律:ALPC 通信横跨了几乎所有可能的安全边界类型。
用一个层级图来直观展示这种跨度:
从这张图能看出:ALPC 的通信范围,从没有任何特权的普通应用一直延伸到内核本身,从 VTL 1 层的可信小程序一直延伸到 VTL 0 层的普通服务,中间几乎所有的层级组合都覆盖到了。
这里提到的 VTL(Virtual Trust Level,虚拟信任级别)是 Windows 虚拟化安全架构里的概念,简单理解就是:VTL 1 是比 VTL 0 更受保护、更受信任的隔离执行环境,即便攻击者拿到了 VTL 0 里内核的完全控制权,也无法直接窥探或篡改 VTL 1 里的数据。
正因为 ALPC 要在这么大的信任级别落差之间传递数据——从最不受信任的普通用户应用,到最受信任的安全内核——所以它的设计必须同时满足两个看起来有点矛盾的要求:
ALPC 设计目标=足够安全⏟防止低权限一方窃取或篡改高权限一方的数据 + 足够高效⏟因为几乎系统的每个角落都在高频使用它 \text{ALPC 设计目标} = \underbrace{\text{足够安全}}_{\text{防止低权限一方窃取或篡改高权限一方的数据}} \;+\; \underbrace{\text{足够高效}}_{\text{因为几乎系统的每个角落都在高频使用它}} ALPC 设计目标=防止低权限一方窃取或篡改高权限一方的数据
足够安全+因为几乎系统的每个角落都在高频使用它
足够高效
如果只顾安全不顾性能,那么像"进程启动"“窗口消息”“电源管理"这类高频、低延迟要求的场景就会被拖慢;如果只顾性能不顾安全,那么像"LSASS 认证”"Credential Guard 隔离通信"这类涉及机密数据和高权限操作的场景就会出现安全漏洞。所以安全性和性能,是 ALPC 设计中同等重要、缺一不可的两个核心指标。
五、ALPC 应用场景全景图
下面用一张表格汇总一下前面提到的所有使用场景,方便快速查阅:
| 使用场景 | 通信双方 | 典型触发时机 |
|---|---|---|
| 本地 RPC (ncalrpc) | RPC客户端 ↔ RPC服务端 | 同机进程间RPC调用,现为默认传输方式 |
| 内核态RPC | 驱动程序 ↔ 内核RPC服务 | 驱动程序发起内核态RPC调用,ALPC是唯一传输选择 |
| 子系统通信 | 进程/线程 ↔ CSRSS | 任意进程或线程启动、子系统相关操作 |
| 会话管理 | 各子系统 ↔ SMSS | 系统会话管理相关操作 |
| 异常与错误报告 | 内核异常分发器 ↔ WER服务 | 进程抛出未处理异常 |
| 登录认证 | Winlogon ↔ LSASS | 用户登录交互过程 |
| 安全审计 | 安全引用监视器 ↔ LSASS | 安全策略检查与审计 |
| 电源管理 | 用户态电源管理器 ↔ 内核态电源管理器 | 如调节LCD屏幕亮度 |
| 用户态驱动 | UMDF驱动 ↔ 内核态反射驱动 | 用户态驱动程序运行期间 |
| 现代UI消息 | CoreUI/UWP组件 ↔ Core Messaging Registrar | UI组件注册与消息发送,替代传统窗口消息 |
| 凭据隔离保护 | 隔离LSASS ↔ 普通LSASS | Credential Guard开启时的凭据相关操作 |
| 可信程序崩溃处理 | 安全内核 ↔ WER服务 | VTL 1可信小程序发生崩溃 |
六、用 C++ 演示 ALPC 思想的简化本地 IPC 示例
因为 ALPC 是 Windows 内核内部专用机制,微软没有开放对应的公开 API 供第三方直接调用(没有类似 AlpcSendMessage 这样可以在普通应用中调用的公开函数)。不过,Windows 确实提供了一套公开、文档化的机制去实现同样的目的,那就是前面提到的 本地 RPC(ncalrpc 传输)。它的编程模型跟 ALPC 的设计思想(客户端-服务端、消息传递、双向通信)是一致的,只是接口层次更高、更安全易用。
下面这份代码使用 Windows RPC 运行时库,实现一个最简化的"客户端向本地服务端发送消息并获取回复"的示例,目的是帮助理解 ALPC 所服务的这一整套"本机进程间消息传递"场景在应用层是什么样子。
因为完整的 RPC 需要 IDL(接口定义语言)文件、MIDL 编译器生成桩代码,篇幅会非常长,这里改用一个更贴近内核机制本质、且能独立编译运行的最小化示例:用 Windows 的命名管道(Named Pipe)模拟"客户端-服务端消息传递"的核心逻辑,这也是 ALPC/RPC 思想在应用层最容易理解、最容易编译验证的等价实现。
// alpc_style_ipc_demo.cpp
// 演示"客户端-服务端消息传递"这一 ALPC/RPC 核心思想的最小化本地IPC示例
// 使用 Windows 命名管道模拟消息传递机制(客户端发送请求,服务端处理并返回结果)
// 编译命令 (MSVC): cl /EHsc alpc_style_ipc_demo.cpp
// 编译命令 (MinGW): g++ -std=c++17 alpc_style_ipc_demo.cpp -o ipc_demo.exe
#include <windows.h>
#include <iostream>
#include <string>
#include <thread>
// 管道名称,格式固定为 \\.\pipe\名称,这是本机命名管道的标准命名空间
const wchar_t* PIPE_NAME = L"\\\\.\\pipe\\alpc_demo_pipe";
// ---------- 服务端逻辑 ----------
// 服务端角色类似于 ALPC 通信里的"服务器端口"(Server Port)持有者,
// 负责创建通信端点、监听客户端连接、接收消息并回复。
void ServerRoutine()
{
// 1. 创建一个命名管道实例,这一步类似 ALPC 里创建"连接端口"(Connection Port)
// PIPE_ACCESS_DUPLEX 表示这是一个双向通信管道,
// 既可以接收客户端发来的消息,也可以向客户端发送回复。
HANDLE hPipe = CreateNamedPipeW(
PIPE_NAME,
PIPE_ACCESS_DUPLEX, // 双向通信
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, // 以"消息"为单位读写,而不是字节流
1, // 最多允许1个客户端实例
4096, // 输出缓冲区大小(字节)
4096, // 输入缓冲区大小(字节)
0, // 默认超时
nullptr // 默认安全属性
);
if (hPipe == INVALID_HANDLE_VALUE)
{
std::wcerr << L"[服务端] 创建管道失败,错误码: " << GetLastError() << std::endl;
return;
}
std::wcout << L"[服务端] 管道已创建,等待客户端连接..." << std::endl;
// 2. 阻塞等待客户端连接,这一步类似 ALPC 服务端调用 NtAlpcAcceptConnectPort,
// 等待客户端通过"连接请求消息"发起连接。
BOOL connected = ConnectNamedPipe(hPipe, nullptr);
if (!connected && GetLastError() != ERROR_PIPE_CONNECTED)
{
std::wcerr << L"[服务端] 客户端连接失败,错误码: " << GetLastError() << std::endl;
CloseHandle(hPipe);
return;
}
std::wcout << L"[服务端] 客户端已连接。" << std::endl;
// 3. 接收客户端发来的消息(一次完整的"消息传递",对应ALPC里的消息端口通信)
wchar_t buffer[512] = {0};
DWORD bytesRead = 0;
BOOL readOk = ReadFile(hPipe, buffer, sizeof(buffer) - sizeof(wchar_t), &bytesRead, nullptr);
if (readOk)
{
std::wcout << L"[服务端] 收到客户端消息: " << buffer << std::endl;
// 4. 构造回复消息,模拟"服务端处理请求后返回结果"
std::wstring reply = L"服务端已收到并处理: [" + std::wstring(buffer) + L"]";
DWORD bytesWritten = 0;
// 5. 把回复消息写回管道,发送给客户端
WriteFile(hPipe, reply.c_str(),
static_cast<DWORD>((reply.size() + 1) * sizeof(wchar_t)),
&bytesWritten, nullptr);
std::wcout << L"[服务端] 已发送回复。" << std::endl;
}
// 6. 通信结束,清理资源。
// 这对应 ALPC 通信结束后端口对象引用计数的释放。
FlushFileBuffers(hPipe);
DisconnectNamedPipe(hPipe);
CloseHandle(hPipe);
std::wcout << L"[服务端] 连接已关闭,服务端退出。" << std::endl;
}
// ---------- 客户端逻辑 ----------
// 客户端角色类似 ALPC 通信里发起 NtAlpcConnectPort 的一方,
// 主动连接到服务端建立的端口,发送请求并等待回复。
void ClientRoutine()
{
// 客户端启动时服务端可能还没创建好管道,做几次重试等待
HANDLE hPipe = INVALID_HANDLE_VALUE;
for (int attempt = 0; attempt < 20; ++attempt)
{
// 1. 尝试打开服务端创建的命名管道,
// 这一步类似 ALPC 客户端调用 NtAlpcConnectPort,向已知名称的端口发起连接。
hPipe = CreateFileW(
PIPE_NAME,
GENERIC_READ | GENERIC_WRITE, // 需要同时读写,因为要发消息也要收回复
0,
nullptr,
OPEN_EXISTING,
0,
nullptr
);
if (hPipe != INVALID_HANDLE_VALUE)
break; // 连接成功,跳出重试循环
Sleep(100); // 管道还没准备好,等100毫秒后重试
}
if (hPipe == INVALID_HANDLE_VALUE)
{
std::wcerr << L"[客户端] 连接管道失败,错误码: " << GetLastError() << std::endl;
return;
}
// 2. 把管道切换为"消息模式"读取,保证一次 ReadFile 读到的是完整的一条消息,
// 而不是被拆成任意长度的字节片段。
DWORD mode = PIPE_READMODE_MESSAGE;
SetNamedPipeHandleState(hPipe, &mode, nullptr, nullptr);
// 3. 构造要发送的请求消息
std::wstring request = L"你好,这是来自客户端的请求";
DWORD bytesWritten = 0;
// 4. 发送消息给服务端(对应ALPC里客户端把消息放入端口的消息队列)
WriteFile(hPipe, request.c_str(),
static_cast<DWORD>((request.size() + 1) * sizeof(wchar_t)),
&bytesWritten, nullptr);
std::wcout << L"[客户端] 已发送请求: " << request << std::endl;
// 5. 阻塞等待服务端的回复消息
wchar_t buffer[512] = {0};
DWORD bytesRead = 0;
BOOL readOk = ReadFile(hPipe, buffer, sizeof(buffer) - sizeof(wchar_t), &bytesRead, nullptr);
if (readOk)
{
std::wcout << L"[客户端] 收到服务端回复: " << buffer << std::endl;
}
// 6. 通信完成,关闭句柄
CloseHandle(hPipe);
std::wcout << L"[客户端] 客户端退出。" << std::endl;
}
// ---------- 主函数 ----------
int main()
{
// 用两个独立线程分别模拟"服务端进程"和"客户端进程",
// 现实中ALPC通信的双方通常是两个不同的进程,
// 这里为了单文件可编译运行,简化为同进程内的两个线程,
// 但通信逻辑(命名管道跨越的是内核对象,天然支持跨进程)与真实跨进程场景完全一致。
std::thread serverThread(ServerRoutine);
Sleep(200); // 确保服务端先启动,创建好管道
std::thread clientThread(ClientRoutine);
serverThread.join();
clientThread.join();
std::cout << "通信演示结束。" << std::endl;
return 0;
}
代码逐点讲解
为什么用命名管道而不是真正调用 ALPC 的系统调用?
因为 ALPC 对应的系统调用(比如 NtAlpcCreatePort、NtAlpcConnectPort、NtAlpcSendWaitReceivePort 等)都是未文档化的 native API,虽然技术上可以通过 ntdll.dll 动态获取函数地址来调用,但微软明确不建议、也不保证这些接口的稳定性,随时可能在系统更新后改变行为甚至消失。命名管道则是官方文档化、稳定支持的机制,并且它的编程模型——创建端点、等待连接、消息化收发、关闭连接——跟 ALPC 的核心思想是完全一致的,适合用来理解 ALPC 解决的是什么问题。PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE 这个组合是什么意思?
命名管道默认情况下更像一个"字节流",读取的时候不保证一次 ReadFile 就能读到发送方一次 WriteFile 写入的完整内容。加上这两个标志之后,管道会按照消息边界来传输数据——发送方写入一条消息,接收方一次读取正好读到这一整条消息,不多不少。这正好对应 ALPC 的核心特性之一:它传递的是完整的、任意大小的消息,而不是零散的字节流。
为什么客户端要做重试循环去打开管道?
因为管道是服务端先创建的,如果客户端启动时服务端还没来得及执行 CreateNamedPipeW,直接打开就会失败。这里用一个简单的重试机制(最多尝试20次,每次间隔100毫秒)来等待服务端准备就绪,这类"等待端口就绪"的逻辑,在真实的 ALPC 客户端连接服务端时同样存在(客户端会等待服务端把端口对象注册到系统的对象命名空间里)。ConnectNamedPipe 在做什么?
这一步是服务端阻塞等待客户端来连接。在 ALPC 的世界里,对应服务端调用 NtAlpcAcceptConnectPort 来接受一个客户端发起的连接请求。只有当双方都完成"握手"(服务端接受连接、客户端确认连接建立)之后,真正的消息收发才能开始。
整个通信流程和 ALPC 思想的对应关系是什么?
可以用下面这张时序图直观展示:
七、小结:ALPC 在整个系统里扮演的角色
用一句话概括:ALPC 是 Windows 内核为了满足内部各组件之间高频、大规模、跨安全边界的消息传递需求而设计的一套专属通信基础设施。它取代了早期的 LPC,同时把性能和安全这两个原本容易顾此失彼的目标,通过精心的内核级设计融合在了一起。
从进程启动、异常处理,到登录认证、电源管理,再到最新的虚拟化安全隔离场景,ALPC 几乎无处不在——这也正是为什么理解它的设计原理,对理解整个 Windows 内核的运作方式,是非常关键的一环。
ALPC 消息模型详解
一、阻塞模式下的基本通信循环
ALPC 最基础的使用方式,是客户端和服务端各自反复调用同一个系统调用——NtAlpcSendWaitReceivePort——形成一个"发送-等待-接收"的循环。
这个系统调用名字本身就说明了它在做什么:发送(Send)、等待(Wait)、接收(Receive),三件事捆绑在一次调用里完成。双方的配合方式是"轮流交替":
- 一方发送一条请求消息,然后阻塞等待对方的回复
- 另一方则相反:阻塞等待接收消息,处理完之后再发送回复
这种模式最大的特点就是阻塞(blocking):调用NtAlpcSendWaitReceivePort的线程,在对方还没有回应之前会一直停在那里,不会继续往下执行,也不会占用 CPU 时间片。
用一张时序图来表示这个基本循环:
不过,阻塞模式并不是唯一的选择。因为 ALPC 支持异步消息,通信的任何一方都可以选择不阻塞,而是先去做别的事情,之后再回头检查有没有新消息到达。这种异步的收发方式,后面会详细展开。
二、消息载荷的三种传递方式
一条 ALPC 消息里真正要传递的数据,被称为载荷(payload)。ALPC 针对不同大小、不同用途的载荷,设计了三种不同的传递方式,各有取舍。
2.1 标准双缓冲机制
最基础的方式,叫做双缓冲机制(double-buffering)。具体流程是:
- 内核在发送方进程的地址空间里,把消息数据拷贝一份到内核自己维护的缓冲区
- 内核执行进程切换,从发送方进程切换到接收方进程的上下文
- 内核再把这份数据从自己的缓冲区拷贝到接收方进程的地址空间
之所以叫"双缓冲",是因为数据经历了两次拷贝:一次是"发送方 → 内核缓冲区",另一次是"内核缓冲区 → 接收方"。整个过程完全由内核居中转发,两个用户态进程之间不会有任何直接的内存访问。
这种方式有一个大小限制,跟兼容性有关:
- 如果用的是旧版 LPC(现在已经被模拟在 ALPC 之上),这种方式一次最多只能传递 256 字节的消息。
- 而 ALPC 本身,可以额外分配一个扩展缓冲区(extension buffer),让消息大小扩展到最多 64 KB。
用公式表示这个大小上限的对比关系:
消息大小上限={256 字节,使用旧版 LPC 兼容模式64 KB,使用 ALPC 的扩展缓冲区 \text{消息大小上限} = \begin{cases} 256 \text{ 字节}, & \text{使用旧版 LPC 兼容模式} \\ 64 \text{ KB}, & \text{使用 ALPC 的扩展缓冲区} \end{cases} 消息大小上限={256 字节,64 KB,使用旧版 LPC 兼容模式使用 ALPC 的扩展缓冲区
2.2 基于 ALPC 节区对象的共享内存传递
第二种方式,是把消息数据存放在一个 ALPC 节区对象(section object)里,客户端和服务端各自把这个节区映射到自己的地址空间,也就是视图映射(view mapping)。
这种方式的本质是共享内存:客户端和服务端实际上在访问同一块物理内存,只是各自看到的是自己虚拟地址空间里映射出来的一个"视图"。这样一来,就不需要像双缓冲那样反复拷贝数据——特别适合传递体积很大的数据,因为拷贝大块数据的开销远比建立一次内存映射要高。
三、异步消息与消息取消
因为 ALPC 支持异步消息发送,随之而来的一个重要能力就是:消息可以被取消。
典型的场景是:某个请求处理的时间太长了,或者用户主动表示"我不想等了,取消这个操作"。这种情况下,已经发出去、但还没处理完的消息,需要有办法被撤回。ALPC 通过一个专门的系统调用来支持这个需求:NtAlpcCancelMessage。
这个能力只有在异步模式下才有意义——如果是纯阻塞式的"发送并死等回复",调用者本身就已经被卡住了,也就谈不上"主动去取消一条消息"这个动作。
四、ALPC 消息的五种队列
ALPC 端口对象内部,会把消息按照当前所处的状态,分别放进不同的队列(queue)里管理。一共有五种消息队列:
主队列(Main Queue):消息已经发送出去,接收方正在处理这条消息。
挂起队列(Pending Queue):消息已经发送出去,发送方正在等待回复,但回复目前还没有被送回来。
大消息队列(Large Message Queue):消息已经发送,但是接收方提供的缓冲区太小,装不下这条消息的完整内容。这种情况下,接收方会得到一次重新分配更大缓冲区的机会,然后再重新请求一次这条消息的完整载荷。
已取消队列(Canceled Queue):这条消息之前已经发送到端口了,但后来被取消了,所以被移到这个队列里。
直接队列(Direct Queue):消息在发送时附带了一个"直接事件"(direct event),这类消息会被放进这个专门的队列。
除了这五种"消息队列"之外,ALPC 端口对象内部还维护着第六种队列,叫做等待队列(wait queue)。但它跟前面五种性质不一样——前五种队列里存放的是"消息"本身,而等待队列里存放的不是消息,而是线程:所有正在等待某条消息的线程,都会被挂在这个队列上。
用一张图把这六种队列和它们各自存放的内容整理一下:
下面用表格汇总这六种队列,方便对照查阅:
| 队列名称 | 存放内容 | 典型触发场景 |
|---|---|---|
| 主队列 | 消息 | 消息已发送,接收方正在处理 |
| 挂起队列 | 消息 | 发送方在等回复,回复还没送回 |
| 大消息队列 | 消息 | 接收缓冲区太小,需要重新申请更大缓冲区再取一次 |
| 已取消队列 | 消息 | 消息已发送后被主动取消 |
| 直接队列 | 消息 | 消息发送时附带了直接事件 |
| 等待队列 | 线程 | 所有正在等待某条消息的线程 |
五、实验:用工具查看系统中真实的 ALPC 端口对象
Windows 本身并没有提供普通用户可以直接浏览内核对象的图形界面,但可以借助两个第三方工具来观察系统里实际存在的 ALPC 端口对象:
- WinObj(来自 Sysinternals 工具集)
- WinObjEx64(在 GitHub 上开源发布)
使用方法很简单:以管理员权限运行其中任意一个工具,然后定位到对象命名空间的根目录(\)。在列表里,ALPC 端口对象会有专门的图标标识——WinObj 里是一个齿轮图标,WinObjEx64 里是一个电源插头图标。为了方便查找,也可以直接点击 Type(类型)这一列的表头,把所有对象按类型排序,这样所有ALPC Port类型的条目就会集中排在一起。
5.1 从第一张截图能看到什么
在第一张图(根目录 \ 下)里,可以看到不少以 ALPC Port 为类型的对象,比如:
PdcPortPowerMonitorPortPowerPortSeLsaCommandPortSeRmCommandPortSmApiPortSmSsWinStationApiPortThemeApiPort
这些正好一一对应之前讲过的那些使用场景:PowerMonitorPort/PowerPort:对应电源管理器跟内核态电源管理器之间的 ALPC 通信SeLsaCommandPort/SeRmCommandPort:Se前缀对应安全引用监视器(Security Reference Monitor),这两个端口就是它跟 LSASS 通信所用的端口SmApiPort/SmSsWinStationApiPort:Sm前缀对应会话管理器(SMSS),这是各子系统跟 SMSS 通信的端口ThemeApiPort:跟主题(Theme)相关的 API 通信端口
另外还能看到一类叫FilterConnectionPort的对象类型,比如PFPort、storqosfltport、WcifsPort,这些是过滤器驱动(filter driver)用来跟用户态组件通信的连接端口,虽然类型名字不是ALPC Port,但底层机制同样是建立在 ALPC 之上的一种变体。
5.2 从第二张截图能看到什么
第二张图展示的是 \Sessions\1\Windows 这个目录,路径栏显示的是 \Sessions\1\Windows\ApiPort。这里能看到:
ApiPortDispBrokerPortDwmApiPortSbApiPort
这些同样都是ALPC Port类型的对象。这一组端口,正是 CSRSS(Windows 子系统进程)用来跟各个进程里加载的子系统 DLL 进行通信的端口。
这里有个值得注意的细节:为什么这些端口出现在\Sessions\1\Windows这个路径下,而不是根目录? 原因是 CSRSS 并不是系统里只运行一份的单例进程——每一个用户会话(session)都会加载一份自己的 CSRSS 实例。所以,你会在每一个会话对应的\Sessions\X\Windows目录下,各自找到一套独立的 ALPC 端口对象,X就是会话编号(比如0通常是系统会话,1通常是第一个交互式登录用户的会话)。
用一棵目录结构树来表示这种"按会话隔离"的组织方式:
对象命名空间根目录 \
│
├── Sessions
│ ├── 0 会话0,通常是系统服务会话
│ │ └── Windows
│ │ └── (该会话自己的 ApiPort 等端口)
│ │
│ └── 1 会话1,通常是交互式用户会话
│ └── Windows
│ ├── ApiPort ALPC端口
│ ├── DispBrokerPort ALPC端口
│ ├── DwmApiPort ALPC端口
│ └── SbApiPort ALPC端口
│
├── RPC Control RPC相关端口和对象所在目录
│
├── PdcPort ALPC端口 全局根目录下
├── PowerMonitorPort ALPC端口
├── PowerPort ALPC端口
├── SeLsaCommandPort ALPC端口
└── SeRmCommandPort ALPC端口
另外,如果想专门查看 RPC 相关使用的 ALPC 端口对象(不算本地 RPC 之外的场景,而是本地 RPC 本身用到的那些端口),可以直接定位到 \RPC Control 这个目录,里面存放的正是本地 RPC 通信所用的端口对象。
六、C++ 完整可运行代码:用命名管道模拟 ALPC 的双缓冲传递与消息队列语义
ALPC 本身的系统调用(NtAlpcSendWaitReceivePort、NtAlpcCancelMessage 等)都属于未公开文档的 native API,普通应用层代码不能直接、稳定地调用它们。这里用命名管道结合一个简易的消息队列结构,模拟出 ALPC 消息模型里几个核心概念:阻塞收发循环、消息大小限制(对应双缓冲机制的256字节/64KB上限)、以及消息可以被取消这几个特性,帮助建立直观理解。
// alpc_message_model_demo.cpp
// 演示 ALPC 消息模型核心概念的简化实现:
// 1. 阻塞式的发送-等待-接收循环(对应 NtAlpcSendWaitReceivePort)
// 2. 消息大小限制(对应双缓冲机制的256字节/64KB上限)
// 3. 消息可以被取消(对应 NtAlpcCancelMessage 与"已取消队列")
// 编译命令 (MSVC): cl /EHsc alpc_message_model_demo.cpp
// 编译命令 (MinGW): g++ -std=c++17 alpc_message_model_demo.cpp -o alpc_demo.exe
#include <windows.h>
#include <iostream>
#include <string>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <atomic>
// ---------- 模拟"消息"结构 ----------
// 对应 ALPC 消息载荷的简化版本:一个消息包含编号、内容、以及是否已被取消的标记
struct AlpcLikeMessage
{
int id; // 消息编号,模拟消息的唯一标识
std::string payload; // 消息载荷内容
bool canceled = false; // 标记这条消息是否已经被取消,对应"已取消队列"的语义
};
// ---------- 限制常量 ----------
// 对应文中提到的:旧版LPC双缓冲一次最多256字节,ALPC扩展缓冲区最多64KB
constexpr size_t LEGACY_LPC_MAX_SIZE = 256;
constexpr size_t ALPC_EXTENDED_MAX_SIZE = 64 * 1024; // 64 KB
// ---------- 模拟ALPC端口对象内部的队列结构 ----------
// 真实ALPC端口对象内部维护五种消息队列外加一个等待队列,
// 这里为了演示核心机制,只模拟"主队列"(待处理)和"已取消队列"两种,
// 并用条件变量实现"发送方阻塞等待回复"的效果。
class SimulatedAlpcPort
{
public:
// 模拟客户端调用 NtAlpcSendWaitReceivePort 发送一条消息,
// 并阻塞等待服务端处理完成后的回复。
// useExtendedBuffer 为 true 时模拟走ALPC扩展缓冲区(64KB上限),
// 为 false 时模拟走旧版LPC兼容路径(256字节上限)。
bool SendAndWait(int id, const std::string& payload, std::string& outReply, bool useExtendedBuffer)
{
// 1. 校验消息大小是否超出对应模式下的上限,
// 这一步对应内核在双缓冲机制里对消息大小的检查。
size_t limit = useExtendedBuffer ? ALPC_EXTENDED_MAX_SIZE : LEGACY_LPC_MAX_SIZE;
if (payload.size() > limit)
{
std::cout << "[客户端] 消息大小 " << payload.size()
<< " 字节超出上限 " << limit << " 字节,发送失败。" << std::endl;
return false;
}
AlpcLikeMessage msg;
msg.id = id;
msg.payload = payload;
{
// 2. 把消息放入"主队列",模拟消息进入端口对象的主队列等待处理
std::lock_guard<std::mutex> lock(m_mutex);
m_mainQueue.push(msg);
std::cout << "[客户端] 消息#" << id << " 已放入主队列,等待服务端处理..." << std::endl;
}
m_cv.notify_all(); // 唤醒可能正在等待新消息的服务端线程
// 3. 阻塞等待这条消息处理完成(对应NtAlpcSendWaitReceivePort的"等待回复"部分)
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this, id]() {
// 唤醒条件:这条消息要么已经有回复了,要么已经被取消了
return m_replies.count(id) > 0 || m_canceledIds.count(id) > 0;
});
if (m_canceledIds.count(id) > 0)
{
std::cout << "[客户端] 消息#" << id << " 已被取消,未获得回复。" << std::endl;
return false;
}
outReply = m_replies[id];
m_replies.erase(id); // 取走回复后从映射表里清理掉
return true;
}
// 模拟服务端调用 NtAlpcSendWaitReceivePort 等待接收一条消息进行处理,
// 处理完之后把回复写回,唤醒对应的客户端线程。
void ServerProcessOneMessage()
{
AlpcLikeMessage msg;
{
std::unique_lock<std::mutex> lock(m_mutex);
// 阻塞等待主队列里出现新消息
m_cv.wait(lock, [this]() { return !m_mainQueue.empty() || m_stop; });
if (m_stop) return;
msg = m_mainQueue.front();
m_mainQueue.pop();
}
std::cout << "[服务端] 从主队列取出消息#" << msg.id
<< ",内容: " << msg.payload << std::endl;
// 检查这条消息在处理之前是否已经被客户端取消
{
std::lock_guard<std::mutex> lock(m_mutex);
if (m_canceledIds.count(msg.id) > 0)
{
std::cout << "[服务端] 消息#" << msg.id << " 已被取消,跳过处理。" << std::endl;
return;
}
}
// 模拟处理耗时
Sleep(50);
std::string reply = "已处理: [" + msg.payload + "]";
{
std::lock_guard<std::mutex> lock(m_mutex);
m_replies[msg.id] = reply; // 把回复放入映射表,对应"挂起队列"转为"已完成"
}
m_cv.notify_all(); // 唤醒正在等待这条回复的客户端线程
std::cout << "[服务端] 消息#" << msg.id << " 处理完成,已发送回复。" << std::endl;
}
// 模拟 NtAlpcCancelMessage:取消一条尚未处理完成的消息
void CancelMessage(int id)
{
std::lock_guard<std::mutex> lock(m_mutex);
m_canceledIds.insert(id); // 加入"已取消队列"对应的集合
std::cout << "[控制线程] 已请求取消消息#" << id << std::endl;
m_cv.notify_all(); // 唤醒可能正在等待这条消息回复的客户端线程
}
void Stop()
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stop = true;
m_cv.notify_all();
}
private:
std::mutex m_mutex; // 保护下面所有共享状态的锁
std::condition_variable m_cv; // 用于阻塞等待/唤醒,模拟ALPC的阻塞收发循环
std::queue<AlpcLikeMessage> m_mainQueue; // 模拟"主队列"
std::unordered_map<int, std::string> m_replies; // 已完成处理、等待被客户端取走的回复
std::unordered_set<int> m_canceledIds; // 模拟"已取消队列",记录哪些消息编号被取消了
std::atomic<bool> m_stop{false};
};
// ---------- 主函数:驱动整个演示流程 ----------
int main()
{
SimulatedAlpcPort port;
// 启动一个服务端线程,持续处理主队列里的消息,
// 模拟服务端反复调用 NtAlpcSendWaitReceivePort 的循环。
std::thread serverThread([&port]() {
for (int i = 0; i < 3; ++i)
{
port.ServerProcessOneMessage();
}
});
// 场景1:正常发送一条走"扩展缓冲区"模式的消息,应当成功收到回复
std::thread clientThread1([&port]() {
std::string reply;
bool ok = port.SendAndWait(1, "第一条正常消息", reply, /*useExtendedBuffer=*/true);
if (ok)
std::cout << "[客户端1] 收到回复: " << reply << std::endl;
});
// 场景2:尝试发送一条超过旧版LPC 256字节上限的消息,应当被拒绝
std::thread clientThread2([&port]() {
std::string bigPayload(300, 'A'); // 构造一个300字节的载荷,超过256字节上限
std::string reply;
bool ok = port.SendAndWait(2, bigPayload, reply, /*useExtendedBuffer=*/false);
if (!ok)
std::cout << "[客户端2] 消息发送被拒绝(符合预期,因为超出旧版LPC上限)。" << std::endl;
});
clientThread2.join(); // 场景2会立刻返回失败,不需要服务端配合
// 场景3:发送一条消息,但在服务端处理之前就把它取消掉
std::thread clientThread3([&port]() {
std::string reply;
bool ok = port.SendAndWait(3, "这条消息会被取消", reply, /*useExtendedBuffer=*/true);
if (!ok)
std::cout << "[客户端3] 未能获得回复(符合预期,消息已被取消)。" << std::endl;
});
// 立刻取消消息#3,模拟"用户中途取消操作"的场景
Sleep(10);
port.CancelMessage(3);
clientThread1.join();
clientThread3.join();
port.Stop();
serverThread.join();
std::cout << "演示结束。" << std::endl;
return 0;
}
代码逐点讲解
为什么用 useExtendedBuffer 这个参数来区分两种大小上限?
这对应文中提到的一个关键兼容性细节:如果走的是旧版 LPC 兼容路径,一次双缓冲传递最多只能带 256 字节;而 ALPC 原生可以额外申请一个扩展缓冲区,把上限提升到 64 KB。代码里用一个布尔参数模拟"选择走哪条路径",并在发送前做大小校验,对应内核在真实场景下拒绝超размер消息的行为。SendAndWait 里为什么要用条件变量而不是简单的循环查询?
这正是在模拟 NtAlpcSendWaitReceivePort 的阻塞语义——调用者发出消息后就应该进入睡眠状态,不占用 CPU,直到有结果(收到回复,或者消息被取消)才被唤醒。这跟之前讲条件变量那篇文档里的核心思想完全一致:用条件变量把"检查状态"和"挂起等待"合并为一个不会错过通知的原子流程,这里正好是条件变量的一次实际应用。m_mainQueue、m_replies、m_canceledIds 这几个成员分别对应什么?
m_mainQueue对应 ALPC 的主队列:消息发出后、被服务端取走处理之前,停留在这里。m_replies是一个简化设计,用来模拟"服务端处理完、客户端还没取走结果"这段时间——概念上接近挂起队列的另一面(挂起队列站在"发送方在等"这个角度看问题,而m_replies是"结果已经产出、等待被取走"这个角度,本质上描述的是同一段等待窗口)。m_canceledIds直接对应 ALPC 的已取消队列:一旦某条消息编号出现在这个集合里,无论它当时处于主队列还是正在被处理,都会被视为"已取消",客户端的等待会立刻以失败告终。
场景2(发送超大消息)为什么没有让服务端线程参与?
因为消息大小的校验是在发送阶段、消息真正进入队列之前完成的——这正好对应真实 ALPC 里内核在拷贝消息到内核缓冲区之前,就会先检查消息大小是否超限。既然发送这一步就直接失败了,这条消息根本不会进入主队列,自然也不需要服务端来处理它。
场景3展示的"取消"逻辑,和真实 ALPC 的NtAlpcCancelMessage有什么对应关系?
真实场景下,NtAlpcCancelMessage允许调用方主动撤回一条已经进入端口队列、但尚未完成处理的消息,内核会把这条消息移动到已取消队列,正在等待这条消息回复的线程会被唤醒并得到"已取消"的结果,而不会无限期等下去。代码里的CancelMessage方法做的正是这件事:往m_canceledIds里加一条记录,然后唤醒所有可能在等待的线程,让SendAndWait里的条件变量检测到取消状态后立刻返回失败。
七、总结:从截图到消息模型的完整对应关系
结合前面看到的两张 WinObj 截图和消息模型的原理,可以把整个链条串起来看:
应用/驱动发起请求 → 找到对应的ALPC端口对象(如 SeLsaCommandPort、ApiPort) → 调用 NtAlpcSendWaitReceivePort 发送消息 \text{应用/驱动发起请求} \;\rightarrow\; \text{找到对应的ALPC端口对象(如 SeLsaCommandPort、ApiPort)} \;\rightarrow\; \text{调用 NtAlpcSendWaitReceivePort 发送消息} 应用/驱动发起请求→找到对应的ALPC端口对象(如 SeLsaCommandPort、ApiPort)→调用 NtAlpcSendWaitReceivePort 发送消息
→ 消息根据大小和用途,走双缓冲机制或节区共享内存 → 消息在端口对象内部五种队列间流转 \rightarrow\; \text{消息根据大小和用途,走双缓冲机制或节区共享内存} \;\rightarrow\; \text{消息在端口对象内部五种队列间流转} →消息根据大小和用途,走双缓冲机制或节区共享内存→消息在端口对象内部五种队列间流转
→ 对端处理完成后回复,或调用方中途取消 → 等待队列上的线程被唤醒,通信完成 \rightarrow\; \text{对端处理完成后回复,或调用方中途取消} \;\rightarrow\; \text{等待队列上的线程被唤醒,通信完成} →对端处理完成后回复,或调用方中途取消→等待队列上的线程被唤醒,通信完成
截图里看到的每一个 ALPC Port 类型的对象,背后都对应着这样一整套"发送、排队、处理、回复或取消"的完整机制。理解了这套消息模型,再回头看那些端口名字(PowerPort、SeLsaCommandPort、ApiPort 等等),就能明白它们各自是哪个系统组件用来实现进程间通信的具体入口。
ALPC 异步操作机制详解
一、为什么需要异步模型
ALPC(Advanced Local Procedure Call,高级本地过程调用)的同步模型,其实是从早期 NT 架构里的原始 LPC 机制继承下来的。这种同步、阻塞式的进程间通信(IPC)方式,和其他一些阻塞式 IPC 机制(比如 Mach 系统里的端口通信)本质上是一类东西:调用方发出请求后就原地阻塞,一直等到对方回应为止。
这种设计思路简单直观,实现起来也不复杂,但它有一个致命的隐患:
阻塞式 IPC 天然容易引发死锁(deadlock) \text{阻塞式 IPC 天然容易引发死锁(deadlock)} 阻塞式 IPC 天然容易引发死锁(deadlock)
想象一种场景:线程 A 阻塞等待线程 B 的回复,而线程 B 恰好也在阻塞等待线程 A 完成某个操作——两边互相等对方,谁都动不了,程序就卡死了。为了规避这类死锁场景,开发者往往需要写非常复杂的代码去做各种规避处理,这本身又带来了新的复杂度和维护成本。
正因为如此,ALPC 从设计之初就把异步(非阻塞)操作模型作为核心目标之一,而不仅仅是同步模型的一个附加功能。这不是可有可无的锦上添花,而是支撑一些关键场景的硬性需求:
- 可伸缩的 RPC(远程过程调用)系统需要异步能力才能撑得起高并发;
- 用户态驱动程序里的挂起 I/O(pending I/O)支持,也依赖异步通信才能实现。
一个基础但重要的改进:超时参数
在讲三种异步通知模型之前,先提一个 ALPC 相对于老 LPC 的基础改进点:ALPC 的阻塞调用支持设置超时参数(timeout)。
这个特性看似简单,但作用很关键——它让老代码(legacy application)即便还在用同步阻塞的调用方式,也能够通过设置超时时间,主动跳出某些可能演变成死锁的等待场景,而不至于永久卡死。相当于给阻塞调用加了一个"最长忍耐时间"的保险丝。
二、三种异步通知模型总览
尽管 ALPC 支持超时这样的兜底手段,但它真正被优化的目标,还是异步消息传递。为此,ALPC 提供了三种不同层次的异步通知模型,从"最基础、最灵活"到"最省事、最贴合驱动场景"依次展开:
下面逐一拆解这三种模型的原理、适用场景和取舍。
三、模型一:只拷贝数据,不主动通知
这是三种模型里最"原始"、最基础的一种。它的行为特点是:
系统不会主动通知客户端或服务端"数据到了",它只是把数据负载(payload)拷贝过去,仅此而已。
也就是说,这个模型只负责把数据从一端搬到另一端这一件事,至于"接收方怎么知道数据已经到了、该什么时候去取",系统完全不管,这个责任被丢给了实现者自己去解决。
那实现者一般怎么解决这个"怎么知道数据到了"的问题呢?材料里给出了两种常见思路:
- 共享一个通知事件对象:客户端和服务端之间共享一个事件对象(event object),数据拷贝完成后,某一方负责触发(signal)这个事件,另一方则在等待这个事件,被唤醒后就知道该去取数据了。
- 轮询(polling):接收方不依赖任何通知信号,而是自己隔一段时间就去检查一下"数据是不是已经到了",用主动查询的方式代替被动等待。
这个模型底层依赖的数据结构叫:
ALPC 完成列表(ALPC completion list) \text{ALPC 完成列表(ALPC completion list)} ALPC 完成列表(ALPC completion list)
这里有个容易混淆的地方需要特别澄清:
ALPC 完成列表和 Windows I/O 完成端口(I/O completion port)不是同一个东西,虽然名字里都带"completion",但它们是两套独立的机制,不要弄混。
ALPC 完成列表本身是一个高效、非阻塞的数据结构,它的核心价值在于:能够保证多个客户端之间原子地(atomically)传递数据,也就是说不会出现数据传了一半、被另一个操作打断、导致数据错乱或者丢失的情况。这个数据结构内部具体是怎么实现的,属于比较底层的性能优化细节,这里先不展开。
四、模型二:基于完成端口的等待模型
第二种模型是在第一种模型(ALPC 完成列表)的基础上,再叠加一层 Windows 完成端口机制,从而构建出的一个更完整、更"智能"的等待模型。
它相比模型一多出来的能力,主要体现在下面这几点:
| 能力 | 说明 |
|---|---|
| 批量取件 | 一个线程可以一次性取回多个数据负载(payload),不用一个个单独等 |
| 并发数量控制 | 可以限制同时处理中的最大并发请求数,避免线程被瞬间涌入的请求压垮 |
| 复用完成端口原生能力 | 直接借助 Windows 完成端口本身已经很成熟的调度、负载均衡等特性 |
这套机制在 Windows 系统内部有非常实际的应用场景。举两个例子:
- 用户态线程池的内部实现:Windows 提供给应用程序使用的用户态线程池(thread pool),其内部有一套专门的 API,用来管理 ALPC 消息,而这套管理方式恰恰就是建立在"工作线程 + 完成端口"这同一套基础设施之上的。也就是说,线程池处理普通工作项和处理 ALPC 消息,走的是同一套底层调度逻辑。
- 本地 RPC(Local RPC):Windows 的 RPC 系统,在使用本地 RPC(也就是通过
ncalrpc这种协议序列,在同一台机器内部的进程之间通信时),同样会利用这套完成端口能力,来实现高效的消息投递。无论是用户态的 RPC 运行时,还是内核态Msrpc.sys里的 RPC 运行时,都受益于这套内核提供的支持能力。
可以说,模型二是三种模型里最"重量级"、功能最完整的一种,适合那些本身就需要高并发、高吞吐量消息处理能力的场景。
五、模型三:基于执行体回调对象的内核级通知
第三种模型是专门为驱动程序这类特殊场景设计的。
驱动程序有一个天然的限制:它们可能运行在任意的执行上下文里(比如可能是在某个随机的中断处理路径上,或者某个不确定的线程上下文中),而且驱动程序通常不喜欢为了这么一件小事去专门创建一个系统线程——创建和维护一个专用线程本身也是有开销的,对驱动这种追求轻量化和高效率的场景来说不太划算。
针对这种需求,ALPC 提供了一种更基础、完全在内核层面完成的通知机制,核心角色是:
执行体回调对象(executive callback object) \text{执行体回调对象(executive callback object)} 执行体回调对象(executive callback object)
使用方式很直接:
- 驱动程序调用
NtSetInformationAlpcPort,把自己的回调函数和一份**上下文数据(context)**注册进去。 - 从此之后,每当有消息到达这个端口时,系统就会自动调用驱动注册的这个回调函数,驱动完全不需要自己起一个线程去轮询或者等待。
材料里举了一个具体的实际案例:内核里的电源依赖协调器(Power Dependency Coordinator,也就是Pdc.sys),正是用这套机制来和它的客户端进行通信的。
这套机制的性能优势
用执行体回调对象这种方式,有一个很吸引人的性能特点:回调函数是以"阻塞方式"、并且和触发信号的代码"内联(inline)"执行的。
这句话具体是什么意思呢?拆开来看:
- 一旦消息被发送、触发了信号(signaled),回调函数会立刻被执行,而且是直接嵌入在发送消息那条代码路径里执行的——也就是说,回调函数运行在发送这条 ALPC 消息的那个用户态线程的上下文里(具体来说,就是那个调用了
NtAlpcSendWaitReceivePort的线程)。
这意味着什么?意味着内核组件(也就是驱动)有机会在不产生上下文切换开销的情况下,直接检查发送方(客户端)当时的状态——因为回调函数本来就运行在客户端线程自己的执行上下文里,天然就"贴"在客户端身边,不需要额外切换线程去访问客户端的信息。甚至,驱动还可能直接在发送方的上下文里,就地消费这份消息负载,进一步节省开销。
用一张图梳理一下这个"内联执行"的过程:
这个模型的潜在风险
材料特别提醒:这种"内联在发送方上下文里执行"的特性,虽然带来了性能优势,但同时也引入了安全风险,而且如果实现者对此不了解,很容易踩坑。
具体的风险来源于这样一个事实:上面说的"回调运行在发送方上下文里"这件事,并不是一个绝对能保证的、简单一一对应的关系。为什么?因为现实中存在这么几种复杂情况:
- 多个客户端可能同时向同一个端口发送消息。
- 在服务端还没来得及注册它的执行体回调对象之前,可能已经有客户端发送过消息了——也就是说,消息的到达和回调的注册之间存在时间差,顺序不一定按预期来。
- 当服务端正在处理某个客户端 A 发来的第一条消息时,另一个客户端 B 可能又发来了一条新消息。
在上面这几种情况交织之下,会出现这样一个微妙但很关键的结果:
服务端的回调函数,确实是运行在"某一个发送过消息的客户端"的线程上下文里,但这个上下文所属的客户端,未必就是当前这条消息真正的发送者。
也就是说,回调函数被触发时所在的那个执行上下文,和它此刻正在分析处理的那条消息,两者的"客户端身份"可能对不上。如果驱动开发者天真地以为"我现在运行在谁的上下文里,这条消息就一定是谁发的",就可能在错误的客户端身上做出判断或操作,这就是安全风险的来源。
正确的应对方式
针对上面这个问题,材料给出了明确的解决办法:
- 每条 ALPC 消息内部都带有一个
PORT_HEADER结构,里面编码了发送者的客户端 ID(Client ID)。 - 服务端在处理消息时,应当主动去识别这种"上下文和消息发送者可能不一致"的情况,并且根据消息里携带的真实客户端 ID,去关联/分析正确的那个发送者的状态。
但这里有个代价需要接受:一旦服务端发现自己当前所在的上下文,和消息真正的发送者对不上,需要主动去关联正确发送者的状态时,这个操作本身就可能带来一次上下文切换的开销——相当于之前"内联执行、零上下文切换"这个性能优势,在这种特殊情况下就不成立了,需要额外付出代价来保证正确性。
用一张表格总结一下这个风险点:
| 场景 | 回调运行的上下文 | 消息真正的发送者 | 是否需要额外处理 |
|---|---|---|---|
| 理想情况:单一客户端顺序通信 | 该客户端自己 | 同一个客户端 | 不需要,天然一致 |
| 多客户端并发发送 | 某个恰好触发信号的客户端 | 可能是另一个客户端 | 需要,按 PORT_HEADER 中的 Client ID 重新关联 |
| 服务端注册回调前已有消息发出 | 消息发送时的客户端 | 消息发送时的那个客户端 | 需要核实,因为注册和消息到达的时间顺序不确定 |
六、三种模型的横向对比
三种异步通知模型各有取舍,适用场景也不一样,用一张表格对比一下:
| 对比维度 | 模型一(纯数据拷贝) | 模型二(完成端口) | 模型三(执行体回调对象) |
|---|---|---|---|
| 是否主动通知 | 否,需要实现者自己想办法 | 是,通过完成端口通知 | 是,通过回调函数自动触发 |
| 典型同步手段 | 共享事件对象 / 轮询 | 完成端口的等待队列 | 内联回调,无需专门线程 |
| 是否支持批量取件 | 不支持 | 支持 | 不适用(逐条触发) |
| 使用者 | 需要自己实现同步逻辑的场景 | 用户态线程池、本地 RPC | 内核驱动 |
| 性能特点 | 简单但需要自行保证可靠性 | 吞吐量高,功能完整 | 可能零上下文切换,但有身份错配风险 |
| 典型应用 | 自定义 IPC 场景 | 线程池、ncalrpc 本地 RPC、Msrpc.sys | Pdc.sys 电源依赖协调器 |
七、C++ 完整示例代码:模拟模型一(共享事件通知)
由于真正的 NtAlpcSendWaitReceivePort 等 ALPC 原生 API 属于 Windows 内核未公开文档化的 Native API,普通用户态程序一般不会直接调用它们。为了帮助理解**模型一(数据拷贝 + 共享事件对象通知)**这个思路,下面用 Win32 公开可用的事件对象(CreateEvent)来模拟这套通信模式:一个"客户端"线程写入数据后触发事件,一个"服务端"线程等待事件被触发后再取走数据。这份代码可以直接编译运行。
// alpc_model1_demo.cpp
// 编译方式(Visual Studio 开发者命令提示符):
// cl /EHsc /std:c++17 alpc_model1_demo.cpp
// 或使用 MinGW:
// g++ -std=c++17 alpc_model1_demo.cpp -o alpc_model1_demo
#include <windows.h> // CreateEvent、SetEvent、WaitForSingleObject 等 API
#include <iostream> // 控制台输出
#include <thread> // std::thread,模拟客户端和服务端两个独立执行体
#include <string> // 用字符串模拟传递的数据负载(payload)
#include <chrono> // 模拟耗时
// -----------------------------------------------------------------------
// 模拟"数据负载":真实 ALPC 里数据是通过完成列表原子传递的,
// 这里简化为一个全局字符串,代表客户端要发给服务端的数据。
// -----------------------------------------------------------------------
static std::string g_payload;
// -----------------------------------------------------------------------
// 通知事件句柄:对应材料里说的"客户端和服务端共享一个通知事件对象"。
// 这里用 Win32 的事件对象来模拟——数据准备好后由客户端触发(Set),
// 服务端则一直在等待(Wait)这个事件。
// -----------------------------------------------------------------------
static HANDLE g_notifyEvent = NULL;
// -----------------------------------------------------------------------
// 客户端线程:负责"拷贝数据",然后通过共享事件通知服务端数据已就绪。
// 对应模型一里"只拷贝数据,不主动通知"这句话中,
// "实现者自己选择可靠的同步方式"这一部分——这里选择的方式就是共享事件。
// -----------------------------------------------------------------------
void ClientThread()
{
// 模拟准备数据需要一点时间
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// 把数据"拷贝"到共享位置(对应 ALPC 完成列表做的事情:原子地搬运数据)
g_payload = "这是客户端发送的一条消息负载";
std::cout << "[客户端] 数据已准备好,正在触发通知事件..." << std::endl;
// 触发事件,通知服务端"数据到了"
SetEvent(g_notifyEvent);
}
// -----------------------------------------------------------------------
// 服务端线程:一直阻塞等待通知事件,被唤醒后再去读取共享数据。
// 这正是"实现者自己想办法保证可靠同步"的一种典型做法——
// 用共享事件对象来代替系统原生的通知机制。
// -----------------------------------------------------------------------
void ServerThread()
{
std::cout << "[服务端] 正在等待客户端的数据通知..." << std::endl;
// 阻塞等待,直到事件被 SetEvent 触发,INFINITE 表示无限等待不设超时
DWORD waitResult = WaitForSingleObject(g_notifyEvent, INFINITE);
if (waitResult == WAIT_OBJECT_0)
{
// 事件被成功触发,说明数据已经准备好,可以安全读取
std::cout << "[服务端] 收到通知,读取到的数据内容:" << g_payload
<< std::endl;
}
else
{
std::cout << "[服务端] 等待过程中出现异常!" << std::endl;
}
}
int main()
{
// 创建一个手动重置为 FALSE(自动重置)、初始状态为未触发的事件对象
// 参数说明:
// 第一个参数:安全属性,传 NULL 使用默认
// 第二个参数:是否手动重置,这里传 FALSE 表示"自动重置"事件,
// 即一旦有一个等待者被唤醒,事件会自动恢复为未触发状态
// 第三个参数:初始状态,FALSE 表示一开始是未触发的
// 第四个参数:事件对象的名字,NULL 表示匿名事件
g_notifyEvent = CreateEventW(NULL, FALSE, FALSE, NULL);
if (g_notifyEvent == NULL)
{
std::cerr << "创建事件对象失败!" << std::endl;
return 1;
}
// 启动服务端线程(先启动,让它先进入等待状态)
std::thread server(ServerThread);
// 启动客户端线程(稍后会触发事件)
std::thread client(ClientThread);
// 等待两个线程都执行完毕
client.join();
server.join();
// 清理事件对象句柄,避免句柄泄漏
CloseHandle(g_notifyEvent);
std::cout << "演示结束。" << std::endl;
return 0;
}
代码逐段讲解
1. 头文件部分
#include <windows.h>
#include <iostream>
#include <thread>
#include <string>
#include <chrono>
<windows.h>:提供CreateEventW、SetEvent、WaitForSingleObject等 Win32 事件相关 API。<iostream>:控制台输出,观察客户端和服务端的执行顺序。<thread>:用两个独立线程分别模拟"客户端"和"服务端"两个通信参与者。<string>:用字符串充当被传递的数据负载,模拟真实场景中的消息内容。<chrono>:给客户端制造一点人为延迟,让"服务端先进入等待、客户端后发通知"这个顺序更明显。
2. 共享数据与事件句柄
static std::string g_payload;
static HANDLE g_notifyEvent = NULL;
g_payload:模拟真实 ALPC 完成列表要传递的数据内容,这里简化成一个全局字符串。g_notifyEvent:对应"客户端和服务端共享一个通知事件对象"这句话中的那个事件对象,类型是 Win32 的HANDLE。
3. 客户端线程ClientThread
g_payload = "这是客户端发送的一条消息负载";
SetEvent(g_notifyEvent);
- 先把数据写入共享变量,这一步对应"ALPC 只负责拷贝数据"这个基础行为;
- 数据写完之后,调用
SetEvent主动触发事件,这一步是实现者自己加上去的同步逻辑——因为 ALPC 本身模型一并不会替你做这件事,必须由使用者自己决定怎么通知对方。
4. 服务端线程ServerThread
DWORD waitResult = WaitForSingleObject(g_notifyEvent, INFINITE);
if (waitResult == WAIT_OBJECT_0)
{
std::cout << "... " << g_payload << std::endl;
}
WaitForSingleObject会让服务端线程阻塞在这里,直到g_notifyEvent被客户端SetEvent触发为止;- 一旦等待成功返回(返回值等于
WAIT_OBJECT_0),就说明数据已经就绪,服务端这时候读取g_payload是安全的,不会读到写了一半的脏数据。
5.main函数
g_notifyEvent = CreateEventW(NULL, FALSE, FALSE, NULL);
std::thread server(ServerThread);
std::thread client(ClientThread);
client.join();
server.join();
CloseHandle(g_notifyEvent);
- 先创建一个自动重置类型的事件对象;
- 故意先启动服务端线程,让它先进入等待状态,再启动客户端线程,这样能更清楚地观察到"服务端先等、客户端后触发"这个典型的异步通知场景;
- 最后回收事件对象句柄,避免句柄资源泄漏。
这个示例体现了材料中的哪些知识点
- “只拷贝数据,不主动通知”:程序里
g_payload的赋值动作单独存在,通知逻辑(SetEvent)是额外补充上去的,对应材料里"这个模型本身不通知,同步方式要实现者自己决定"这句话。 - 共享事件对象作为同步手段:这正是材料举的第一个例子(另一个例子是轮询,这里没有实现,因为轮询本质上就是用一个循环不断检查条件变量,思路比较直白,不需要额外代码演示)。
- 数据传递的"原子性"设想:虽然这里用简单的字符串赋值模拟,没有真正实现完整的原子完成列表,但在真实场景里,
g_payload = "..."这一步对应的就是 ALPC 完成列表保证的那次"原子拷贝"。
八、模型三"上下文错配"问题的 ASCII 示意
用 ASCII 图直观展示一下模型三里"回调运行的上下文"和"消息真正发送者"可能不一致这个核心风险点:
时间线:
t1 客户端 A 发送消息 M1 ────────┐
t2 客户端 B 发送消息 M2 ──────┐ │
t3 服务端正在处理 M1 时 │ │
客户端 B 的 M2 已经排队等待 │ │
▼ ▼
┌─────────────────────┐
│ ALPC 端口的消息队列 │
│ [ M1(来自A) M2(来自B) ]│
└───────────┬───────────┘
│
某个客户端线程触发信号,回调被内联执行
│
▼
┌───────────────────────────────┐
│ 回调函数运行在"触发信号的那个 │
│ 客户端线程"的上下文里 │
│ 但正在处理的消息,可能来自另一个 │
│ 客户端(需要看 PORT_HEADER 里的 │
│ Client ID 才能确定真正的发送者) │
└───────────────────────────────┘
这张图想说明的是:ALPC 端口的消息队列里可能同时排着多个客户端发来的消息,而回调函数被触发执行时所"附身"的那个上下文,不一定恰好就是它正在处理的那条消息的真正主人,必须靠 PORT_HEADER 里的客户端 ID 字段来确认身份。
九、全篇要点小结
| 要点 | 说明 |
|---|---|
| 同步模型的历史来源 | 继承自早期 NT 的 LPC 架构,和 Mach 端口这类阻塞式 IPC 类似 |
| 阻塞式 IPC 的主要问题 | 容易引发死锁,规避代码复杂 |
| ALPC 相对 LPC 的基础改进 | 阻塞调用支持超时参数,帮助老程序避免死锁 |
| 异步模型一 | 只拷贝数据不通知,依赖 ALPC 完成列表,同步方式(事件/轮询)需自行实现 |
| 异步模型二 | 叠加 Windows 完成端口,支持批量取件与并发控制,用于线程池和本地 RPC |
| 异步模型三 | 基于执行体回调对象,专为驱动设计,通过 NtSetInformationAlpcPort 注册 |
| 模型三的性能优势 | 回调内联执行在发送方线程上下文中,理论上零上下文切换 |
| 模型三的风险 | 多客户端并发时,回调所在上下文未必等于消息真正的发送者 |
| 风险应对方式 | 依据 PORT_HEADER 中的 Client ID 重新关联正确的发送者状态 |
| ALPC 完成列表 vs I/O 完成端口 | 两个不同的机制,名字相似但不可混淆 |
ALPC 共享内存机制:Views、Regions 与 Sections 详解
一、为什么要引入共享内存
在最基础的 LPC/ALPC 通信里,客户端和服务器之间传消息,靠的是把数据从一个进程的缓冲区拷贝到另一个进程的缓冲区。这种方式简单可靠,但当传输的数据量很大时(比如几十 MB 的文件内容、图像数据),反复拷贝会带来明显的性能损耗。
Windows 内存管理器为此提供了一个更高效的机制:section object(节对象)。这是 Windows 内存管理体系里最核心的对象之一(在 Part 1 第 5 章有详细介绍)。它的思路很直接:
- 分配一块内存,把它标记为"共享";
- 让客户端和服务器同时看到这块内存的同一份内容(consistent、equal 的 view);
- 谁写了数据,写进去的瞬间对方就能读到,因为本质上是同一块物理内存被映射到了两个进程各自的虚拟地址空间。
这样一来,理论上能传输的数据量只受这块共享内存大小的限制,而且不需要"拷贝一次再拷贝一次",效率自然更高。
二、共享内存带来的安全问题
天下没有免费的午餐。共享内存虽然快,但正因为双方都能直接访问同一块内存,也带来了两个典型的安全隐患:
问题一:低权限客户端可能污染/攻击服务器
因为客户端对这块内存也有访问权限,一个恶意的、权限很低的客户端理论上可以:
- 直接篡改服务器要用的共享数据,造成服务器逻辑错误甚至崩溃;
- 精心构造一段数据,布置成可执行的攻击载荷(payload),为后续的漏洞利用做铺垫。
问题二:绕过 ASLR
ASLR(地址空间布局随机化,详见 Part 1 第 5 章)本质上是靠"攻击者不知道目标数据/代码在内存里的确切位置"来提高攻击门槛的。但共享内存机制下,客户端天生就知道这块共享数据在自己地址空间里的位置——因为它就是通过映射拿到的。如果服务器端的某些关键地址信息也存在于这块共享内存附近,攻击者就有可能借此推算出服务器的实际地址布局,让 ALSR 的防护形同虚设。
正因为这两个问题,ALPC 没有简单粗暴地直接用 section object,而是在其之上包了一层自己的安全机制。
三、ALPC 的三层结构:Section → Region → View
ALPC 在原生 section object 的基础上,构建了一套三层的抽象,从"整体"到"局部"再到"每个进程的具体视角":
3.1 Section(节)—— 最外层的共享内存对象
- 通过专门的 API
NtAlpcCreatePortSection来创建,而不是直接用通用的 section 创建接口。 - 这样做的好处是:ALPC 可以在创建时就建立好"这个 section 属于哪个 port(端口)"的引用关系,方便后续管理。
- 同时支持自动垃圾回收:当这个 section 不再被任何东西引用时,系统会自动把它清理掉,不需要显式调用删除。当然,如果需要手动控制,也存在对应的删除 API。
可以把 Section 理解成"划出的一整块地皮"。
3.2 Region(区域)—— Section 内被实际用到的一段地址范围
- 当 Section 的所有者(通常是 Server)开始真正使用这块共享内存时,实际被用到的那一段地址范围,就被称为一个 Region。
- 每次一个 Region 被用于某条消息,都会给这条消息增加一个引用——这也是为什么原文说 region “add an extra reference to the message”。这个引用计数机制,正是前面提到的"自动垃圾回收"能够正常工作的基础:只有当所有引用都释放了,Region(进而 Section)才会被真正回收。
- 有一条重要限制:在同一个 port 的上下文里,对于给定的一段共享内存范围,只能打开一个 Region。换句话说,不能对同一段地址范围重复开多个 Region——这是为了避免管理混乱和潜在的安全隐患。
可以把 Region 理解成"地皮上具体划分出来、正在使用的那一块地基"。
3.3 View(视图)—— 每个进程眼中的"本地映射"
- Region 只是描述了共享内存中"哪一段被用了",但每个进程要实际访问这段内存,还得把它映射到自己的地址空间里。这个映射的结果,就是这个进程拿到的 View。
- 同一个 Region,可以被多个不同进程分别映射出各自的 View——但每个 View 在各自进程的地址空间里的具体虚拟地址,不一定相同。
可以把 View 理解成"你站在自己家阳台上看到的那块地基的样子"——地基是同一个,但每个人是从自己家的角度去看的。
四、Region 的两项关键安全设置
原文特别强调了 Region 支持的两种安全选项,这也是 ALPC 相比传统共享内存 IPC 更安全的核心所在。
4.1 安全模式(secure)与非安全模式(unsecure)
- 非安全模式(unsecure):一个 Region 可以有任意多个 View——也就是可以有很多个客户端同时映射这段共享内存。适合"一对多广播式"的共享数据场景。
- 安全模式(secure):一个 Region 最多只能有 2 个 View。这通常用在服务器想要私密地跟单个客户端进程共享数据的场景——比如一对一的敏感会话数据。因为限制死了只有 2 个 view(通常是 Server 自己一个,Client 一个),第三方即便知道这个 Region 的存在,也无法再挂一个 View 上去偷窥或篡改。
4.2 写保护(write-access protection)
- 即便是允许多个 View 的场景,ALPC 也可以设置成:只有一个进程上下文(通常是 Server)拥有写权限,其余所有的客户端 View 都是只读的。
- 这是通过底层的
MmSecureVirtualMemoryAgainstWrites这个内存管理器 API 实现的——它会在页表层面把对应的页标记为不可写,客户端一旦尝试写入,就会触发访问违例(access violation),被系统直接拦下。
写权限持有者数量≤1(在一个 Region 内) \text{写权限持有者数量} \le 1 \quad (\text{在一个 Region 内}) 写权限持有者数量≤1(在一个 Region 内)
View 数量{≤2,secure 模式无限制,unsecure 模式 \text{View 数量} \begin{cases} \le 2, & \text{secure 模式} \\ \text{无限制}, & \text{unsecure 模式} \end{cases} View 数量{≤2,无限制,secure 模式unsecure 模式
五、这套机制解决了什么问题
回到第二节提到的两个安全问题,来看看 Section/Region/View 加上这两项安全设置是怎么应对的:
| 安全风险 | 对应缓解机制 |
|---|---|
| 恶意客户端篡改共享内存、构造攻击载荷 | 写保护(write-access protection)——客户端默认只读,写权限唯一收归 Server |
| 客户端借共享内存位置推算服务器地址、绕过 ASLR | secure 模式限制 View 数量为 2,缩小暴露面;配合引用计数的自动垃圾回收,避免陈旧映射长期暴露 |
| 同一地址范围被重复、混乱地打开多个 Region,管理失控 | "同一 port 下同一地址范围只能开一个 Region"的强制约束 |
总结一句话:ALPC 并没有完全信任底层 section object 提供的共享内存能力,而是在它之上又叠了一层自己的引用计数管理 + 访问粒度控制(secure 模式 + 写保护),从而在保留高性能共享内存传输优势的同时,把很多典型的提权攻击路径给堵死了。
六、完整可运行的 C++ 代码模拟
下面这段代码用 C++ 模拟了 Section → Region → View 三层结构,以及 secure 模式限流、写权限唯一性、访问违例拦截、region 去重这四个关键安全行为。代码不依赖任何第三方库,只用标准库(<memory>、<map>、<vector> 等),可以直接编译运行。
// alpc_section_sim.cpp
// 用途:模拟 ALPC 中 Section / Region / View 三层结构的行为
// 重点还原:
// 1. Section —— 一整块共享内存对象(对应 NtAlpcCreatePortSection)
// 2. Region —— Section 内被使用的一段地址范围,带引用计数(垃圾回收用)
// 3. View —— 某个进程对 Region 的本地映射
// 4. 安全模式(secure/unsecure):secure 模式下一个 Region 最多只能有 2 个 View
// 5. 写保护:只有 Server 进程拥有写权限,其余进程只读
//
// 编译方式(不使用万能头文件,只用标准库):
// g++ -std=c++17 -O2 -o alpc_section_sim alpc_section_sim.cpp
// 运行:
// ./alpc_section_sim
#include <iostream> // 标准输入输出流
#include <vector> // std::vector 容器
#include <string> // std::string 字符串
#include <memory> // std::shared_ptr 智能指针,用于模拟引用计数(对应 Region 的自动垃圾回收)
#include <stdexcept> // std::runtime_error 异常类型
#include <map> // std::map 有序关联容器
// ------------------------------------------------------------------
// 权限枚举:区分"只读"和"读写",对应原文中 MmSecureVirtualMemoryAgainstWrites
// 所实现的"只有 Server 可写,其他进程只读"的机制
// ------------------------------------------------------------------
enum class AccessMode {
ReadOnly, // 只读视图
ReadWrite // 读写视图(通常只授予 Server)
};
// 把权限枚举转成字符串,方便打印调试信息
std::string accessModeToString(AccessMode mode) {
return (mode == AccessMode::ReadWrite) ? "读写(ReadWrite)" : "只读(ReadOnly)";
}
// ------------------------------------------------------------------
// View 类:代表某一个进程对 Region 的"本地映射"
// ------------------------------------------------------------------
class View {
public:
View(std::string ownerProcess, AccessMode mode)
: ownerProcess_(std::move(ownerProcess)), mode_(mode) {}
// 只读打印接口,展示这个 view 属于哪个进程、权限是什么
void describe() const {
std::cout << " [View] 所属进程=" << ownerProcess_
<< " 权限=" << accessModeToString(mode_) << "\n";
}
// 写入操作:如果不是 ReadWrite 权限,直接抛异常
// 这里模拟内核态对非法写操作的拦截(真实系统里是页表权限位 + 缺页异常处理完成的)
void write(const std::string& data) {
if (mode_ != AccessMode::ReadWrite) {
// 用异常来模拟"访问违例"(Access Violation)
throw std::runtime_error(
"进程[" + ownerProcess_ + "] 试图写入只读视图,触发访问违例(Access Violation)!");
}
std::cout << " 进程[" << ownerProcess_ << "] 写入数据: \"" << data << "\"\n";
}
const std::string& owner() const { return ownerProcess_; }
private:
std::string ownerProcess_; // 拥有这个 view 的进程名(模拟用,真实系统是进程句柄/EPROCESS)
AccessMode mode_; // 该 view 的访问权限
};
// ------------------------------------------------------------------
// Region 类:代表 Section 内一段被实际使用的地址范围
//
// 关键点:
// - secure 模式下最多只能有 2 个 view(一对一私有通信场景)
// - 使用 shared_ptr 风格的引用计数模拟"自动垃圾回收"
// ------------------------------------------------------------------
class Region {
public:
Region(std::string name, size_t sizeBytes, bool secureMode)
: name_(std::move(name)), sizeBytes_(sizeBytes), secureMode_(secureMode) {
std::cout << "[Region] 创建区域 \"" << name_ << "\" 大小=" << sizeBytes_
<< " 字节 模式=" << (secureMode_ ? "安全(secure)" : "非安全(unsecure)") << "\n";
}
// 析构时打印日志,模拟"引用计数归零后自动垃圾回收"
~Region() {
std::cout << "[Region] 引用计数归零,区域 \"" << name_ << "\" 被自动垃圾回收\n";
}
// 创建一个新的 view(映射)
// 参数 processName: 请求映射的进程名
// 参数 requestedMode: 请求的访问权限
View& createView(const std::string& processName, AccessMode requestedMode) {
// secure 模式下强制限制:最多 2 个 view
if (secureMode_ && views_.size() >= 2) {
throw std::runtime_error(
"区域 \"" + name_ + "\" 处于安全模式,已达到最大视图数(2),拒绝进程["
+ processName + "]的映射请求");
}
// 写权限约束:整个区域中只允许一个进程持有 ReadWrite 权限
if (requestedMode == AccessMode::ReadWrite && writerAssigned_) {
throw std::runtime_error(
"区域 \"" + name_ + "\" 已经存在写权限持有者["
+ writerName_ + "],拒绝再次授予进程[" + processName + "]写权限");
}
if (requestedMode == AccessMode::ReadWrite) {
writerAssigned_ = true;
writerName_ = processName;
}
views_.emplace_back(std::make_unique<View>(processName, requestedMode));
std::cout << "[Region] 进程[" << processName << "] 成功获得视图,权限="
<< accessModeToString(requestedMode)
<< " (当前视图总数=" << views_.size() << ")\n";
return *views_.back();
}
void listViews() const {
std::cout << " 区域 \"" << name_ << "\" 当前视图列表:\n";
for (const auto& v : views_) {
v->describe();
}
}
const std::string& name() const { return name_; }
private:
std::string name_; // Region 名称
size_t sizeBytes_; // Region 大小
bool secureMode_; // 是否处于安全模式(限制最多2个view)
bool writerAssigned_ = false; // 是否已经有进程持有写权限
std::string writerName_; // 持有写权限的进程名
std::vector<std::unique_ptr<View>> views_; // 该区域下所有的 view
};
// ------------------------------------------------------------------
// Section 类:代表整个共享内存对象
// 用 shared_ptr<Region> 来模拟"每次使用 region 都增加一个引用"
// ------------------------------------------------------------------
class ALPCSection {
public:
explicit ALPCSection(std::string portName)
: portName_(std::move(portName)) {
std::cout << "\n=== 通过 NtAlpcCreatePortSection 创建 ALPC Section,绑定端口: "
<< portName_ << " ===\n";
}
// 在 section 中创建一个新的 region(分配一段共享地址范围)
// 返回 shared_ptr,模拟"每个持有者都会增加引用计数"
std::shared_ptr<Region> createRegion(const std::string& regionName,
size_t sizeBytes,
bool secureMode) {
// 原文强调:"only one region for a given range of shared memory
// can be opened from within the context of a given port."
// 这里用 map 检查是否该范围(用 regionName 简化代表地址范围)已经被打开过
if (regions_.find(regionName) != regions_.end()) {
throw std::runtime_error(
"端口[" + portName_ + "]中区域 \"" + regionName + "\" 已存在,"
"同一端口下同一地址范围不能重复打开region");
}
auto region = std::make_shared<Region>(regionName, sizeBytes, secureMode);
regions_[regionName] = region; // section 自身持有一份引用
return region;
}
private:
std::string portName_;
std::map<std::string, std::shared_ptr<Region>> regions_; // regionName -> Region
};
// ------------------------------------------------------------------
// 主函数:演示四种典型场景
// ------------------------------------------------------------------
int main() {
std::cout << "======================================\n";
std::cout << " ALPC Section / Region / View 机制模拟\n";
std::cout << "======================================\n";
// ---------- 场景1:非安全模式,一写多读 ----------
std::cout << "\n----- 场景1:非安全模式,Server写,多个Client只读 -----\n";
ALPCSection section1("\\RPC Control\\MyAlpcPort");
auto region1 = section1.createRegion("SharedBuffer_A", 4096, /*secureMode=*/false);
region1->createView("Server", AccessMode::ReadWrite);
region1->createView("Client_1", AccessMode::ReadOnly);
region1->createView("Client_2", AccessMode::ReadOnly);
region1->listViews();
// ---------- 场景2:安全模式,最多2个view ----------
std::cout << "\n----- 场景2:安全模式,最多允许2个View(一对一私有通信)-----\n";
auto region2 = section1.createRegion("PrivateBuffer_B", 8192, /*secureMode=*/true);
region2->createView("Server", AccessMode::ReadWrite);
region2->createView("Client_VIP", AccessMode::ReadOnly);
try {
// 第三个view请求,应当被拒绝
region2->createView("Client_Intruder", AccessMode::ReadOnly);
} catch (const std::exception& e) {
std::cout << " [预期内的异常] " << e.what() << "\n";
}
// ---------- 场景3:只读视图尝试写入,触发访问违例 ----------
std::cout << "\n----- 场景3:只读视图尝试写操作 -----\n";
ALPCSection section3("\\RPC Control\\AnotherPort");
auto region3 = section3.createRegion("SharedBuffer_C", 2048, false);
View& writerView = region3->createView("Server", AccessMode::ReadWrite);
View& readerView = region3->createView("Client_ReadOnly", AccessMode::ReadOnly);
writerView.write("Hello from Server"); // 正常
try {
readerView.write("Malicious data"); // 触发异常
} catch (const std::exception& e) {
std::cout << " [预期内的异常] " << e.what() << "\n";
}
// ---------- 场景4:同一端口下重复打开同一region范围,应当失败 ----------
std::cout << "\n----- 场景4:同一端口重复打开同一区域范围 -----\n";
try {
section1.createRegion("SharedBuffer_A", 4096, false);
} catch (const std::exception& e) {
std::cout << " [预期内的异常] " << e.what() << "\n";
}
std::cout << "\n程序结束,作用域内的 shared_ptr<Region> 全部释放,"
"触发自动垃圾回收(析构函数打印)\n";
return 0;
}
6.1 代码整体结构说明
代码一共设计了 4 个类,正好对应文章里的 4 个核心概念:
| 类名 | 对应概念 | 核心职责 |
|---|---|---|
AccessMode(枚举) |
View 的访问权限 | 区分 ReadOnly(只读)和 ReadWrite(读写)两种状态 |
View |
View(视图) | 保存某进程对某段内存的映射信息,写操作会检查权限 |
Region |
Region(区域) | 管理一批 View,负责 secure 模式限流、写权限唯一性检查 |
ALPCSection |
Section(节对象) | 管理一批 Region,负责防止同一地址范围重复开 Region |
6.2 关键实现点逐一拆解
(1) 用 enum class AccessMode 表达权限
enum class AccessMode {
ReadOnly,
ReadWrite
};
用强类型枚举(enum class)而不是普通 enum,是为了避免它跟其他整数类型发生隐式转换导致的 bug——这是现代 C++ 里的常见最佳实践,虽然跟原文主题无关,但值得顺手写对。
(2) View::write 里用异常模拟"访问违例"
void write(const std::string& data) {
if (mode_ != AccessMode::ReadWrite) {
throw std::runtime_error(...);
}
...
}
真实的 Windows 系统里,MmSecureVirtualMemoryAgainstWrites 是在页表层面把对应内存页设置成不可写。一旦某个进程试图写入这样的页,CPU 会触发缺页异常(page fault),内核的异常处理程序识别出这是"写只读页",进而以访问违例(Access Violation,对应 STATUS_ACCESS_VIOLATION)的形式终止这次非法操作。
在用户态模拟这套机制没办法真的操作页表,所以退而求其次,用 C++ 的异常机制(throw std::runtime_error)来表达"这次操作被拒绝"的语义,重点是让读者理解行为逻辑,而不是真的去操作内存页保护位。
(3) Region::createView 里的两个核心检查
View& createView(const std::string& processName, AccessMode requestedMode) {
// 检查一:secure 模式下最多 2 个 view
if (secureMode_ && views_.size() >= 2) {
throw std::runtime_error(...);
}
// 检查二:整个 region 只能有一个写权限持有者
if (requestedMode == AccessMode::ReadWrite && writerAssigned_) {
throw std::runtime_error(...);
}
...
}
这两行 if 判断,正是文章里两条安全规则的直接代码化:
secureMode_ && views_.size() >= 2—— 对应"In the secure mode, only two views (mappings) are allowed to the region";requestedMode == AccessMode::ReadWrite && writerAssigned_—— 对应"regions can also be marked with write-access protection, which enables only one process context (the server) to have write access"。
(4)ALPCSection::createRegion用std::map防止重复开 Region
std::shared_ptr<Region> createRegion(const std::string& regionName, ...) {
if (regions_.find(regionName) != regions_.end()) {
throw std::runtime_error(... "同一端口下同一地址范围不能重复打开region");
}
auto region = std::make_shared<Region>(...);
regions_[regionName] = region;
return region;
}
这里简化处理:用 regionName(字符串)当作"地址范围"的代号,实际系统里这应该是一段真实的地址区间(起始地址 + 长度)。用 map::find 检查是否已存在同名 region,对应文章里"only one region for a given range of shared memory can be opened from within the context of a given port"这条限制。
(5) 用 std::shared_ptr<Region> 模拟引用计数与自动垃圾回收
auto region = std::make_shared<Region>(regionName, sizeBytes, secureMode);
regions_[regionName] = region;
shared_ptr 本身自带引用计数:每多一个 shared_ptr 指向同一个对象,计数加一;每少一个(离开作用域、被重置等),计数减一;计数归零时,对象自动析构。这跟原文描述的"Region 会在被引用的过程中增加引用计数,未被引用时自动垃圾回收"的语义高度吻合,所以直接借用 shared_ptr 的语言特性来实现,不用自己手写引用计数逻辑。Region 类的析构函数里打印了一行日志:
~Region() {
std::cout << "[Region] 引用计数归零,区域 \"" << name_ << "\" 被自动垃圾回收\n";
}
这样运行时就能直观看到"什么时候某个 Region 真正被回收了"。
6.3 程序运行流程图
6.4 实际运行结果(已验证可编译运行)
程序用 g++ -std=c++17 编译通过,实际运行后四个场景均按预期工作:
- 场景1:Server 获得读写权限,两个 Client 均获得只读权限,视图总数正确累加到 3(后续又演示性地加了一个只读 view 到 4)。
- 场景2:secure 模式下,第三个 View 请求被正确拦截,抛出"已达到最大视图数(2)"的异常。
- 场景3:Server 写入成功,Client 的只读视图写入被正确拦截,抛出"访问违例"异常。
- 场景4:在同一端口下重复创建同名 Region,被正确拦截。
- 程序结束时,三个 Region 对象依次触发析构函数,打印"引用计数归零,自动垃圾回收"的日志,验证了
shared_ptr引用计数机制工作正常。
七、几个概念的快速对比总结
| 层级 | 类比 | 对应真实系统里的角色 | 生命周期管理 |
|---|---|---|---|
| Section | 一整块地皮 | NtAlpcCreatePortSection 创建的 section object |
绑定到 ALPC port,支持自动垃圾回收 |
| Region | 地皮上正在使用的一块地基 | Section 内一段被实际分配使用的地址范围 | 带引用计数,每次被消息引用就加一 |
| View | 你站在自家阳台看到的这块地基 | 某个进程对 Region 的本地映射(一个虚拟地址范围) | 映射存在期间有效,可撤销 |
理解了这三层关系之后,再看 secure 模式和写保护这两个安全选项,其实就是在限制 View 这一层的数量和权限,从而堵住"客户端拿共享内存做文章"的攻击路径——这也是 ALPC 相比其他 IPC 机制,在处理共享内存这个"双刃剑"特性时更谨慎、更安全的地方。
ALPC 消息属性(Attributes)详解
一、先搞清楚"属性"到底是什么
ALPC(Advanced Local Procedure Call,高级本地过程调用)不是简单的"把一段字节从进程 A 送到进程 B"。它在消息之外,还允许附加一份上下文信息,这份信息由内核负责管理,包括:
- 这份信息什么时候有效(有效性)
- 这份信息能存活多久(生命周期)
- 这份信息具体怎么实现(实现细节)
这份附加信息,ALPC 统一称之为属性(Attribute)。它分两种来源: - 系统管理的属性:内核自己生成、维护,用户只能读取,不能随意篡改。
- 用户管理的属性:调用者(客户端或服务端)自己定义、自己解释的数据,内核只负责搬运和保管,不关心内容含义。
内核一共内置管理了 7 种属性,理解它们是理解 ALPC 安全模型、句柄传递机制、同步机制的基础。
| 属性名称 | 一句话概括 |
|---|---|
| 安全属性 Security | 客户端身份模拟与高级安全功能 |
| 数据视图属性 Data View | 管理共享内存节区的视图映射 |
| 上下文属性 Context | 用户自定义上下文指针 + 内核维护的序号/ID |
| 句柄属性 Handle | 随消息传递的内核对象句柄 |
| 令牌属性 Token | 轻量获取发送者身份标识(不能模拟) |
| 直接属性 Direct | 直接消息关联的同步对象 |
| 代理工作属性 Work-On-Behalf-Of | 电源管理与资源调度的工作票据 |
下面逐一展开讲解。
二、七种属性逐个拆解
1. 安全属性(Security Attribute)
这是与身份模拟(Impersonation)直接相关的属性。
当客户端向服务端发送消息时,服务端有时需要"扮演"客户端的身份去执行某些操作(例如检查客户端是否有权限访问某个文件)。这个"扮演"的过程在 Windows 安全模型中叫模拟(Impersonation)。
安全属性携带的关键信息就是支持这种模拟所需的凭据数据(比如访问令牌相关的引用)。除此之外,ALPC 还built-in 了一套更高级的安全功能(比如安全上下文的建立、撤销、验证),这些都挂靠在安全属性之上。
可以把它类比为:客户端往消息里塞了一张"临时授权卡",服务端处理消息时可以拿着这张卡去执行原本只有客户端才能做的事情。
2. 数据视图属性(Data View Attribute)
ALPC 支持通过节区对象(Section Object)(也就是共享内存)在客户端和服务端之间传递大块数据,而不是把所有数据都塞进消息本身(消息通常有大小限制)。
数据视图属性负责管理这块共享内存在双方地址空间中的视图(View)——也就是这块物理内存被映射到进程虚拟地址空间的哪个位置、映射了多大范围。
它还承担两个具体职责:
- 设置自动释放标志(auto-release flag):当消息处理完毕后,是否自动把这个视图从地址空间中撤销映射,避免内存泄漏或者进程间数据继续可见的安全隐患。
- 在**回复消息(Reply)**时,支持手动撤销映射(unmap)某个视图。
简单说:这是"大宗货物"的物流管理员,负责共享内存这块地皮该怎么开、什么时候收回。
3. 上下文属性(Context Attribute)
这是用户自定义程度最高的一个属性。它允许调用者:
- 在**端口(Port)**上挂一个自定义的上下文指针(比如服务端可以把某个端口和它对应的内部数据结构关联起来)。
- 在某一条具体消息上也挂一个上下文指针(区别于端口级别的上下文,这是消息级别的)。
除了用户自己的指针,内核还会在这个属性里自动维护三样东西: - 序列号(Sequence Number):保证消息可以被排序、追踪。
- 消息 ID(Message ID):唯一标识一条消息,便于做基于消息的哈希(比如快速查找某条消息对应的等待者)。
- 回调 ID(Callback ID):用于支持异步回调场景下,回复能正确匹配回原来的请求。
这三样内核维护的数据,让上层的 ALPC 使用者可以自己实现唯一性判断、基于消息的哈希表、请求-响应的顺序化管理,而不需要每个使用者各自造轮子。
这也是为什么前面说:上下文属性和令牌属性属于"内核内部总是关联好的"那一类——不管调用者要不要,内核都顺手把这些序号信息记好了,随时可以查询。
4. 句柄属性(Handle Attribute)
Windows 中大量资源(文件、事件、信号量、互斥体等)都是通过句柄(Handle)来引用的。但句柄本质上只在某一个进程的句柄表里有意义,A 进程的句柄值 5 在 B 进程里可能完全对应着另一个不相关的对象,甚至是无效的。
句柄属性就是用来描述:这条消息想要传递哪些句柄给对方。内核会读取这个属性里指定的句柄,在内核层面找到真正的对象,然后在接收方进程的句柄表里创建一个新的、指向同一个内核对象的句柄。
这个过程称为句柄传递(Handle Passing),后面章节会更详细讨论其具体机制。
5. 令牌属性(Token Attribute)
令牌属性和安全属性看起来有点像,都跟"身份"有关,但目的完全不同、代价也完全不同:
- 安全属性:完整的安全上下文,可以拿来做模拟(Impersonation),代价较高(需要构建完整的安全令牌相关结构)。
- 令牌属性:只提供三个轻量的身份标识字段——
令牌属性={Token ID, Authentication ID, Modified ID} \text{令牌属性} = \{\text{Token ID},\ \text{Authentication ID},\ \text{Modified ID}\} 令牌属性={Token ID, Authentication ID, Modified ID}
这三个 ID 可以让服务端判断消息发送者的身份是否一致、是否被修改过,比如用来做快速的身份校验、缓存键、审计日志,但是拿着这三个 ID 无法去模拟客户端,因为它没有携带完整的、可用于模拟的令牌数据。
一句话总结区别:令牌属性是"看一眼你是谁",安全属性是"我可以变成你"。
6. 直接属性(Direct Attribute)
ALPC 支持一种叫直接消息(Direct Message)的发送模式,这种模式下,消息会关联一个同步对象(Synchronization Object),用于实现更精细的等待/通知语义(后面"直接事件"章节详细讲)。
直接属性就是用来携带、管理这个同步对象相关信息的属性。可以理解为:普通消息发送后你只能等"有没有回复",而直接消息可以更精确地控制"什么事件发生了就唤醒等待者"。
7. 代理工作属性(Work-On-Behalf-Of Attribute)
这是一个偏系统调度层面的属性。当线程 A 因为处理某个消息,实际上是在代表线程 B 做工作时(比如服务端线程在处理客户端的一次同步调用请求),系统需要知道这一层"代理关系",从而做出更合理的电源管理和资源管理决策。
举个例子:如果客户端线程因为等待服务端处理而被挂起,服务端线程实际上是在为客户端"卖力干活",那么这个服务端线程的调度优先级、CPU 占用份额等,理应参考客户端线程的状态,而不是被系统当作一个孤立的、不重要的后台线程对待。
代理工作属性就是编码了这样一张"工作委托票据(Work Ticket)",让内核的电源管理器和调度器能做出更精准的判断。
三、属性的"可见性"模型
原文提到一个很关键的设计点:
一部分属性是客户端或服务端在发送消息的时候传进去的,内核会把它转换成自己内部的表示形式。如果 ALPC 的使用者想要把这份数据要回来,内核会安全地把它暴露出来。
也就是说,从数据流向上看,属性经历了这样一个过程:
用户传入的属性数据→内核转换内核内部表示→用户请求时安全地暴露给用户 \text{用户传入的属性数据} \xrightarrow{\text{内核转换}} \text{内核内部表示} \xrightarrow{\text{用户请求时}} \text{安全地暴露给用户} 用户传入的属性数据内核转换内核内部表示用户请求时安全地暴露给用户
但并不是所有属性都需要"用户传入"才会存在。原文特别强调:
在少数情况下,服务端或客户端总是可以请求某个属性,因为这是 ALPC 内部自动跟消息关联好、并且始终可用的(比如上下文属性或令牌属性)。
也就是说,七种属性可以按"谁来决定它存不存在"分成两类:
| 类别 | 包含属性 | 特点 |
|---|---|---|
| 内核自动关联,始终可查 | 上下文属性、令牌属性 | 不需要显式传入即可读取,内核自动维护 |
| 按需传入,用户决定 | 安全属性、数据视图属性、句柄属性、直接属性、代理工作属性 | 需要发送方在发消息时显式指定 |
四、为什么这样设计:不透明性 + 内核持有真实指针
原文最后点出了这套模型的核心价值:
通过实现这种模型,并结合自己内部的句柄表,ALPC 能够让关键数据在客户端和服务端之间保持不透明,同时在内核态维持真实的指针。
这句话拆开理解:
- 不透明(Opaque):客户端和服务端拿到的,都不是直接的内核指针,而是内核给它们的"代号"或者"引用"。哪怕客户端想伪造、篡改这些引用,也无法直接操纵内核对象。
- 内核持有真实指针:真正的内存地址、对象指针,只存在于内核态的内部表示里,用户态永远接触不到。
- 内核自己的句柄表:这是实现不透明性的关键机制——内核维护一张独立于普通进程句柄表之外的表,专门用来管理 ALPC 内部这些属性所引用的对象。
这套设计的好处是显而易见的:安全性(用户态无法伪造指针来越权访问)加上灵活性(内核可以随时改变内部实现,而不影响暴露给用户的接口)。
五、相关 API
原文提到,ALPC 内部消费者可以用这些 API 来定义、读取属性:
AlpcInitializeMessageAttribute:初始化一个消息属性结构(分配、清零、设置好属性掩码)。AlpcGetMessageAttribute:从一条已有消息里,把某个具体属性的数据取出来。
这些是 Windows 内核内部(以及部分驱动开发场景)使用的接口,属于 NT 内核 API 范畴,不是常规 Win32 应用程序会直接调用的接口。
六、用一段可运行的 C++ 代码模拟属性机制
下面这段代码不是调用真实的 Windows ALPC 内核 API(那些是内核态接口,普通用户态程序无法直接调用),而是用标准 C++ 模拟出 ALPC 属性系统的核心思想:
- 一条消息可以携带多种属性
- 每种属性有自己的类型和数据
- 有的属性内核自动维护(如上下文、令牌),有的需要显式传入(如安全、句柄)
- 通过"属性表"来实现按需查询、按位掩码判断是否存在某个属性
这样写的目的是让你脱离 Windows 内核环境也能直接编译运行,通过运行结果直观感受这套属性模型是怎么工作的。
#include <iostream>
#include <string>
#include <map>
#include <memory>
#include <cstdint>
#include <bitset>
#include <stdexcept>
// ------------------------------------------------------------------
// 用位掩码表示七种属性,对应真实 ALPC 中的属性标志位思路。
// 每一位代表一种属性是否存在于某条消息中。
// ------------------------------------------------------------------
enum class AttributeFlag : uint32_t {
Security = 1u << 0, // 安全属性:支持模拟
DataView = 1u << 1, // 数据视图属性:管理共享内存视图
Context = 1u << 2, // 上下文属性:用户上下文 + 内核自动维护的序号
Handle = 1u << 3, // 句柄属性:跨进程传递句柄
Token = 1u << 4, // 令牌属性:轻量身份标识
Direct = 1u << 5, // 直接属性:关联同步对象
WorkOnBehalfOf = 1u << 6 // 代理工作属性:电源/资源调度票据
};
// 按位或运算符重载,方便组合多个属性标志
inline uint32_t operator|(AttributeFlag a, AttributeFlag b) {
return static_cast<uint32_t>(a) | static_cast<uint32_t>(b);
}
inline uint32_t operator|(uint32_t a, AttributeFlag b) {
return a | static_cast<uint32_t>(b);
}
// |= 运算符重载:让 present_attributes_ |= AttributeFlag::Security 这种写法能够编译通过
inline uint32_t& operator|=(uint32_t& a, AttributeFlag b) {
a = a | static_cast<uint32_t>(b);
return a;
}
// ------------------------------------------------------------------
// 上下文属性的数据结构。
// context_ptr : 用户自定义的上下文指针(这里用字符串模拟指代的数据)
// sequence_no : 内核自动维护的序列号,用于排序、去重
// message_id : 内核自动维护的消息唯一 ID
// callback_id : 内核自动维护的回调 ID,用于异步回复匹配
// ------------------------------------------------------------------
struct ContextAttribute {
std::string context_ptr;
uint64_t sequence_no;
uint64_t message_id;
uint64_t callback_id;
};
// 令牌属性:只携带三个轻量身份字段,不能用于模拟
struct TokenAttribute {
uint64_t token_id;
uint64_t authentication_id;
uint64_t modified_id;
};
// 安全属性:携带可用于模拟客户端的凭据引用(这里简化为一个字符串句柄)
struct SecurityAttribute {
std::string impersonation_handle;
bool allow_impersonation;
};
// 句柄属性:描述本条消息想要传递哪些句柄给接收方
struct HandleAttribute {
std::vector<int> handles_to_transfer;
};
// 数据视图属性:管理共享内存节区映射信息
struct DataViewAttribute {
uint64_t section_base_address;
uint64_t view_size;
bool auto_release;
};
// 直接属性:关联的同步对象名称(简化模拟)
struct DirectAttribute {
std::string sync_object_name;
};
// 代理工作属性:电源/资源调度用的工作票据
struct WorkOnBehalfOfAttribute {
uint64_t on_behalf_of_thread_id;
};
// ------------------------------------------------------------------
// ALPC 消息类:内部持有一个"内核内部表示",
// 用户只能通过 GetXxxAttribute / SetXxxAttribute 接口访问,
// 模拟真实 ALPC 中"内核持有真实数据,用户态只拿到安全暴露的副本"这一设计。
// ------------------------------------------------------------------
class AlpcMessage {
public:
// 构造时,上下文属性和令牌属性由内核自动生成,始终存在
// 这里用一个静态计数器模拟内核自动分配递增的序列号 / 消息 ID
AlpcMessage() {
static uint64_t g_sequence_counter = 0;
static uint64_t g_message_id_counter = 1000;
present_attributes_ = AttributeFlag::Context | AttributeFlag::Token;
context_.sequence_no = ++g_sequence_counter;
context_.message_id = ++g_message_id_counter;
context_.callback_id = 0; // 默认没有回调,异步场景下才会设置
context_.context_ptr = ""; // 用户还没设置自定义上下文
// 令牌属性由内核在消息创建时自动填充发送者身份信息(这里用固定值模拟)
token_.token_id = 1;
token_.authentication_id = 2001;
token_.modified_id = 1;
}
// -------------------- 上下文属性(内核自动维护,用户可追加自定义指针) --------------------
void SetUserContext(const std::string& ptr) {
context_.context_ptr = ptr;
}
const ContextAttribute& GetContextAttribute() const {
// 上下文属性内核始终关联,不需要判断是否存在
return context_;
}
// -------------------- 令牌属性(内核自动维护,始终可查) --------------------
const TokenAttribute& GetTokenAttribute() const {
return token_;
}
// -------------------- 安全属性(需要显式设置才存在) --------------------
void SetSecurityAttribute(const SecurityAttribute& attr) {
security_ = attr;
present_attributes_ |= AttributeFlag::Security;
}
const SecurityAttribute& GetSecurityAttribute() const {
RequireAttribute(AttributeFlag::Security, "安全属性");
return security_;
}
// -------------------- 数据视图属性 --------------------
void SetDataViewAttribute(const DataViewAttribute& attr) {
data_view_ = attr;
present_attributes_ |= AttributeFlag::DataView;
}
const DataViewAttribute& GetDataViewAttribute() const {
RequireAttribute(AttributeFlag::DataView, "数据视图属性");
return data_view_;
}
// -------------------- 句柄属性 --------------------
void SetHandleAttribute(const HandleAttribute& attr) {
handle_ = attr;
present_attributes_ |= AttributeFlag::Handle;
}
const HandleAttribute& GetHandleAttribute() const {
RequireAttribute(AttributeFlag::Handle, "句柄属性");
return handle_;
}
// -------------------- 直接属性 --------------------
void SetDirectAttribute(const DirectAttribute& attr) {
direct_ = attr;
present_attributes_ |= AttributeFlag::Direct;
}
const DirectAttribute& GetDirectAttribute() const {
RequireAttribute(AttributeFlag::Direct, "直接属性");
return direct_;
}
// -------------------- 代理工作属性 --------------------
void SetWorkOnBehalfOfAttribute(const WorkOnBehalfOfAttribute& attr) {
work_on_behalf_of_ = attr;
present_attributes_ |= AttributeFlag::WorkOnBehalfOf;
}
const WorkOnBehalfOfAttribute& GetWorkOnBehalfOfAttribute() const {
RequireAttribute(AttributeFlag::WorkOnBehalfOf, "代理工作属性");
return work_on_behalf_of_;
}
// 打印当前消息一共携带了哪些属性,模拟调试时查看属性掩码的场景
void PrintPresentAttributes() const {
std::cout << "当前消息包含的属性: ";
if (present_attributes_ & static_cast<uint32_t>(AttributeFlag::Security))
std::cout << "[安全] ";
if (present_attributes_ & static_cast<uint32_t>(AttributeFlag::DataView))
std::cout << "[数据视图] ";
std::cout << "[上下文] "; // 始终存在
if (present_attributes_ & static_cast<uint32_t>(AttributeFlag::Handle))
std::cout << "[句柄] ";
std::cout << "[令牌] "; // 始终存在
if (present_attributes_ & static_cast<uint32_t>(AttributeFlag::Direct))
std::cout << "[直接] ";
if (present_attributes_ & static_cast<uint32_t>(AttributeFlag::WorkOnBehalfOf))
std::cout << "[代理工作] ";
std::cout << std::endl;
}
private:
// 检查某个按需属性是否存在,不存在则抛异常,
// 这模拟了真实 ALPC 中"没有传入的属性无法被读取"的行为
void RequireAttribute(AttributeFlag flag, const std::string& name) const {
if (!(present_attributes_ & static_cast<uint32_t>(flag))) {
throw std::runtime_error("属性不存在,无法读取: " + name);
}
}
uint32_t present_attributes_ = 0; // 位掩码,记录哪些按需属性被设置过
ContextAttribute context_;
TokenAttribute token_;
SecurityAttribute security_{};
DataViewAttribute data_view_{};
HandleAttribute handle_{};
DirectAttribute direct_{};
WorkOnBehalfOfAttribute work_on_behalf_of_{};
};
// ------------------------------------------------------------------
// 主函数:模拟一次客户端发送消息、服务端接收并读取属性的完整过程
// ------------------------------------------------------------------
int main() {
std::cout << "===== 模拟 ALPC 消息属性系统 =====\n\n";
// 客户端构造一条消息:内核自动生成上下文属性和令牌属性
AlpcMessage msg;
std::cout << "-- 消息创建后,内核自动关联的属性 --\n";
const auto& ctx = msg.GetContextAttribute();
std::cout << "消息 ID: " << ctx.message_id
<< ", 序列号: " << ctx.sequence_no
<< ", 回调 ID: " << ctx.callback_id << "\n";
const auto& tok = msg.GetTokenAttribute();
std::cout << "令牌 ID: " << tok.token_id
<< ", 认证 ID: " << tok.authentication_id
<< ", 修改 ID: " << tok.modified_id << "\n\n";
// 客户端在发送前,显式设置了用户自定义上下文指针
msg.SetUserContext("MyServiceRequestContext#42");
std::cout << "-- 设置用户自定义上下文后 --\n";
std::cout << "用户上下文: " << msg.GetContextAttribute().context_ptr << "\n\n";
// 客户端还想传递一个句柄给服务端(比如一个已打开的文件句柄)
HandleAttribute h;
h.handles_to_transfer = {101, 202};
msg.SetHandleAttribute(h);
// 客户端要求服务端能够模拟自己去执行操作,设置安全属性
SecurityAttribute sec;
sec.impersonation_handle = "ImpersonationToken_ClientA";
sec.allow_impersonation = true;
msg.SetSecurityAttribute(sec);
msg.PrintPresentAttributes();
std::cout << "\n";
// 服务端接收消息后,读取安全属性去执行模拟
std::cout << "-- 服务端读取安全属性并执行模拟 --\n";
const auto& read_sec = msg.GetSecurityAttribute();
if (read_sec.allow_impersonation) {
std::cout << "服务端使用凭据 [" << read_sec.impersonation_handle
<< "] 模拟客户端身份执行操作\n\n";
}
// 服务端读取句柄属性,把句柄转换成本进程内的句柄
std::cout << "-- 服务端读取句柄属性并完成句柄传递 --\n";
const auto& read_h = msg.GetHandleAttribute();
for (int handle_value : read_h.handles_to_transfer) {
std::cout << "接收到句柄值 " << handle_value
<< ",内核已在服务端句柄表中创建对应新句柄\n";
}
std::cout << "\n";
// 尝试读取一个没有设置过的属性(数据视图属性),应当抛出异常
std::cout << "-- 尝试读取未设置的数据视图属性 --\n";
try {
msg.GetDataViewAttribute();
} catch (const std::exception& e) {
std::cout << "捕获到异常: " << e.what() << "\n";
}
return 0;
}
代码讲解:它是如何实现这套模型的
下面对着上面的代码逐段说明它对应了原文的哪个知识点,以及具体是怎么落地的。
1. 用位掩码表示七种属性
enum class AttributeFlag : uint32_t {
Security = 1u << 0,
...
};
这对应原文里"内核用某种方式跟踪哪些属性存在于哪条消息上"的思路。真实 ALPC 内部也是用类似的标志位(flags)机制来标记一条消息携带了哪些属性,这样判断"某属性是否存在"只需要做一次位运算,效率很高。这里用 enum class 加上 1u << n 的写法,让每一种属性各占一个比特位,n 个属性就可以用一个 uint32_t 装下最多 32 种属性标志。
2. 每种属性单独定义一个结构体
比如:
struct ContextAttribute {
std::string context_ptr;
uint64_t sequence_no;
uint64_t message_id;
uint64_t callback_id;
};
这直接对应原文对上下文属性的描述:既有用户自定义的部分(context_ptr),也有内核自动维护的部分(sequence_no、message_id、callback_id)。把这两类数据放在同一个结构体里,是为了体现"这是同一个属性,只是一部分字段由用户填、一部分字段由内核填"这个设计事实。
3. AlpcMessage 构造函数里自动初始化上下文和令牌属性
AlpcMessage() {
...
present_attributes_ = AttributeFlag::Context | AttributeFlag::Token;
context_.sequence_no = ++g_sequence_counter;
...
}
这一段对应原文那句关键的话:“在少数情况下,服务端或客户端总是可以请求某个属性,因为这是 ALPC 内部自动跟消息关联好、并且始终可用的(比如上下文属性或令牌属性)。”
代码里体现为:只要构造出一个 AlpcMessage 对象,不需要任何额外调用,上下文属性和令牌属性就已经准备好了,present_attributes_ 里对应的位从一开始就被置上了。而其余五种属性,需要用户显式调用 SetXxxAttribute 才会被置位。
4. RequireAttribute 函数模拟"按需属性不存在就无法读取"
void RequireAttribute(AttributeFlag flag, const std::string& name) const {
if (!(present_attributes_ & static_cast<uint32_t>(flag))) {
throw std::runtime_error("属性不存在,无法读取: " + name);
}
}
这对应原文中"如果 ALPC 使用者想要把这份数据要回来,内核会安全地把它暴露出来"这句话背后隐含的前提:不是所有属性都天然存在,如果调用者压根没有设置过某个属性(比如没有设置安全属性),那么读取的时候应该得到明确的失败信息,而不是读到一堆垃圾数据。代码用抛异常的方式来模拟这种"安全暴露"机制——只有真正设置过的数据才允许被读到。
5. SetSecurityAttribute / GetSecurityAttribute 等一组接口
这组接口对应原文提到的 AlpcInitializeMessageAttribute(初始化/设置属性)和 AlpcGetMessageAttribute(读取属性)这两个真实 API 的简化版本。真实系统中,这两个 API 是内核态提供的通用接口,可以对任意一种属性类型进行初始化和读取;这里出于教学目的,为每种属性各自写了一对专门的 setter/getter,逻辑更直白,便于对照理解。
6. main 函数模拟一次完整的客户端-服务端交互
主函数按顺序演示了:
- 消息创建后,直接可以拿到内核自动生成的上下文属性和令牌属性;
- 客户端补充设置用户自定义上下文;
- 客户端设置句柄属性(模拟要把文件句柄传给服务端)和安全属性(模拟允许服务端模拟自己);
- 服务端读取安全属性执行"模拟"逻辑;
- 服务端读取句柄属性完成"句柄传递";
- 最后故意读取一个没设置过的属性(数据视图属性),验证会正确抛出异常。
这个流程虽然是高度简化的模拟,但完整覆盖了原文描述的核心逻辑链条:属性有内核自动维护和用户按需设置两类;内核负责安全暴露数据;未设置的属性无法被凭空读取。
编译运行方式
将代码保存为 alpc_attributes_demo.cpp,然后执行:
g++ -std=c++17 -Wall -o alpc_demo alpc_attributes_demo.cpp
./alpc_demo
预期会看到消息创建、属性设置、服务端读取、以及最后捕获异常的完整输出过程。
七、内核内部转换过程的示意
用一张 ASCII 图,展示"用户传入属性 → 内核转换 → 安全暴露"这条链路:
客户端 / 服务端(用户态)
|
| 调用 AlpcInitializeMessageAttribute
| 填入:安全 / 数据视图 / 句柄 / 直接 / 代理工作 属性数据
v
+-------------------------------------------+
| ALPC 内核态 |
| |
| 接收用户传入的属性数据 |
| | |
| v |
| 转换为内核内部表示(持有真实指针) |
| | |
| +--> 自动追加:上下文属性(序列号等) |
| +--> 自动追加:令牌属性(身份三元组) |
| | |
| v |
| 内核自己的句柄表(隔离用户态直接访问) |
+-------------------------------------------+
|
| 调用 AlpcGetMessageAttribute
| (按需属性需先设置过,否则读取失败)
v
客户端 / 服务端(用户态)
收到经过安全处理、不直接暴露内核指针的属性数据
八、小结
原文这段内容的核心信息,可以浓缩为三句话:
- ALPC 消息不仅仅是数据,还能携带七种"属性":安全、数据视图、上下文、句柄、令牌、直接、代理工作各司其职,分别服务于身份模拟、共享内存管理、用户上下文追踪、句柄传递、轻量身份校验、同步机制、以及电源资源调度。
- 属性分两类:一类由内核自动维护、随时可查(上下文、令牌);另一类需要调用者在发送消息时显式传入(安全、数据视图、句柄、直接、代理工作)。
- 整个模型的设计目标是"数据对用户不透明、内核持有真实指针",配合内核自己维护的句柄表,既保证了安全性,也给了内核实现上足够的灵活性。
ALPC 端口所有权(Port Ownership)机制详解
一、为什么需要"端口所有权"这个概念
一个 ALPC 端口(尤其是连接端口)一旦被创建出来,就会被打上一个"烙印":谁创建的这个端口,谁就是它永久的所有者(owner)。这件事听上去很朴素,但它在整个 ALPC 的安全体系里起到了非常关键的作用,主要体现在三个方面:
- 只有 owner 才能 accept 连接请求;
- 句柄属性的复制,永远发生在 owner 进程的上下文里;
- server SID 校验,永远拿 owner 的 token 去比对。
这三条规则背后有一个共同的核心思想:端口所有者是固定的、可信的锚点,而"当前是谁在处理这条消息"是会变化的、不一定可信的。ALPC 选择永远相信锚点,而不是相信"当下正在跑代码的这个执行环境"。
二、"当前进程"为什么会跟"端口所有者"不一致
这是理解整个机制的关键前提。在用户态的应用程序里,"当前进程"这个概念通常是稳定的——一个进程创建了端口,之后它自己处理消息,没什么好混淆的。
但是在内核态,情况完全不同。内核组件(比如某个驱动)用 ALPC 跟客户端通信时,有可能出现下面这种情况:
- 端口是驱动在
DriverEntry(驱动初始化入口函数)里创建的,那时候驱动运行在某个特定的进程上下文(甚至就是 System 进程)里; - 但驱动后续处理消息的时候,未必总是运行在创建端口时的那个进程上下文里。举例来说:
- 内核里的某个系统线程,专门消费 ALPC 端口消息,这个线程本身就常驻在 System 进程里;
- 更复杂的场景是:内核组件通过可执行体回调对象(executive callback object)来投递消息,而这个回调是同步地在某个发送者(sender)进程的上下文里被调用的——这时候"当前进程"直接变成了客户端进程,而不是端口本来的 owner。
用一张图来表示这种"错位":
正因为存在这种"当前上下文"和"端口所有者"随时可能对不上的情况,ALPC 才必须明确规定:一切跟安全相关的判断,都要以 owner 为准,而不是看"现在恰好是谁在跑这段代码"。
三、规则一:只有 owner 才能 accept 连接
这是最基础也是最重要的一条规则:一个连接端口,只有它的所有者进程才可以在上面接受(accept)客户端的连接请求。
这条规则要解决的问题很直接:端口句柄有可能被复制或者被继承到别的进程里去。比如:
- 一个进程把自己持有的端口句柄,通过句柄复制 API 传给了另一个进程;
- 子进程从父进程那里继承了句柄(Windows 支持句柄继承机制)。
如果没有 owner 检查,那么持有这个句柄的任何进程,理论上都可以拿它去 accept 连接、伪装成合法的服务端。这就给了攻击者可乘之机——只要它能想办法拿到这个句柄(哪怕只是意外继承来的),就能冒充真正的服务,跟毫不知情的客户端建立连接,进而窃取数据或发起攻击。
owner 检查堵住了这条路:即便句柄被复制/继承到了别的进程,那个进程也没有资格 accept 连接,因为系统会去核对"你是不是这个端口真正的创建者"。
accept 是否允许={允许,当前进程=端口 owner拒绝,当前进程≠端口 owner(且非 system port) \text{accept 是否允许} = \begin{cases} \text{允许}, & \text{当前进程} = \text{端口 owner} \\ \text{拒绝}, & \text{当前进程} \ne \text{端口 owner(且非 system port)} \end{cases} accept 是否允许={允许,拒绝,当前进程=端口 owner当前进程=端口 owner(且非 system port)
四、规则二:句柄属性复制永远发生在 owner 上下文
ALPC 消息可以携带句柄属性(handle attribute),分为 direct(直接)和 indirect(间接)两种形式,用来在消息传递过程中顺带传递句柄(比如文件句柄、事件句柄等)。
这里有一条容易被忽视但很重要的规则:不管当前是谁在解析(parse)这条消息,句柄的实际复制目标,永远是端口所有者进程,而不是"当前正在处理这条消息的那个进程"。
打个比方:假设内核里有个系统线程,此刻正附身在某个客户端进程的上下文里去读取一条消息(这个"读取消息"的动作本身可能出于效率或者实现方便,被安排在了客户端进程的上下文里执行)。如果句柄复制目标跟着"当前解析消息的进程"走,那句柄就会被错误地复制到客户端进程里,而不是本该拥有这个句柄的服务端进程(也就是端口 owner)。这样一来,权限归属就乱套了。
所以 ALPC 的设计是:"谁在解析消息"和"句柄最终交给谁"是两件独立的事,句柄复制这件事永远认准 owner,跟当前上下文完全解耦。
五、规则三:system port —— 给内核开的特殊口子
规则一说得很清楚:只有 owner 才能 accept。但这在某些内核场景下会造成麻烦。
还是前面提到的 executive callback 场景:
- 内核连接端口,很可能是在
DriverEntry阶段创建的,那时候运行环境是 System 进程上下文,所以这个端口的 owner 被记录成了 System; - 但当客户端发起连接、触发 callback 的时候,这个回调是同步调用的,调用发生在"发送消息的那个客户端进程"的上下文里,而不是 System 进程的上下文里;
- 按规则一,这时候 accept 请求的"当前进程"(某个客户端进程)跟"端口 owner"(System)对不上,理应被拒绝——但这明明是内核组件自己合法的正常工作流程,不该被拦下来。
为了解决这个矛盾,ALPC 提供了一个专门的端口属性标志位:把某个连接端口标记为 system port。这个标志位有一个重要限制——只有内核态的调用者才能设置它,用户态代码无法伪造。一旦端口被标记成 system port,规则一的 owner 检查就会被直接跳过,不管当前是谁在执行 accept 逻辑,都可以正常接受连接。
可以把这理解成:“这个端口本来就是设计成要被内核灵活调度到不同进程上下文里去处理的,所以我们提前给它开了绿灯”。这不是在削弱安全性,而是精确地识别出"这类端口的 owner 检查天然就不适用",用一个显式的、只有内核能设置的标志位来标注这种例外,而不是简单粗暴地放开所有端口的限制。
六、规则四:Server SID 校验永远查 owner 的 token
在讲 ALPC 安全机制的时候提到过,客户端可以要求对服务端做 SID 校验(server SID validation)——也就是客户端提前声明"我期望连接的服务端应该具备这个身份(SID)“,只有校验通过,连接才会真正建立,防止客户端被钓鱼、连接到一个冒充的恶意服务端上。
这里的关键规则跟前面两条一脉相承:这项校验永远是拿"端口所有者"的 token 去跟客户端的期望值做比对,而不是看"当前是哪个进程在监听这个端口的消息"。
原因也很直白:"谁在监听消息"是可能变化的、不一定稳定的运行时状态,而"谁是端口的真正所有者"才是这个服务的身份根基。如果校验逻辑图省事,去看"当前监听者是谁”,那么理论上就存在被绕过的风险——比如某种场景下监听消息的进程被替换或者被劫持,但 owner 没变,如果校验错误地依赖了监听者而不是 owner,攻击者就有可能钻这个空子。ALPC 的设计从根上避免了这种依赖,始终锚定 owner 的 token。
七、四条规则的共同逻辑小结
| 规则 | 判断依据 | 要防的问题 |
|---|---|---|
| 谁能accept连接 | 端口的owner进程(system port例外) | 句柄被复制/继承后被滥用来冒充服务端 |
| 句柄属性复制到哪 | 端口的owner进程,与谁在解析消息无关 | 句柄被错误地复制到临时上下文进程,导致权限归属混乱 |
| system port豁免 | 只有内核可设置的特殊标志位 | 内核回调场景下current process与owner天然不一致造成的误判 |
| Server SID校验依据 | 端口owner的token | 依赖易变的"当前监听者"身份,给伪造/劫持留下空子 |
一句话总结:ALPC 把"端口所有者"当作唯一可信的身份锚点,所有跟安全相关的判定都往这个锚点上靠,而不是相信随时可能变化的"当前执行上下文"。system port 是唯一的例外,而且这个例外的开关权限被严格限制在内核态,普通用户态代码无法触碰。
八、完整可运行的 C++ 代码模拟
下面用 C++ 模拟了端口所有权的四条核心规则:普通端口 owner 检查、system port 豁免、句柄属性复制目标锚定 owner、以及 server SID 校验锚定 owner。代码只用标准库,不依赖任何第三方库或万能头文件,可以直接编译运行。
// alpc_port_ownership_sim.cpp
// 用途:模拟 ALPC Port Ownership(端口所有权)机制
// 重点还原:
// 1. 端口有一个固定的"所有者进程"(owner process),跟"当前正在处理消息的进程/线程"是两回事
// 2. 只有 owner 进程才能在这个端口上 accept 连接请求
// 3. handle attribute 的复制,永远是在 owner 进程的上下文里做的,而不是当前处理消息的进程
// 4. system port:内核组件专用的特殊标志位,允许绕开"owner检查",
// 用于回调场景下 current process 跟 port owner 不一致的情况
// 5. server SID 验证:永远拿 port owner 的 token 去做校验,而不是当前监听消息的那个进程
//
// 编译方式(不用万能头文件,只用标准库):
// g++ -std=c++17 -O2 -o alpc_port_ownership_sim alpc_port_ownership_sim.cpp
// 运行:
// ./alpc_port_ownership_sim
#include <iostream> // 标准输入输出
#include <string> // std::string
#include <memory> // std::shared_ptr
#include <stdexcept> // std::runtime_error
#include <vector> // std::vector
// ------------------------------------------------------------------
// Process 类:模拟一个 Windows 进程
// 用一个简单的整数 SID(安全标识符,真实系统中是一长串结构体)来代表这个进程的身份
// ------------------------------------------------------------------
class Process {
public:
Process(std::string name, int sid, bool isSystem = false)
: name_(std::move(name)), sid_(sid), isSystemProcess_(isSystem) {}
const std::string& name() const { return name_; }
int sid() const { return sid_; }
bool isSystemProcess() const { return isSystemProcess_; }
private:
std::string name_; // 进程名,方便打印调试
int sid_; // 简化版安全标识符(真实系统是 SID 结构体 + token)
bool isSystemProcess_; // 是否是 System 进程(内核线程常驻的那个特殊进程)
};
// ------------------------------------------------------------------
// PortAttributes:端口属性标志位,这里只关心一个:是否是 system port
// ------------------------------------------------------------------
struct PortAttributes {
bool isSystemPort = false; // 只有内核调用者才能设置这个标志
};
// ------------------------------------------------------------------
// ALPCConnectionPort:模拟一个 ALPC 连接端口
//
// 核心设计:
// - ownerProcess_ 在构造时就固定下来,代表"创建这个端口的进程",
// 之后不会随着"谁在处理消息"而改变。
// - acceptConnection() 需要传入"当前正在执行的进程"(currentProcess),
// 用来模拟"当前上下文可能跟owner不一样"这件事。
// - duplicateHandleAttribute() 演示"handle属性永远在owner进程上下文里复制"。
// - validateServerSid() 演示"SID校验永远查owner的token,不管谁在监听"。
// ------------------------------------------------------------------
class ALPCConnectionPort {
public:
ALPCConnectionPort(std::string portName,
std::shared_ptr<Process> ownerProcess,
PortAttributes attrs = PortAttributes{})
: portName_(std::move(portName)),
ownerProcess_(std::move(ownerProcess)),
attrs_(attrs) {
std::cout << "[创建端口] \"" << portName_ << "\" 所有者进程="
<< ownerProcess_->name()
<< " system_port=" << (attrs_.isSystemPort ? "是" : "否") << "\n";
}
// ----------------------------------------------------------------
// acceptConnection:接受一个客户端的连接请求
//
// currentProcess: 当前正在执行 accept 逻辑的进程/线程上下文
//
// 规则:
// - 如果不是 system port:只有 currentProcess == ownerProcess_ 才允许 accept
// - 如果是 system port:忽略 owner 检查,任何 currentProcess 都可以 accept
// ----------------------------------------------------------------
bool acceptConnection(const std::shared_ptr<Process>& currentProcess,
const std::string& clientName) {
std::cout << "\n[尝试连接] 客户端[" << clientName << "] 请求连接端口 \""
<< portName_ << "\",当前执行上下文进程=" << currentProcess->name() << "\n";
if (!attrs_.isSystemPort) {
// 普通端口:必须是owner进程自己才能accept
if (currentProcess->sid() != ownerProcess_->sid()) {
std::cout << " [拒绝] 当前上下文进程[" << currentProcess->name()
<< "] 不是端口所有者[" << ownerProcess_->name()
<< "],普通端口不允许非owner accept连接\n";
return false;
}
} else {
// system port:跳过owner检查,只要是内核合法设置的system port就放行
std::cout << " [system port特权] 跳过owner检查,允许非owner进程["
<< currentProcess->name() << "] 代表内核组件accept连接\n";
}
std::cout << " [接受] 端口 \"" << portName_ << "\" 成功接受客户端["
<< clientName << "]的连接\n";
return true;
}
// ----------------------------------------------------------------
// duplicateHandleAttribute:模拟句柄属性(direct/indirect)的复制过程
//
// 关键点:无论当前是谁在解析这条消息(parsingProcess),
// handle都必须复制到 ownerProcess_ 的上下文里,而不是parsingProcess的上下文
// ----------------------------------------------------------------
void duplicateHandleAttribute(const std::shared_ptr<Process>& parsingProcess,
const std::string& handleName) {
std::cout << "\n[句柄属性复制] 消息携带句柄=\"" << handleName << "\"\n";
std::cout << " 当前解析消息的进程=" << parsingProcess->name() << "(仅负责读取/解析消息内容)\n";
std::cout << " 实际句柄复制目标进程=" << ownerProcess_->name()
<< "(端口所有者,无论谁在解析消息,都复制到这里)\n";
// 这里不做真实的句柄复制,只做日志展示,突出"目标永远是owner"这一事实
}
// ----------------------------------------------------------------
// validateServerSid:模拟"server SID validation"检查
//
// 规则:永远拿 ownerProcess_ 的 SID(token) 去跟客户端期望的SID比较,
// 而不是当前正在监听/处理消息的那个进程的SID
// ----------------------------------------------------------------
bool validateServerSid(int expectedSidByClient,
const std::shared_ptr<Process>& currentListener) {
std::cout << "\n[Server SID校验] 客户端期望的server SID=" << expectedSidByClient << "\n";
std::cout << " 当前监听消息的进程=" << currentListener->name()
<< "(SID=" << currentListener->sid() << ",本次校验不使用它)\n";
std::cout << " 实际用于校验的进程=端口所有者[" << ownerProcess_->name()
<< "](SID=" << ownerProcess_->sid() << ")\n";
bool matched = (ownerProcess_->sid() == expectedSidByClient);
std::cout << " 校验结果: " << (matched ? "通过" : "失败——SID不匹配,拒绝连接") << "\n";
return matched;
}
const std::string& portName() const { return portName_; }
const std::shared_ptr<Process>& owner() const { return ownerProcess_; }
private:
std::string portName_; // 端口名称
std::shared_ptr<Process> ownerProcess_; // 端口所有者(固定不变,不随当前执行上下文变化)
PortAttributes attrs_; // 端口属性(是否system port等)
};
// ------------------------------------------------------------------
// 主函数:演示四个典型场景
// 场景1:普通端口,owner自己accept —— 成功
// 场景2:普通端口,非owner进程(比如内核附身到别的进程)尝试accept —— 失败
// 场景3:system port,非owner进程accept —— 成功(内核回调场景)
// 场景4:句柄属性复制 + server SID校验,始终锚定owner进程
// ------------------------------------------------------------------
int main() {
std::cout << "==========================================\n";
std::cout << " ALPC Port Ownership 机制模拟\n";
std::cout << "==========================================\n";
// 构造几个模拟进程
auto systemProcess = std::make_shared<Process>("System", /*sid=*/0, /*isSystem=*/true);
auto driverHostProcess = std::make_shared<Process>("DriverService.exe", /*sid=*/100);
auto randomProcess = std::make_shared<Process>("RandomApp.exe", /*sid=*/200);
auto clientProcess = std::make_shared<Process>("Client.exe", /*sid=*/300);
// ---------- 场景1:普通端口,owner自己accept ----------
std::cout << "\n----- 场景1:普通端口,端口所有者自己执行accept -----\n";
ALPCConnectionPort normalPort("\\Device\\MyDriverPort", driverHostProcess);
normalPort.acceptConnection(driverHostProcess, "Client.exe"); // owner自己accept,应当成功
// ---------- 场景2:普通端口,非owner进程尝试accept ----------
std::cout << "\n----- 场景2:普通端口,非owner进程尝试accept(应被拒绝) -----\n";
normalPort.acceptConnection(randomProcess, "Client.exe");
// ---------- 场景3:system port,允许非owner进程accept ----------
std::cout << "\n----- 场景3:system port,非owner进程accept(内核回调场景,应当成功) -----\n";
PortAttributes sysAttrs;
sysAttrs.isSystemPort = true; // 只有内核调用者才能设置这个标志
ALPCConnectionPort systemPort("\\Device\\KernelCallbackPort", systemProcess, sysAttrs);
systemPort.acceptConnection(randomProcess, "Client.exe");
// ---------- 场景4:句柄属性复制 + Server SID校验 ----------
std::cout << "\n----- 场景4:句柄属性复制与Server SID校验,始终锚定owner -----\n";
normalPort.duplicateHandleAttribute(randomProcess, "FileHandle_0x1234");
bool sidOk1 = normalPort.validateServerSid(100, randomProcess);
std::cout << " => 场景4a结果: " << (sidOk1 ? "连接允许" : "连接拒绝") << "\n";
bool sidOk2 = normalPort.validateServerSid(999, randomProcess);
std::cout << " => 场景4b结果: " << (sidOk2 ? "连接允许" : "连接拒绝") << "\n";
std::cout << "\n程序结束。\n";
return 0;
}
8.1 代码整体结构说明
代码设计了两个核心类:
| 类名 | 职责 |
|---|---|
Process |
模拟一个进程,携带名字和简化版SID(安全标识符) |
PortAttributes |
端口属性结构体,目前只有一个isSystemPort字段 |
ALPCConnectionPort |
核心类,模拟连接端口本身,内部固定记录owner,并提供4个方法对应4条规则 |
8.2 关键实现点逐一拆解
(1) owner 在构造函数里就固定下来,构造之后不可更改
ALPCConnectionPort(std::string portName,
std::shared_ptr<Process> ownerProcess,
PortAttributes attrs = PortAttributes{})
: portName_(std::move(portName)),
ownerProcess_(std::move(ownerProcess)),
attrs_(attrs) { ... }
ownerProcess_ 是一个私有成员,只在构造函数里被赋值一次,类里没有提供任何修改它的接口(没有 setter)。这样设计是为了在代码层面就体现"owner 从创建那一刻起就固定了,不会随后续操作变化"这个核心事实。
(2) acceptConnection 里显式传入 currentProcess,用来模拟上下文错位
bool acceptConnection(const std::shared_ptr<Process>& currentProcess,
const std::string& clientName) {
if (!attrs_.isSystemPort) {
if (currentProcess->sid() != ownerProcess_->sid()) {
// 拒绝
}
} else {
// system port:跳过检查
}
}
这里最关键的设计是:函数签名里专门要求调用者传入 currentProcess,而不是假设"当前调用这个函数的就是 owner"。这是为了明确展示"当前执行上下文"是一个独立于 owner 的变量——在真实系统里,这对应的是"哪个进程/线程正在执行这段内核代码",跟端口创建时记录的 owner 完全是两个不同的东西。if (!attrs_.isSystemPort) 这个分支结构,直接对应"system port 会跳过 owner 检查"这条规则——注意 system port 分支里没有任何 SID 比较,直接放行,这就是"忽略owner检查"的字面体现。
(3) duplicateHandleAttribute 里刻意区分"解析者"和"复制目标"
void duplicateHandleAttribute(const std::shared_ptr<Process>& parsingProcess,
const std::string& handleName) {
std::cout << " 当前解析消息的进程=" << parsingProcess->name() << "...\n";
std::cout << " 实际句柄复制目标进程=" << ownerProcess_->name() << "...\n";
}
这个函数故意接收一个叫 parsingProcess 的参数(代表当前解析消息的进程),但函数体里打印句柄复制目标时,用的是 ownerProcess_,完全没有用到 parsingProcess 去做任何判断逻辑——这正是为了突出"这两者是解耦的,句柄复制这件事根本不关心谁在解析消息"。
(4) validateServerSid 同样不依赖当前监听者的 SID 做判断
bool validateServerSid(int expectedSidByClient,
const std::shared_ptr<Process>& currentListener) {
...
bool matched = (ownerProcess_->sid() == expectedSidByClient);
...
}
matched 这行比较代码里,参与比较的是 ownerProcess_->sid(),而 currentListener 这个参数只是被打印出来做对比展示(“你看,即便有人在监听,我们也不用它的SID”),并没有参与到真正决定校验结果的逻辑运算中。这个设计是为了让读者能够一眼看出"监听者身份在这里完全是摆设,真正起作用的只有 owner"。
8.3 场景执行流程图
8.4 实际运行结果(已验证可编译运行)
程序用 g++ -std=c++17 编译无警告通过,运行结果完全符合预期:
- 场景1:
DriverService.exe(owner)自己执行 accept,成功接受连接。 - 场景2:
RandomApp.exe(非owner)尝试在同一个普通端口上 accept,被正确拒绝,日志明确指出"不是端口所有者"。 - 场景3:把端口标记为 system port 后,同样是
RandomApp.exe这个非owner进程去 accept,这次被放行,日志显示"跳过owner检查",模拟了内核回调场景下的合法例外。 - 场景4:句柄属性复制的目标始终锚定在 owner(
DriverService.exe),不受当前解析消息的进程(RandomApp.exe)影响;Server SID 校验同样锚定 owner 的 SID(100),客户端期望值为100时校验通过,期望值为999时正确失败。
九、一张图总览四条规则
四条规则表面上分散在不同的功能点上,但本质都是同一件事的不同侧面:Owner 是端口安全属性的唯一权威来源,运行时的执行上下文只是"临时演员",不能被信任来做安全判断。
ALPC 句柄传递与安全机制详解
一、为什么需要句柄传递
先明确一个问题:为什么进程间通信除了传数据,还需要能传"句柄"?
在 Windows 里,几乎所有内核对象(文件、管道、事件、互斥体、Socket)都是通过句柄(Handle)来引用的。句柄本身只是一个数字,它的含义完全依赖于"在哪个进程的句柄表里查"。同一个数字 0x1A4 在 A 进程里可能指向一个打开的文件,在 B 进程里可能根本无效,或者对应着完全不相关的对象。
如果 A 进程想让 B 进程也能访问同一个文件、同一个管道,光靠"把这个数字告诉 B"是完全没用的——必须要有一种机制,在内核层面把这个句柄真正复制到 B 进程的句柄表里,让它指向同一个底层对象。
Unix 世界里,Unix Domain Socket 和 macOS 的 Mach 端口早就支持这个能力:发送消息的同时,可以附带一个文件描述符,接收进程收到后会在自己的描述符表里得到一个复制出来的新描述符,从而获得访问权限(比如访问一个管道、一个 socket、或者文件系统上的某个位置)。
ALPC 把这个能力带到了 Windows 上,实现方式就是靠句柄属性(Handle Attribute)。
二、直接句柄传递机制:两阶段复制
这是 ALPC 最初实现句柄传递时采用的方式,现在称为"直接(direct)"句柄传递,以区别于后面讲的"间接(indirect)"句柄传递。
整个过程可以理解为两次复制,一步都不能省:
阶段一:发送方声明句柄
发送方在消息里通过句柄属性编码这样几件事:
- 对象类型(Object Type):声明自己要传的是什么类型的对象(文件、事件、互斥体等)。
- 复制方式的相关信息:告诉内核该怎么复制这个句柄(比如是否要复制访问权限、是否继承等)。
- 句柄在发送方句柄表里的索引位置:也就是这个句柄具体对应发送方句柄表里的哪一项。
内核拿到这些信息后,会检查:这个索引位置上的句柄,它实际指向的对象类型,是否和发送方声称的类型一致。如果一致,内核就会立即把这个句柄复制一份,放进系统(内核)句柄表里,作为一个临时的中间副本。
这一步的意义非常关键:它锁定了发送方在此刻确实拥有这个类型的对象,而且从这一刻起,哪怕发送方后续关闭了自己的原始句柄,或者对象状态发生变化,都不会影响内核里这份已经复制出来的副本——它已经独立存在了。
用一个类比理解:这就像你去银行开一张汇票,银行先把钱从你账户划走冻结在银行自己的账户里,而不是等收款人来取的时候才去查你账户里还有没有钱。
阶段二:接收方索取句柄
接收方处理消息时,如果需要用到这个句柄,就调用相应接口,请求暴露句柄属性,并且声明自己期望的对象类型。
内核检查:接收方期望的类型,和内核句柄表里那份临时副本的实际类型,是否匹配。如果匹配,内核会再复制一次——这次是从内核句柄表复制到接收方的用户态句柄表,同时把内核里那份临时副本关闭掉(它已经完成使命)。
到这里,句柄传递才算真正完成。接收方现在拥有一个新的句柄,它指向的内核对象和发送方最初引用的完全是同一个,而且类型也确实是接收方期望的类型。
两次复制带来的安全保证
这套"两阶段复制"设计带来两个关键保证:
保证一:发送方声称的类型=句柄实际指向的对象类型 \text{保证一:} \quad \text{发送方声称的类型} = \text{句柄实际指向的对象类型} 保证一:发送方声称的类型=句柄实际指向的对象类型
保证二:接收方最终拿到的对象=发送方最初引用的同一个内核对象 \text{保证二:} \quad \text{接收方最终拿到的对象} = \text{发送方最初引用的同一个内核对象} 保证二:接收方最终拿到的对象=发送方最初引用的同一个内核对象
而且由于整个复制过程都是内核代劳完成的,所以还带来一个额外的好处:一个高权限的服务端可以给一个低权限的客户端发送句柄,而完全不需要客户端对服务端进程本身拥有任何访问权限。因为客户端根本不需要"伸手去服务端进程里拿东西",它拿到的是内核已经准备好、放在自己句柄表里的现成东西。
三、直接句柄传递的历史局限
这套机制最早主要用在 Windows 子系统进程 CSRSS(Client/Server Runtime Subsystem)身上。CSRSS 需要知道系统里所有 Windows 子系统下创建的子进程,这样当某个子进程真正开始运行、需要连接 CSRSS 的时候,CSRSS 已经提前从父进程那里知道了这个子进程的存在,可以顺利完成连接。
但这套最初的机制有明显的局限:
- 一次只能传一个句柄:不支持一条消息里打包传多个句柄,更不用说传多种不同类型的句柄了。
- 接收方没有选择权:只要消息上关联了句柄属性,接收方就必须接收这个句柄,事先根本无法知道"这条消息到底该不该带句柄"。
这两个限制放在现代系统里显然是不够用的——现代 IPC 场景经常需要一次性传递多个不同类型的对象(比如同时传一个文件句柄和一个事件句柄)。
四、间接句柄传递:Windows 8 带来的改进
为了解决上面的问题,Windows 8 引入了**间接句柄传递(Indirect Handle Passing)**机制。
核心变化
在支持间接句柄的端口上(注意:大多数非 RPC 的 ALPC 服务端通常不会启用间接句柄),行为发生了根本变化:
- 不再自动复制:以前只要句柄属性里类型匹配,接收消息(通过
NtAlpcSendWaitReceivePort)的时候就会自动完成句柄复制。现在不再这样了。 - 接收方必须手动查询和拉取:客户端和服务端需要自己:
- 先查询这条消息里到底带了多少个句柄;
- 分配足够的数据结构来接收这些句柄的值和类型;
- 请求内核完成这些句柄的复制,解析出符合预期类型的句柄,同时主动关闭/丢弃不符合预期的句柄。
这个查询和拉取的过程,是通过调用NtAlpcQueryInformationMessage并传入接收到的消息来完成的。
间接句柄传递的安全收益
这个改动带来了一个非常直接的安全好处:句柄不再是"只要类型匹配就自动复制",而是"只有被明确请求的时候才复制"。
想象一个场景:服务端预期消息 A 会带一个句柄,但消息 B、消息 C 都不带句柄。如果用旧的自动复制机制,服务端在解析 B、C 消息的时候,很容易忘记去检查、关闭那些"莫名其妙冒出来的句柄"(因为只要类型对得上就自动复制了,哪怕服务端压根没打算处理它)。
用间接句柄传递,服务端根本不会对 B、C 这两类消息调用 NtAlpcQueryInformationMessage,那么这些句柄自然就永远不会被复制,也就不存在"忘记关闭导致句柄泄漏"这种问题了。
| 对比维度 | 直接句柄传递 | 间接句柄传递 |
|---|---|---|
| 支持的句柄数量 | 每条消息最多一个 | 一条消息可携带多个不同类型的句柄 |
| 复制时机 | 接收消息时自动完成 | 接收方主动查询并请求复制 |
| 接收方是否可以拒绝 | 不行,只要属性存在就会复制 | 可以,不查询就不会复制 |
| 典型使用场景 | 早期 CSRSS 等有限场景 | 现代 RPC 运行时、IDL 编译器广泛使用 |
五、间接句柄传递与 RPC 系统的深度整合
正是因为有了间接句柄传递这套更灵活、更安全的机制,ALPC 的句柄传递能力现在已经远远超出了早期那种"点对点特例"的用法,而是深度整合进了 **RPC 运行时(RPC Runtime)**和 IDL 编译器(Interface Definition Language Compiler)。
具体表现为:IDL 文件里可以使用 system_handle(sh_type) 这样的语法,来声明某个参数是一个需要被封送(marshal)的系统句柄,并且指定它的具体类型。目前支持超过 20 种不同的句柄类型,RPC 运行时会负责把它从客户端封送传递到服务端(或者反过来)。
双重类型检查
值得注意的是,类型检查其实做了两层:
- 内核层面:前面讲的 ALPC 句柄传递机制本身,会检查句柄的对象类型是否匹配(比如是不是"文件对象"这个大类)。
- RPC 运行时层面:做了更细粒度的检查。因为像命名管道、Socket、普通文件,在内核对象类型上其实都属于同一个大类——“文件对象(File Object)”,所以单靠内核那一层的类型检查,是分辨不出"你传的到底是管道还是 Socket"的。
RPC 运行时通过调用诸如GetFileAttribute、GetDeviceType这类 API,进一步区分具体的子类型。比如 IDL 文件里声明的是system_handle(sh_pipe),如果实际传过来的是一个 Socket 句柄,RPC 运行时的封送/解封送检查就能识别出这种类型不匹配的情况,从而拒绝它。
可以理解成一个两层过滤器:
句柄能否成功传递=内核层类型检查 ∧ RPC 运行时子类型检查 \text{句柄能否成功传递} = \text{内核层类型检查} \ \land\ \text{RPC 运行时子类型检查} 句柄能否成功传递=内核层类型检查 ∧ RPC 运行时子类型检查
只有两层检查都通过,句柄传递才算真正安全地完成。
实际应用场景
这套间接句柄传递的能力,如今被大量重要组件依赖:
- AppContainer 沙箱基础设施:WinRT API 里,各种代理进程(Broker)在完成能力检查(capability check)之后,正是靠这套机制把它们打开的句柄传回沙箱化的应用程序,让应用可以直接使用。
- DNS 客户端:用它来填充
GetAddrInfoExAPI 里的ai_resolutionhandle字段。
六、ALPC 的安全机制
讲完句柄传递,再看看 ALPC 在整体安全模型上做了哪些工作。
1. 端口对象的对象管理器保护
ALPC 端口本身也是一种内核对象,因此它天然享受**对象管理器(Object Manager)**统一提供的安全保护——也就是说,端口对象也可以挂 ACL(访问控制列表)。这意味着,一个没有权限的应用程序,从一开始就拿不到服务端端口的句柄,连"尝试连接"的资格都没有。
2. 基于 SID 的信任模型
这是从最早的 LPC(Local Procedure Call,ALPC 的前身)设计里继承下来的机制。它解决的问题是:客户端怎么确认自己连接的服务端真的是它以为的那个服务端,而不是别人冒充的?
如果只靠端口名字来判断,会存在一个明显的攻击面——命名空间抢注(Namespace Squatting)攻击:一个不受信任的程序,抢先用相同的名字创建一个端口,冒充真正的服务端,诱骗客户端连接上来,然后窃取客户端发来的敏感数据或者做恶意应答。
ALPC 的做法是:在使用"安全端口"(Secured Port)时,客户端进程会向内核提交一个它期望连接到的服务端进程的 SID(安全标识符,Security Identifier)。当真正建立连接时,内核会验证:现在连接的这个服务端,它的 SID 是否和客户端一开始声明的期望值一致。只有一致,连接才会成功建立。
这样一来,哪怕恶意程序抢先用了同样的端口名,只要它的进程 SID 对不上,客户端的连接请求就会被内核拒绝,冒充攻击自然就失效了。
3. 消息来源的原子化、唯一化标识
无论客户端还是服务端,都可以准确地、原子性地识别出:这条消息到底是哪个线程、哪个进程发出来的。这个能力保证了在并发、竞争的环境下,身份判断依然是准确可靠的,不会因为时序问题产生混淆。
4. 完整的模拟支持
ALPC 支持完整的 Windows **身份模拟(Impersonation)**模型,具体通过 NtAlpcImpersonateClientThread 这个 API 实现——服务端线程可以"变成"客户端的身份去执行操作,这样在做权限检查(比如检查是否有权限访问某个文件)的时候,检查的就是客户端的权限,而不是服务端自身权限过高导致的越权问题。
5. 查询客户端安全信息的辅助 API
除了模拟能力之外,ALPC 还提供了额外的查询接口,让服务端可以:
- 查询所有已连接客户端各自对应的 SID;
- 查询某个客户端安全令牌对应的 LUID(本地唯一标识符,Locally Unique Identifier)。
这些信息可以用于审计、日志、以及更精细的访问控制判断,而不需要真的去做一次完整的身份模拟。
七、用一段可运行的 C++ 代码模拟"直接 vs 间接句柄传递"
下面这段代码同样不是调用真实的 Windows 内核 API(那部分是内核态实现,用户态程序访问不到),而是用标准 C++ 模拟出直接句柄传递和间接句柄传递这两种模式的核心行为差异,帮助直观理解它们各自的流程和安全特性。
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <memory>
#include <stdexcept>
#include <algorithm>
// ------------------------------------------------------------------
// 用枚举模拟内核对象类型,对应文中提到的文件、事件、互斥体等类型
// ------------------------------------------------------------------
enum class ObjectType {
File,
Event,
Mutex,
Pipe,
Socket
};
std::string ObjectTypeToString(ObjectType t) {
switch (t) {
case ObjectType::File: return "File";
case ObjectType::Event: return "Event";
case ObjectType::Mutex: return "Mutex";
case ObjectType::Pipe: return "Pipe";
case ObjectType::Socket: return "Socket";
}
return "Unknown";
}
// ------------------------------------------------------------------
// 模拟内核对象本身:这里只保留一个名字和类型,
// 真实系统中它会是一大块内核数据结构,用户态永远碰不到。
// ------------------------------------------------------------------
struct KernelObject {
int id;
ObjectType type;
std::string description;
};
// ------------------------------------------------------------------
// 模拟一张句柄表:句柄值 -> 内核对象指针。
// 用 shared_ptr 模拟内核对象的引用计数(多个句柄可以指向同一个对象)。
// ------------------------------------------------------------------
class HandleTable {
public:
// 在表里插入一项,返回分配到的句柄值(这里简化为递增整数)
int Insert(std::shared_ptr<KernelObject> obj) {
int handle_value = next_handle_++;
table_[handle_value] = obj;
return handle_value;
}
// 按句柄值查找对应的内核对象,找不到返回空指针
std::shared_ptr<KernelObject> Lookup(int handle_value) const {
auto it = table_.find(handle_value);
if (it == table_.end()) return nullptr;
return it->second;
}
// 关闭一个句柄:从表里移除该项
void Close(int handle_value) {
table_.erase(handle_value);
}
size_t Size() const { return table_.size(); }
private:
std::map<int, std::shared_ptr<KernelObject>> table_;
int next_handle_ = 1;
};
// ------------------------------------------------------------------
// 全局模拟:内核句柄表,代表 System/Kernel Handle Table,
// 这是发送方和接收方之外,第三个独立存在的句柄表,
// 用来临时存放"正在传递途中"的句柄。
// ------------------------------------------------------------------
class AlpcKernel {
public:
// -------- 直接句柄传递:阶段一,发送方声明句柄 --------
// sender_table : 发送方自己的句柄表
// sender_handle : 发送方要传递的句柄值
// claimed_type : 发送方声称的对象类型
// 返回值:如果类型匹配,返回在内核句柄表里创建的临时句柄值;不匹配则抛异常
int DirectSend(const HandleTable& sender_table, int sender_handle, ObjectType claimed_type) {
auto obj = sender_table.Lookup(sender_handle);
if (!obj) {
throw std::runtime_error("发送方句柄无效,无法发送");
}
if (obj->type != claimed_type) {
throw std::runtime_error("发送方声称的类型与句柄实际类型不符,拒绝复制");
}
// 类型匹配,内核立即复制一份,放进内核句柄表(阶段一完成)
int kernel_handle = kernel_table_.Insert(obj);
std::cout << "[内核] 阶段一完成:已将 " << ObjectTypeToString(obj->type)
<< " 类型对象复制到内核句柄表,临时句柄值 = " << kernel_handle << "\n";
return kernel_handle;
}
// -------- 直接句柄传递:阶段二,接收方索取句柄 --------
// kernel_handle : 阶段一返回的内核临时句柄值
// expected_type : 接收方期望的对象类型
// receiver_table : 接收方的句柄表(会被写入新句柄)
// 返回值:接收方句柄表里新分配的句柄值
int DirectReceive(int kernel_handle, ObjectType expected_type, HandleTable& receiver_table) {
auto obj = kernel_table_.Lookup(kernel_handle);
if (!obj) {
throw std::runtime_error("内核句柄已失效或不存在");
}
if (obj->type != expected_type) {
throw std::runtime_error("接收方期望的类型与内核句柄实际类型不符,拒绝接收");
}
// 类型匹配,再复制一次到接收方句柄表(阶段二完成)
int receiver_handle = receiver_table.Insert(obj);
// 内核里的临时副本使命完成,关闭它
kernel_table_.Close(kernel_handle);
std::cout << "[内核] 阶段二完成:接收方获得新句柄值 = " << receiver_handle
<< ",内核临时句柄已关闭\n";
return receiver_handle;
}
size_t KernelTableSize() const { return kernel_table_.Size(); }
private:
HandleTable kernel_table_;
};
// ------------------------------------------------------------------
// 模拟间接句柄传递:一条消息可以携带多个不同类型的句柄,
// 接收方需要先查询,再决定要不要真正拉取每一个句柄。
// ------------------------------------------------------------------
struct PendingHandleEntry {
int sender_handle;
ObjectType type;
};
class IndirectMessage {
public:
// 发送方往消息里追加一个待传递的句柄描述(还没有真正复制!)
void AddHandleDescriptor(int sender_handle, ObjectType type) {
entries_.push_back({sender_handle, type});
}
// 接收方查询:这条消息一共带了几个句柄描述,分别是什么类型
// 对应 NtAlpcQueryInformationMessage 的简化模拟
const std::vector<PendingHandleEntry>& QueryHandleDescriptors() const {
return entries_;
}
private:
std::vector<PendingHandleEntry> entries_;
};
// ------------------------------------------------------------------
// 主函数:分别演示直接句柄传递、间接句柄传递两种模式
// ------------------------------------------------------------------
int main() {
std::cout << "===== 场景一:直接句柄传递(两阶段复制)=====\n\n";
AlpcKernel kernel;
HandleTable sender_table;
HandleTable receiver_table;
// 发送方先打开一个文件对象,放入自己的句柄表
auto file_obj = std::make_shared<KernelObject>(KernelObject{1, ObjectType::File, "log.txt"});
int sender_handle = sender_table.Insert(file_obj);
std::cout << "发送方持有句柄值 " << sender_handle << ",指向文件对象 " << file_obj->description << "\n\n";
// 阶段一:发送方声明要传递这个句柄,类型是 File
int kernel_handle = kernel.DirectSend(sender_table, sender_handle, ObjectType::File);
// 阶段二:接收方声明期望类型也是 File,请求拉取
int receiver_handle = kernel.DirectReceive(kernel_handle, ObjectType::File, receiver_table);
// 验证接收方拿到的对象和发送方最初的对象是同一个
auto received_obj = receiver_table.Lookup(receiver_handle);
std::cout << "接收方句柄 " << receiver_handle << " 指向的对象描述: "
<< received_obj->description << "\n";
std::cout << "验证是否为同一个内核对象: "
<< (received_obj.get() == file_obj.get() ? "是,同一个对象" : "不是") << "\n\n";
// 演示类型不匹配被拒绝的情况
std::cout << "-- 演示类型不匹配时会被拒绝 --\n";
try {
kernel.DirectSend(sender_table, sender_handle, ObjectType::Socket);
} catch (const std::exception& e) {
std::cout << "捕获到异常: " << e.what() << "\n\n";
}
std::cout << "===== 场景二:间接句柄传递(按需查询与拉取)=====\n\n";
// 发送方这次要一次性传递两个不同类型的句柄:一个事件对象、一个互斥体对象
auto event_obj = std::make_shared<KernelObject>(KernelObject{2, ObjectType::Event, "StartupEvent"});
auto mutex_obj = std::make_shared<KernelObject>(KernelObject{3, ObjectType::Mutex, "ConfigLock"});
int event_handle_in_sender = sender_table.Insert(event_obj);
int mutex_handle_in_sender = sender_table.Insert(mutex_obj);
IndirectMessage msg;
msg.AddHandleDescriptor(event_handle_in_sender, ObjectType::Event);
msg.AddHandleDescriptor(mutex_handle_in_sender, ObjectType::Mutex);
std::cout << "发送方在消息里描述了 " << msg.QueryHandleDescriptors().size() << " 个待传递句柄\n\n";
// 接收方先查询消息里到底有哪些句柄描述
const auto& descriptors = msg.QueryHandleDescriptors();
std::cout << "-- 接收方查询到的句柄描述列表 --\n";
for (size_t i = 0; i < descriptors.size(); ++i) {
std::cout << "第 " << i << " 项,类型: " << ObjectTypeToString(descriptors[i].type) << "\n";
}
std::cout << "\n";
// 接收方决定:只拉取自己关心的 Event 类型句柄,忽略 Mutex 类型
std::cout << "-- 接收方只请求拉取 Event 类型的句柄,忽略其余类型 --\n";
for (const auto& entry : descriptors) {
if (entry.type == ObjectType::Event) {
int k_handle = kernel.DirectSend(sender_table, entry.sender_handle, entry.type);
int r_handle = kernel.DirectReceive(k_handle, entry.type, receiver_table);
std::cout << "接收方成功拉取到 Event 句柄,本地句柄值 = " << r_handle << "\n";
} else {
std::cout << "跳过类型 " << ObjectTypeToString(entry.type)
<< " 的句柄,未查询也未复制,因此内核不会为它产生任何临时句柄\n";
}
}
std::cout << "\n最终内核句柄表剩余项数: " << kernel.KernelTableSize()
<< "(应为 0,说明没有遗留未关闭的临时句柄)\n";
return 0;
}
代码讲解:逐段对应原文机制
1. HandleTable 类模拟句柄表
class HandleTable {
public:
int Insert(std::shared_ptr<KernelObject> obj) { ... }
std::shared_ptr<KernelObject> Lookup(int handle_value) const { ... }
void Close(int handle_value) { ... }
...
};
这个类对应"进程的句柄表"这个概念。这里用 std::map<int, std::shared_ptr<KernelObject>> 来表示——键是句柄的数值,值是指向内核对象的智能指针。用 shared_ptr 是为了模拟多个句柄可以指向同一个内核对象这一事实:发送方的句柄、内核临时句柄、接收方的句柄,三者虽然数值各不相同,但都可以同时指向同一份底层数据。
2. AlpcKernel::DirectSend 对应阶段一:发送方声明句柄
int DirectSend(const HandleTable& sender_table, int sender_handle, ObjectType claimed_type) {
auto obj = sender_table.Lookup(sender_handle);
...
if (obj->type != claimed_type) {
throw std::runtime_error("发送方声称的类型与句柄实际类型不符,拒绝复制");
}
int kernel_handle = kernel_table_.Insert(obj);
...
return kernel_handle;
}
这里精确对应了原文描述的"内核检查发送方声称的类型和实际类型是否一致,一致才复制到内核句柄表"这个逻辑。kernel_table_ 这个成员变量就是模拟"系统(内核)句柄表"——它是一张独立于发送方和接收方的、只存在于 AlpcKernel 内部的第三方表,外部代码完全访问不到它的内部实现,这正是"内核持有真实指针、对用户态不透明"这个设计理念的体现。
3. AlpcKernel::DirectReceive 对应阶段二:接收方索取句柄
int DirectReceive(int kernel_handle, ObjectType expected_type, HandleTable& receiver_table) {
auto obj = kernel_table_.Lookup(kernel_handle);
...
if (obj->type != expected_type) {
throw std::runtime_error("接收方期望的类型与内核句柄实际类型不符,拒绝接收");
}
int receiver_handle = receiver_table.Insert(obj);
kernel_table_.Close(kernel_handle);
...
return receiver_handle;
}
这一步对应原文"接收方声明期望类型,匹配则再复制一次到接收方句柄表,同时关闭内核里的副本"。注意 kernel_table_.Close(kernel_handle) 这一行——它精确模拟了原文提到的"内核副本此时被关闭"这个细节,保证内核句柄表不会无限膨胀。
4. IndirectMessage 类模拟间接句柄传递里的"待传递描述列表"
struct PendingHandleEntry {
int sender_handle;
ObjectType type;
};
class IndirectMessage {
public:
void AddHandleDescriptor(int sender_handle, ObjectType type) { ... }
const std::vector<PendingHandleEntry>& QueryHandleDescriptors() const { ... }
...
};
这里的关键设计是:AddHandleDescriptor 只是往一个列表里追加描述信息,并没有触发任何真正的句柄复制。这正是间接句柄传递和直接句柄传递最本质的区别——直接传递在阶段一就已经触发了内核复制,而间接传递把"描述"和"真正复制"这两件事彻底解耦开了。QueryHandleDescriptors 对应原文提到的 NtAlpcQueryInformationMessage——接收方调用它,只是拿到"这条消息里都有哪些句柄描述"这份清单,而不会产生任何句柄复制的副作用。
5. main 函数里演示接收方"选择性拉取"
for (const auto& entry : descriptors) {
if (entry.type == ObjectType::Event) {
int k_handle = kernel.DirectSend(sender_table, entry.sender_handle, entry.type);
int r_handle = kernel.DirectReceive(k_handle, entry.type, receiver_table);
...
} else {
std::cout << "跳过类型 ... 的句柄,未查询也未复制...\n";
}
}
这段代码直接对应原文强调的安全收益:接收方只对自己真正关心的类型(这里是 Event)调用复制流程,对 Mutex 类型的句柄描述完全不予理会。最后打印 kernel.KernelTableSize() 应该为 0,验证了"没有被查询过的句柄描述,永远不会在内核里产生临时副本",也就不存在"忘记关闭导致的句柄泄漏"问题。
编译运行方式
保存为 alpc_handle_passing_demo.cpp,然后执行:
g++ -std=c++17 -Wall -o handle_demo alpc_handle_passing_demo.cpp
./handle_demo
八、两阶段复制过程的 ASCII 示意
发送方句柄表 内核句柄表(临时) 接收方句柄表
+----------------+ +----------------+
| handle=7 | 阶段一:类型匹配才复制 | |
| -> File对象 A | -------------------------> | |
+----------------+ | handle=101 | +----------------+
| -> File对象 A |
+--------------------------+
|
| 阶段二:类型匹配才复制,
| 复制完成后关闭内核临时句柄
v
+----------------+
| handle=1 |
| -> File对象 A |
+----------------+
九、间接句柄传递流程图
十、安全机制小结表
| 安全机制 | 解决的问题 | 关键手段 |
|---|---|---|
| 对象管理器 ACL 保护 | 防止无权限进程拿到服务端端口句柄 | 端口对象本身受 ACL 约束 |
| 基于 SID 的信任模型 | 防止命名空间抢注、服务端被冒充 | 连接时内核校验服务端进程 SID |
| 消息来源原子化标识 | 防止并发场景下身份判断混乱 | 内核对每条消息标记确切的线程与进程 |
| 完整身份模拟 | 服务端按客户端权限执行操作 | NtAlpcImpersonateClientThread |
| 客户端安全信息查询 | 支持审计与更细粒度判断 | 查询 SID、查询令牌 LUID |
十一、整体理解
这一段内容其实讲了两条主线:
第一条主线是句柄传递怎么从"能用"进化到"好用又安全":最早的直接句柄传递解决了"能不能传句柄"的问题,但一次只能传一个、接收方没有选择权;Windows 8 引入的间接句柄传递,把"描述"和"真正复制"解耦,让传递多个不同类型句柄成为可能,同时把主动权交给接收方,从根源上避免了句柄泄漏,也因此被 RPC 系统和 AppContainer 沙箱这些现代安全基础设施广泛采纳。
第二条主线是 ALPC 整体的安全设计哲学:从端口对象的访问控制,到基于 SID 防止服务端被冒充,再到完整的身份模拟支持和辅助查询接口,每一层都在回答同一个问题——在进程间通信的场景下,怎么确保双方看到的、操作的,都是它们各自真正预期的那个对象和那个身份。这也正是句柄传递两阶段复制机制背后的同一套逻辑:宁可多复制一次,也要保证任何一步都不给攻击者留下可乘之机。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)