-
-
[原创]Print Monitor Driver:打印子系统中的持久化 DLL
-
发表于: 8小时前 93
-
Print Monitor Driver:打印子系统中的持久化 DLL
目标与四步流程
本篇要完成的目标是只读列出当前 Windows 打印监视器的名称、Driver 配置文本、值类型和读取状态。打印作业由应用创建,Print Spooler(打印后台处理服务)保存和调度,打印监视器把作业关联到端口通信。DLL(Dynamic-Link Library,动态链接库)是可由进程按需装入的代码文件,Print Spooler 在配置与端口需要时装入相应监视器 DLL。读取结果描述注册表中的配置事实,不能单独证明服务已经加载 DLL。
要得到这组配置证据,需要按以下四步完成:
- 确认打印组件的职责,并定位当前生效的
Monitors配置目录。 - 打开根键,计算子键名称容量,逐个取得监视器名称。
- 打开每个监视器子键,按字节长度读取并验证
Driver值。 - 区分配置与运行状态,记录读取结果,并评估关联文件和变更的影响。
完整关系如下:应用创建打印作业 -> Print Spooler 调度队列 -> 打印监视器处理端口路径。读取路径为 Monitors 根键 -> 监视器子键 -> Driver 原始值 -> 类型与长度校验 -> 配置证据。
Windows 打印由应用、打印后台处理服务、打印队列、打印处理器、端口监视器和打印监视器共同完成。应用把文档转换成打印作业并提交到队列。服务名为 Spooler 的 Print Spooler 服务保存和调度作业。后续组件把作业转换并发送到本地端口、网络端口或设备厂商提供的通道。每个组件只负责其中一段工作,排查配置时先确认组件身份,才不会把普通驱动、端口和监视器混成一个对象。
打印监视器(Print Monitor)是由后台处理服务加载的 DLL。它负责端口通信、作业状态和厂商扩展的打印路径,常见的系统组件包括本地端口、USB、TCP/IP 端口相关监视器。它随打印服务的启动、配置重新加载或端口使用进入服务进程,运行身份和普通交互程序不同。注册项存在、文件存在、服务已经加载、某个队列正在使用,分别描述四个层次的事实。
1. 确认组件职责并定位 Monitors 配置目录
打印监视器的注册目录是 HKLM\SYSTEM\CurrentControlSet\Control\Print\Monitors。该目录中的直接子键代表监视器名称,子键中的 Driver 值提供 DLL 名称或路径。Driver 值只是一项属性。“监视器”这个配置对象由子键名称、值类型、Driver 值和可能存在的厂商值共同组成。
CurrentControlSet 是当前实际生效控制集的别名。系统保留 ControlSet001、ControlSet002 等控制集,启动过程选择一个控制集后将其映射为 CurrentControlSet。读取运行中的打印配置应使用该别名。枚举全部 ControlSet00x 会把备用、历史配置也加入结果。
关系可以按下面的顺序理解:Monitors 根键 -> 监视器子键 -> Driver 值与厂商附加配置 -> Print Spooler 服务按配置加载监视器 DLL -> 监视器与打印端口、队列发生关联。
Driver 可能只是 localspl.dll 这样的文件名。文件名、绝对路径和包含 %SystemRoot% 的环境变量文本需要分别记录。服务侧 DLL 搜索规则、服务进程环境和厂商安装程序都会影响最终加载位置,调查进程不能把相对名称直接拼成固定的当前目录路径。
完成第一步后,已经知道应读取当前控制集中的哪个根键,也知道子键名称与 Driver 值分别代表什么。接下来需要在不猜测名称长度的前提下打开根键并取得每个监视器名称。
2. 打开根键并枚举监视器名称
此步骤从打开 Monitors 根键开始。HKEY(注册表键句柄)是进程打开键后持有的访问引用,Windows 注册表管理器在打开成功时创建该引用,调用方在完成读取后关闭它。RegOpenKeyExW 成功时返回 ERROR_SUCCESS,并在最后一个参数交出 HKEY。这个句柄的所有权属于调用方,必须以 RegCloseKey 关闭。失败时函数直接返回 Win32 错误码,不能使用无关的 GetLastError 替代返回值。
// 作用:打开一个注册表子键。
// 返回:ERROR_SUCCESS 表示成功。其它 LSTATUS 值就是错误码。
LSTATUS RegOpenKeyExW(
HKEY hKey, // 根键或已经打开的键,不转移所有权
LPCWSTR lpSubKey, // 相对路径。nullptr 表示当前键
DWORD ulOptions, // 必须为 0
REGSAM samDesired, // 请求权限,例如 KEY_READ
PHKEY phkResult // 输出:新 HKEY。成功后调用方必须 RegCloseKey
);
HKEY monitors = nullptr;
LSTATUS status = RegOpenKeyExW(
HKEY_LOCAL_MACHINE,
L"SYSTEM\\CurrentControlSet\\Control\\Print\\Monitors",
0, KEY_READ, &monitors);
if (status != ERROR_SUCCESS) {
// 记录 status。这里没有可用的 monitors 句柄。
}
打开成功后,RegQueryInfoKeyW 得到直接子键数量及最长名称长度。该函数返回 LSTATUS,lpcMaxSubKeyLen 的长度单位是宽字符,且不包含结尾 NUL。枚举缓冲区因此要额外预留一个 wchar_t。枚举只针对直接子键,避免把厂商自己的嵌套配置误当成另一个监视器。
// 作用:取得键的元信息,例如直接子键数和最长子键名。
// 返回:ERROR_SUCCESS 表示输出参数有效。其它值表示查询失败。
LSTATUS RegQueryInfoKeyW(
HKEY hKey, // 输入:已经打开的键。调用方仍拥有 HKEY
LPWSTR lpClass, // 输出:可选 UTF-16 键类名缓冲区。无需求传 nullptr
LPDWORD lpcchClass, // 输入/输出:lpClass 容量和实际字符数,单位 wchar_t。lpClass 为 nullptr 时传 nullptr
LPDWORD lpReserved, // 必须为 nullptr
LPDWORD lpcSubKeys, // 可选输出:直接子键数量
LPDWORD lpcMaxSubKeyLen, // 可选输出:最长子键名字符数,不含结尾 NUL
LPDWORD lpcMaxClassLen, // 可选输出:最长子键类名字符数,不含结尾 NUL
LPDWORD lpcValues, // 可选输出:直接值数量
LPDWORD lpcMaxValueNameLen, // 可选输出:最长值名字符数,不含结尾 NUL
LPDWORD lpcMaxValueLen, // 可选输出:最大值数据长度,单位字节
LPDWORD lpcbSecurityDescriptor, // 可选输出:安全描述符长度,单位字节
PFILETIME lpftLastWriteTime // 可选输出:最后写入时间。nullptr 表示忽略
);
// 作用:按零基索引读取一个直接子键名。
// 返回:ERROR_SUCCESS 表示成功。ERROR_NO_MORE_ITEMS 表示索引超过最后。
LSTATUS RegEnumKeyExW(
HKEY hKey, // 输入:已打开键。不转移所有权
DWORD dwIndex, // 输入:零基直接子键索引
LPWSTR lpName, // 输出:UTF-16 子键名缓冲区。不能为 nullptr
LPDWORD lpcchName, // 输入容量、输出实际字符数。单位 wchar_t,不含结尾 NUL
LPDWORD lpReserved, // 必须为 nullptr
LPWSTR lpClass, // 输出:可选类名缓冲区。通常传 nullptr
LPDWORD lpcchClass, // lpClass 非空时输入/输出字符数。否则传 nullptr
PFILETIME lpftLastWriteTime // 可选输出:子键最后写入时间。传 nullptr 表示忽略
);
完成第二步后,已经获得了每个直接子键的名称,并且每个名称都来自注册表实际返回的字符长度。接下来要打开这些子键,按照注册表值报告的字节长度读取 Driver,才能避免把路径文本截断或误作其他类型的数据。
3. 读取 Driver 值并验证类型、长度和边界
打开每个监视器子键后,再读取其 Driver 值。RegQueryValueExW 的 lpcbData 始终以字节为单位:第一次传入 lpData=nullptr 获取所需字节数,第二次分配相同字节数后读取。两次调用间值可能被其他进程改写,第二次出现 ERROR_MORE_DATA 时应重新测量并限制重试次数。
// 作用:查询一个值的类型和原始数据。
// 返回:ERROR_SUCCESS 表示成功。ERROR_MORE_DATA 表示容量不足。其它值是错误码。
LSTATUS RegQueryValueExW(
HKEY hKey, // 已打开的键,不转移所有权
LPCWSTR lpValueName, // 值名。nullptr 查询默认值
LPDWORD lpReserved, // 必须为 nullptr
LPDWORD lpType, // 输出:REG_SZ、REG_EXPAND_SZ 等类型
LPBYTE lpData, // 输出:原始字节。测量阶段可为 nullptr
LPDWORD lpcbData // 输入容量、输出实际长度,单位为字节
);
DWORD type = REG_NONE;
DWORD bytes = 0;
LSTATUS status = RegQueryValueExW(monitor, L"Driver", nullptr,
&type, nullptr, &bytes);
if (status == ERROR_SUCCESS &&
(type == REG_SZ || type == REG_EXPAND_SZ) &&
bytes <= 64 * 1024 && bytes % sizeof(wchar_t) == 0) {
std::vector<wchar_t> text(bytes / sizeof(wchar_t) + 1, L'\0');
DWORD capacity = bytes;
status = RegQueryValueExW(monitor, L"Driver", nullptr, &type,
reinterpret_cast<BYTE*>(text.data()), &capacity);
}
REG_SZ 是普通 UTF-16 文本。REG_EXPAND_SZ 允许 %SystemRoot%、%ProgramFiles% 一类环境变量。例如 REG_EXPAND_SZ 可保存 %SystemRoot%\\System32\\tcpmon.dll,原始文本描述配置,当前服务环境中的展开结果才描述候选路径。只有 REG_EXPAND_SZ 适合进行环境变量展开。capacity 为字节数,bytes / sizeof(wchar_t) 才是 UTF-16 代码单元数量。
注册表 API 返回的字节序列不保证一定带结尾 NUL。为安全显示可以在自己的缓冲区最后额外留一个 NUL,构造 std::wstring 时仍然只使用 API 返回的 capacity 范围。长度为奇数时不能构成完整 UTF-16 代码单元。类型为 REG_BINARY 时也不能按字符串解释。这些状态应与缺少 Driver 值、访问拒绝和子键已删除分别输出。
错误写法常用固定数组接收任意长度的数据:
wchar_t driver[260]{};
RegQueryValueExW(monitor, L"Driver", nullptr, nullptr,
reinterpret_cast<BYTE*>(driver), nullptr);
// 错误:没有输入容量,未检查返回值、类型、实际字节数和结尾 NUL。
固定数组会遗漏长路径,nullptr 长度参数也使 API 无法知道缓冲区容量。正确读取先测量长度,再限制允许的最大值,最后使用显式返回长度构造文本。监视器子键可以在枚举与打开之间被删除,循环遇到单项失败后应记录并继续处理其他子键。
完成第三步后,已经把可读的 Driver 值与类型异常、长度异常、缺失值和访问失败区分开了。接下来需要限定这些数据能够说明的范围,并把单条配置记录组织成可核对的证据。
4. 记录配置证据并限定关联文件与变更的结论
将 Driver 文本用于验证前,需要明确它属于哪一种输入。绝对路径可以先检查路径语法、文件属性、版本资源和签名状态。带环境变量的值应同时保留原文和展开文本。仅含文件名的值只能标记为“需要按服务加载环境定位”。文件存在也只说明路径可见,不能证明 Print Spooler 已加载它,更不能证明它当前服务某个端口。
记录一条监视器信息时,至少保存监视器子键名、根键路径、注册表视图、Driver 的原始类型、原始字节长度、原始文本、可选展开结果和每个失败码。这样可区分“根键不可读”“条目缺少 Driver”“值类型异常”“路径尚未定位”和“候选文件不存在”。
配置变更影响的是完整配置对象
移除监视器时,目标是 Monitors\\监视器名 整个配置子树。只删除 Driver 值会留下同名但不完整的监视器配置。删除错误子键会影响端口、打印队列、厂商状态页或已经安装的设备。开始前需要确认监视器是否为系统组件、哪些端口引用它、是否有业务作业正在处理,以及备份能否恢复完整的键和值类型。
已经加载的 DLL 不会因为注册表值被删除而立刻从服务进程卸载。重启 Print Spooler 会影响队列和进行中的作业。重启系统影响范围更大。变更应在维护窗口完成,并在变更后重新读取注册表、服务状态和相关队列,确认配置与运行状态一致。
从输入到证据的完整读取过程
这一流程的输入是管理员或调查程序指定的 Monitors 注册表目录,输出是一组“监视器子键 + Driver 原始数据 + 读取状态”的记录。注册表是 Windows 由 Configuration Manager 管理的分层配置数据库。注册表键是其中的目录对象,键值是键持有的类型化数据对象。HKEY 是进程持有的已打开键引用,RegOpenKeyExW 创建它,RegCloseKey 结束它的使用期。KEY_READ 是访问检查使用的权限掩码,权限不足时读取动作停止在对应键,不应扩大权限或改变配置。
打开 Monitors 键(获得 HKEY) -> 读取子键数量和最长名称 -> 逐个枚举监视器名称 -> 打开单个监视器 -> 两次查询 Driver 的类型和字节数 -> 只把 REG_SZ / REG_EXPAND_SZ 解码为 UTF-16 文本 -> 关闭单个键和根键 -> 输出原始配置证据
下面的代码从打开到关闭都写在同一段中。它不依赖自定义帮助函数,std::vector 的生命周期覆盖每次 API 使用,所取得的 HKEY 在每条分支结束前释放。
#include <windows.h>
#include <string>
#include <vector>
int ReadFirstPrintMonitorDriver() {
HKEY monitors = nullptr;
LSTATUS status = RegOpenKeyExW(
HKEY_LOCAL_MACHINE, // 输入:机器范围根键。预定义根键无需关闭
L"SYSTEM\\CurrentControlSet\\Control\\Print\\Monitors", // 输入:UTF-16 相对路径
0, // 输入:必须为 0
KEY_READ, // 输入:只读权限。ACL 拒绝时返回 ERROR_ACCESS_DENIED
&monitors); // 输出:成功时获得 HKEY,调用方随后 RegCloseKey
if (status != ERROR_SUCCESS) return static_cast<int>(status);
DWORD subKeyCount = 0;
DWORD longestNameChars = 0;
status = RegQueryInfoKeyW(
monitors, nullptr, nullptr, nullptr, // 不读取键类名。所有可选指针为 nullptr
&subKeyCount, &longestNameChars, nullptr,
nullptr, nullptr, nullptr, nullptr, nullptr);
if (status != ERROR_SUCCESS || subKeyCount == 0) {
RegCloseKey(monitors); // 输入:当前调用方拥有的 HKEY。之后不能再使用
return status == ERROR_SUCCESS ? ERROR_FILE_NOT_FOUND : static_cast<int>(status);
}
std::vector<wchar_t> name(static_cast<size_t>(longestNameChars) + 1, L'\0');
DWORD nameCapacityChars = static_cast<DWORD>(name.size()); // 输入容量以 wchar_t 字符计量。字节数不适用
status = RegEnumKeyExW(monitors, 0, name.data(), &nameCapacityChars,
nullptr, nullptr, nullptr, nullptr);
if (status == ERROR_SUCCESS) {
HKEY monitor = nullptr;
status = RegOpenKeyExW(monitors, name.data(), 0, KEY_READ, &monitor);
if (status == ERROR_SUCCESS) {
DWORD type = REG_NONE;
DWORD bytes = 0;
status = RegQueryValueExW(monitor, L"Driver", nullptr, &type, nullptr, &bytes);
if (status == ERROR_SUCCESS &&
(type == REG_SZ || type == REG_EXPAND_SZ) &&
bytes % sizeof(wchar_t) == 0 && bytes <= 64 * 1024) {
std::vector<wchar_t> value(bytes / sizeof(wchar_t) + 1, L'\0');
DWORD capacityBytes = bytes; // 输入/输出均为字节,第二次返回实际长度
status = RegQueryValueExW(monitor, L"Driver", nullptr, &type,
reinterpret_cast<BYTE*>(value.data()), &capacityBytes);
}
RegCloseKey(monitor); // 关闭单个子键,不影响 monitors 根键
}
}
RegCloseKey(monitors);
return static_cast<int>(status);
}
RegCloseKey 的完整声明如下。预定义根键例如 HKEY_LOCAL_MACHINE 由系统提供,不传给它。RegOpenKeyExW 返回的键才是本段代码负责关闭的资源。
// 作用:释放调用方已经打开的注册表键引用。
// 返回:ERROR_SUCCESS 表示关闭请求完成。失败时返回 LSTATUS。hKey 不能为 nullptr。
LSTATUS RegCloseKey(
HKEY hKey // 输入:调用方拥有的已打开 HKEY。调用后该值失效
);
ERROR_FILE_NOT_FOUND 表示目标键或值在当前读取时不存在,ERROR_ACCESS_DENIED 表示当前令牌缺少 ACL 允许的读取权限,ERROR_MORE_DATA 表示第二次查询容量不足,ERROR_NO_MORE_ITEMS 表示枚举索引到最后。根键与子键都可能在两次调用之间变化。每个返回值都应单独成为记录的一部分。
完成第四步后,读取结果同时保留了配置文本、类型、长度和失败状态,也明确了它与 DLL 实际加载、端口引用和服务运行状态之间的界限。这样得到的记录可以用于后续核对,而不会把注册表配置直接当成运行时结论。
完整可运行程序在附件

496K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4j5h3&6Y4N6$3g2A6j5$3#2Q4x3X3g2D9j5h3&6*7L8%4g2#2i4K6u0W2j5$3!0E0i4K6u0r3K9g2g2Y4y4q4p5K6P5i4g2X3P5i4q4V1
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。