简介打印监控领域除了Hook打印函数、注册系统打印消息、生成虚拟打印机接管任务外解析SPOOL文件是获取打印任务详情的另一条有效路径。这套资料面向打印监控、文档审计及打印驱动相关开发者提供了完整的SPL/SHD文件解析工具链与示例代码并附有原始样本文件和技术说明。资源共55个文件涉及CPP源码工程、EXE可执行程序、PDB调试符号、SPL与SHD样例以及docx说明文档和“如何将SPL剥离成EMF格式”参考文章。其中SPL_Split_Test工程演示了拆包与解析过程splview.exe可直接查看SPL内容配合SHD文件能还原打印任务基础属性整体包体约19.93MB目录信息完整便于按需提取学习。已有1969人学习浏览适合具备Windows文件结构基础、希望拓展打印信息获取手段的读者借鉴与二次开发。1. 打印信息获取不玄学SPOOL 文件里到底能挖到什么很多人以为 Windows 打印记录一旦从打印队列里消失就无从追查实际恰好相反打印后台服务在把任务送往打印机之前会把整个后台打印缓存连同作业元数据一起落盘形成 .spl 和 .shd 文件位置固定在系统后台打印目录里。本标题要解决的“打印信息获取”就是绕过打印日志断档、驱动报错、用户声称没打过这类黑匣子场景直接分析 SPOOL 文件把打印文件名、打印机名、提交用户、端口、页数和提交时间从字节流里抠出来。适合运维、打印系统开发者和做日志审计的从业者尤其是你手头只有一台出过事的 Windows 机器而常规事件日志已经被清掉的时候。2. 先搞懂 SPOOL 文件从哪里来打印链路与文件形态2.1 打印后台缓存不是玄学从 GDI 调用到 SPL 落盘的过程Windows 的打印链路大致是应用程序调用 GDI 或 DirectWrite 生成打印内容交给打印机驱动驱动把内容转成后台打印格式再交给后台打印程序Print Spooler统一排队。后台打印程序拿到数据后会在%SystemRoot%\System32\spool\PRINTERS\目录下生成两个文件一个是扩展名 .spl 的打印缓冲文件保存的是真正的页面数据另一个是 .shd 的影子文件保存作业 ID、文档名、打印机名、用户名、提交时间、页数等元数据。任务完成后这两个文件正常情况下会被立刻删除只有在任务停滞、队列出错、后台打印程序崩溃或中途断电时才会残留在目录里。对做打印信息获取的人来说这两类残留文件反而是最值钱的分析对象。常见的后台打印格式有两种。第一种是 EMF 中间格式应用程序把打印内容先转成增强元文件EMF后台打印程序把这个 EMF 内容整体写入 .spl 文件由打印处理器在后台再解析成打印机驱动需要的指令。第二种是 RAW 直通格式驱动或应用程序直接把打印机语言如 PCL、PostScript 片段写入 .spl后台打印程序不做二次处理。两种格式决定了后面脚本解析时用哪一套思路EMF 可以按记录结构拆RAW 只能靠字符串扫描和打印机指令特征识别。还有一种 XPS 打印路径此时 .spl 文件内容以 ZIP 结构开头可以当压缩包处理。分析之前先确认格式比盲目套解析模板更稳。2.2 采集时机与文件定位停服务还是在线复制动手分析之前第一步是把 .spl 和 .shd 从后台打印目录里安全复制出来。这个动作看似简单踩坑率却不低。后台打印程序为了保证排队一致性会对正在写和正在读的 .spl 文件加独占锁直接复制经常报“文件正由另一进程使用”。常见做法有两种在线复制和停服务复制。在线复制的优势是不断打印服务对生产环境影响小适合审计任务不紧急的场景。用 PowerShell 一条命令把整个后台打印目录复制出来$src C:\Windows\System32\spool\PRINTERS $dst C:\spool_analysis\raw New-Item -ItemType Directory -Path $dst -Force | Out-Null Copy-Item -Path $src\*.spl, $src\*.shd -Destination $dst -Force -ErrorAction SilentlyContinue这段命令里-ErrorAction SilentlyContinue负责跳过被锁定的文件不会让脚本整体中断。代价是可能漏掉个别正在被后台打印程序占用的活跃任务文件。解决方法是复制完一轮后等几秒再复制一轮对比两次文件列表把第二次才出现的文件重点分析——这些往往是刚排队的新作业。在线复制的另一层限制是权限后台打印目录默认只有 SYSTEM 和管理员能读普通账号复制会直接拒绝访问执行前确认当前终端有管理员权限。停服务复制则能拿到完整快照适合任务已经卡死、打印服务本身已经不正常的场景Stop-Service -Name Spooler -Force Start-Sleep -Seconds 2 Copy-Item -Path C:\Windows\System32\spool\PRINTERS\* -Destination $dst -Recurse -Force Start-Service -Name Spooler这段命令先强制停止后台打印程序等待 2 秒让句柄释放再复制整个目录最后把服务重新启动。注意-Force会强制终止正在排队的打印任务生产环境下如果队列里还有几十个等着打印的作业停服务会把这些任务全部冲掉。所以我的习惯是能在线复制就优先在线复制只有服务本身已经挂掉或任务完全卡死时才用停服务方案。复制完成后还要识别哪个 .spl 对应哪次打印任务。后台打印程序给文件起的是随机十六进制文件名比如0001A2.spl单看文件名看不出任务信息。定位方法有三条按创建时间排序找任务提交时间附近的文件打开同名 .shd 文件读取元数据或者先看 .spl 修改时间再和打印队列历史记录做时间关联。2.3 原始文件的字节级预检先决定走哪条解析路线拿到文件后先做一次快速预检确认它是 EMF、RAW 还是 XPS 格式。用十六进制工具查看文件开头EMF 文件的整个 EMF 流以一个固定标识符开始结构上第一个记录是文件头记录文件中间散布着绘制记录RAW 文件开头没有固定魔数常见的是 PJL 语句或者二进制打印机指令片段XPS 则直接以PK开头说明文件体是一个 ZIP 容器。判断格式之后再决定怎么提取信息否则直接套正则找文件名碰到 EMF 会扫出一堆二进制噪音碰到 XPS 则会无功而返。预检这一步决定后续脚本的参数值得在写解析脚本前单独跑一次。实际工作中我会对同一任务留下的 .spl 和 .shd 同时做预检因为 .shd 里经常有更干净的用户名和文档名字段可以直接拿来校正 .spl 的扫描结果。提示复制过程不要直接修改原始文件分析全部基于副本。后台打印目录是系统现场证据后续如果需要做时间线回溯原始文件的时间戳和内容完整性比提取出的信息更重要。3. 从字节流里把打印信息抠出来SPOOL 文件格式解剖与提取实践3.1 三种格式的特征对比EMF、RAW 与 XPS分析 SPL 文件前先用手头的文件格式和提取策略做个对照下面这张表是三类格式最典型的识别特征。格式典型开头特征存储内容能提取的信息提取难度EMF 中间格式EMF 文件头记录打印指令序列、文本记录、绘图记录文件名、打印机名、用户名、页数估算中等RAW 直通格式PJL 语句或打印机指令二进制流送到打印机的原始指令通过 PJL 注释找文件名、用户名受驱动影响大低XPS 打印路径PK开头ZIP 容器XPS 文档包文档属性、页数稳定可解包读取低EMF 是后台打印程序默认采用的中间格式信息密度最高但解析也最麻烦因为绘制记录和文本记录混在二进制数据流里。RAW 格式则完全依赖打印机驱动把元数据写进 PJL 注释或打印机状态页扫描成功率和驱动实现强相关。XPS 是最省心的格式按 ZIP 解包后文档属性都在 XML 文件里。分析的第一步永远是识别格式再决定策略跳过这步直接跑正则的结果往往是一堆乱码和误报。3.2 字符串扫描是第一梯队提取打印文件名、打印机名、用户名无论哪种格式.spl 文件里都会以明文形式存放一部分任务元数据原因是后台打印程序在写入页面数据时会把作业名称、打印机名称、用户名一并以字符串形式嵌入缓冲区。字符串扫描就是先把这些可打印文本从原始字节里捞出来再按特征过滤。import re, os, sys def extract_strings(data, min_len6): 从二进制数据里提取 ASCII 与 UTF-16LE 字符串 results [] # ASCII 可见字符长度不小于 min_len ascii_re re.compile(([\x20-\x7e]{%d,} % min_len).encode()) for m in ascii_re.finditer(data): try: s m.group().decode(ascii) results.append((ascii, s, m.start())) except UnicodeDecodeError: pass # UTF-16LE 可见字符典型场景是中文 Windows utf16_re re.compile(((?:[\x20-\x7e]\x00){%d,} % min_len).encode()) for m in utf16_re.finditer(data): s m.group().decode(utf-16le, errorsreplace) results.append((utf16le, s, m.start())) return results这段代码的核心思路是分别用两套字节模式扫描纯 ASCII 字符容易识别只匹配可见字符UTF-16LE 的显式特征是每个可见字符后面跟着一个\x00在中文系统上用户名和文档名大量以 UTF-16LE 形式存在。min_len参数建议设置为 6太短会扫出一堆随机的二进制巧合太长又会漏掉短文件名。拿到字符串列表后下一步是过滤目标字段。import re def find_print_job_info(results): 从字符串列表里定位文件名、打印机名、用户名 doc_patterns [r[\w\u4e00-\u9fa5\-]\.(?:docx?|xlsx?|pptx?|pdf|txt|jpg|png|bmp), r[\w\u4e00-\u9fa5\-]\.(?:prn|spl)] file_hits, printer_hits, user_hits [], [], [] for kind, s, pos in results: for p in doc_patterns: m re.search(p, s, re.IGNORECASE) if m: file_hits.append((m.group(), pos, kind)) if print in s.lower() or len(s) 12: printer_hits.append((s, pos, kind)) if s.lower() in [administrator, guest] or re.match(r^[A-Za-z0-9_\-\.]$, s): user_hits.append((s, pos, kind)) return file_hits, printer_hits, user_hits这段过滤逻辑里文件名通过扩展名正则命中打印机名和用户名则靠启发式规则打印机名通常包含print关键字或出现在打印处理器相关的字符串附近用户名则尽量匹配纯账号字符集。启发式规则肯定有误报但作为第一梯队足够定位候选再结合上下文和 .shd 文件交叉确认。实践经验是文档名命中后把该字符串前后各 256 字节的原始内容打印出来看一遍经常能顺带把打印机名和用户名都带出来因为它们在缓冲区里往往挨得很近。3.3 解析 EMF 记录结构页数与注释记录从哪来字符串扫描只能拿到零散字段页数这类结构化信息还需要解析 EMF 记录本身。EMF 的本质是一串记录头加记录体的序列每条记录开头有类型字段和记录大小字段。遍历整个文件的策略是找到一条记录头读大小跳到下一条反复迭代直到文件结束。import struct def walk_emf_records(data): 从疑似 EMF 流起始位置开始遍历记录头返回 (记录偏移, 记录大小) 列表 records [] # 在文件中搜索 EMF 文件头标识常规值为 0x464D4520 附近 idx data.find(b EMF) if idx -1: # 某些后台打印文件带 16 字节 spooler 头EMF 头可能在后面 idx data.find(b\x01\x00\x00\x00) if idx -1: return records offset idx while offset 8 len(data): rec_type struct.unpack_from(I, data, offset)[0] rec_size struct.unpack_from(I, data, offset 4)[0] if rec_size 8 or rec_size len(data) - offset: break records.append((offset, rec_type, rec_size)) offset rec_size return records这里的关键校验是rec_size必须落在合理区间否则就是找错了起始位置。遍历拿到记录列表后页数估算就水到渠成统计记录中出现的换页控制记录数量。EMF 格式里分页操作通常落在特定记录类型上实际环境里不同驱动实现略有差异所以更稳妥的做法是同时统计换页记录数和文件里的\x0c换页符出现的次数两者取较大值作为页数估算再和 .shd 里记录的页数做交叉比对。注释记录也是同样的思路EMR 注释记录里保留了应用程序自己写入的调试字符串打印驱动和应用经常把文档名、用户名塞进去遍历记录时对这些记录单独做一次字符串扫描能拿到比全局扫描干净得多的目标字段。这一节的方法只解决“有 EMF 头且记录完整”的文件。实际 SPL 文件里还会出现延迟渲染延迟打印的情况.spl 里存的是生成 EMF 所需的中间数据EMF 头不直接可见这时必须先跳过延迟渲染包装头再解析或者直接依赖字符串扫描结果。4. SPOOL 文件分析避坑记录四条翻车现场与修复手法4.1 翻车一文件被占用复制阶段就失败现象执行复制命令只拷出来几个小文件大部分 .spl 报“正由另一进程使用无法访问”。原因后台打印程序对正在处理的作业文件加了独占锁任务越大锁定时间越长。解决优先用 2.2 节的在线复制加-ErrorAction SilentlyContinue方案复制后对比两次列表如果绝大多数文件都被锁说明后台打印程序正处于高负载状态此时直接停服务复制是唯一可靠方案但要先确认队列里没有重要任务。这个坑在服务器上尤其常见打印量大且队列长期不清空时备用服务器上残留下的 .spl 文件反而好采生产服务器上活跃文件总是被锁。4.2 翻车二打印文件名死活找不到扫出来的全是二进制噪音现象字符串扫描跑完文件名一个都没命中满屏是乱码和孤立字节。原因驱动的打印路径决定 .spl 里是否写入文档名。RAW 直通格式下文档名只存在于 .shd 元数据里.spl 里只有纯打印机指令又或者是任务采用了延迟渲染.spl 里存的是中间格式字符串以非标准编码存放。解决先回头确认格式RAW 格式就放弃全局扫描改用 .shd 文件读元数据延迟渲染场景则先定位渲染包装头跳过包装再扫 EMF 区域。不要把字符串扫描当成万灵药正确顺序是格式识别 → .shd 交叉读取 → 字符串扫描 → EMF 记录解析。4.3 翻车三中文用户名和打印机名全部乱码现象英文账号能扫出来中文用户名输出成一串????或不完整的乱码字符。原因Windows 后台打印程序在写入中文字符串时通常使用 UTF-16LE 编码而脚本只做了 ASCII 解码反过来某些 ASCII 字符串在 UTF-16LE 解码路径里也会产生大量不可见字符。解决字符串扫描阶段同时跑两套模式ASCII 和 UTF-16LE 分开收集不要混在一起统一解码。提取结果后先做编码嗅探如果一段文本在 ASCII 模式下可读但语义不通大概率是 UTF-16LE 被错误按单字节解码了。把两个解码结果都保存下来比对时优先取可读性强、字段特征匹配度高的那组。4.4 翻车四页数统计对不上报表口径被质疑现象脚本统计出 3 页打印作业实际只有 2 页或者反过来了。原因页数统计依赖 EMF 里的换页控制记录但不同驱动对分页的处理方式不同有的驱动把整份文档当作单条记录流有的会把分页信息放在打印机指令里而不是 EMF 记录里。另一些情况是文档里有大量空白页驱动优化掉了换页记录导致统计偏少。解决页数只作为估算值不要当作精确审计依据。做法是取三个来源交叉比对EMF 记录统计、文件内的换页符计数、.shd 元数据里的页数字段三者取最接近的两个值作为结论。如果三组数字完全对不上大概率是 .spl 文件已经损坏此时用事件日志里的打印任务记录兜底。5. 把提取流程固化成一条可复现的流水线从 SPOOL 目录到结构化结果5.1 流水线整体结构采集、解析、落库三件事分开零散脚本只能应对单次分析遇到连续审计多台机器、多批残留文件时必须把过程固化成流水线。我的常见做法是拆成三个独立步骤采集脚本负责把远程或本地的后台打印目录复制到分析工作区解析脚本负责识别格式并提取字段落库脚本负责把结果写入 SQLite。三个步骤分开的最大好处是每一步可以被独立验证采集结果可以核对文件数量解析结果可以抽样检查误报率落库结果可以随时删掉重来。选 SQLite 而不是 CSV 的原因很实际同一批打印任务要反复查证CSV 每次重新打开都很容易串行读取出错而且不方便做跨字段关联。SQLite 只需要一条查询语句就能同时查“某个用户在某个时间段里打过哪些文件”这对审计场景几乎是刚需。表结构也简单核心字段就是采集时能稳定提取的那几个作业文件、文档名、打印机名、用户名、页数估算、文件大小、采集时间。不要一开始就把字段设计得过于复杂解析能力跟不上反而让入库脚本天天报错。5.2 分析脚本落地扫描目录、解析任务、输出结构化记录下面这段脚本把字符串扫描、EMF 遍历、结果入库整合成一条可用命令。它接收一个原始文件目录作为输入输出一份 SQLite 数据库import os, re, sqlite3, argparse def extract_strings(data, min_len6): ascii_re re.compile(([\x20-\x7e]{%d,} % min_len).encode()) utf16_re re.compile(((?:[\x20-\x7e]\x00){%d,} % min_len).encode()) results [] for m in ascii_re.finditer(data): try: results.append((ascii, m.group().decode(ascii), m.start())) except UnicodeDecodeError: pass for m in utf16_re.finditer(data): results.append((utf16le, m.group().decode(utf-16le, errorsreplace), m.start())) return results def find_doc_name(results): doc_pat re.compile(r[\w\u4e00-\u9fa5\-]\.(?:docx?|xlsx?|pptx?|pdf|txt|jpg|png|bmp), re.IGNORECASE) for _, s, _ in results: m doc_pat.search(s) if m: return m.group() return None def analyze_spl_file(path): with open(path, rb) as f: data f.read() strings extract_strings(data, min_len6) return { spl_file: os.path.basename(path), doc_name: find_doc_name(strings), total_bytes: len(data), pages_estimate: data.count(b\x0c) } def main(): parser argparse.ArgumentParser() parser.add_argument(--dir, requiredTrue) parser.add_argument(--db, requiredTrue) parser.add_argument(--min-len, typeint, default6) args parser.parse_args() conn sqlite3.connect(args.db) conn.execute(CREATE TABLE IF NOT EXISTS print_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, spl_file TEXT, doc_name TEXT, total_bytes INTEGER, pages_estimate INTEGER, scanned_at TEXT DEFAULT (datetime(now)) )) for root, _, files in os.walk(args.dir): for name in files: if name.lower().endswith(.spl): info analyze_spl_file(os.path.join(root, name)) conn.execute( INSERT INTO print_jobs (spl_file, doc_name, total_bytes, pages_estimate) VALUES (?,?,?,?), (info[spl_file], info[doc_name], info[total_bytes], info[pages_estimate]) ) conn.commit() conn.close() print(done) if __name__ __main__: main()这个脚本故意保持简单核心逻辑只做了两件事扫描字符串找文档名统计换页符做页数估算。实际使用时需要把find_doc_name里的扩展名列表扩充成实际环境的文档类型集合并且把pages_estimate与 .shd 文件里的信息做合并。脚本的运行方式如下python spl_extract.py --dir C:\spool_analysis\raw --db C:\spool_analysis\result.db --min-len 6执行完后可以用一条 SQL 验证结果SELECT spl_file, doc_name, pages_estimate, total_bytes FROM print_jobs ORDER BY scanned_at DESC LIMIT 20;这里需要解释三个重要参数。--dir必须是副本目录不要直接指向PRINTERS系统目录否则会在采集阶段就被文件锁干扰。--db每次分析应指向新的库文件避免多批次数据混淆如果确实需要增量入库建议在表里增加source_machine和collection_time字段再合并。--min-len控制字符串最小长度默认 6 是综合误报率和召回率的选择如果分析对象全部是短文件名比如a.pdf就要降到 4代价是误报数量明显上升。5.3 与 .shd 文件做交叉核对元数据字段的补齐.spl 里提取不到文件名或用户名时.shd 文件是最后一道兜底。.shd 结构比 .spl 简单它是后台打印程序维护的作业状态文件常见做法是直接用十六进制编辑器打开肉眼定位可读字符串文档名、打印机名、用户名通常连续出现在文件前半部分。脚本化处理时可以直接把 .shd 也纳入字符串扫描流程但不要复用文档名正则因为它还包含打印端口、作业状态等辅助字段。我的做法是先把 .shd 整体读出取 ASCII 和 UTF-16LE 两套字符串把所有命中结果按出现位置排序然后从前到后读字段名判断每个字段的归属。这个步骤没有统一标准因为不同 Windows 版本和不同打补丁状态下的 .shd 字段偏移不一样所以脚本里必须做成“扫描全部字符串后按字段名归纳”而不是写死偏移量。真碰上 .shd 结构完全读不出来的情况就退回事件日志兜底Microsoft-Windows-PrintService/Operational日志里每个打印任务的事件记录包含文档名、用户名、打印机名、页数和结果这也是审计时最容易被忽视的交叉验证来源。6. 验证分析结果与两个进阶用法让提取的信息经得起审计验证流程不能省。最直接的做法是在测试环境里发起一次打印任务等作业完成后从后台打印目录采集残留文件再和PrintService/Operational日志里的对应记录比对。查询事件日志的命令如下Get-WinEvent -FilterHashtable {LogNameMicrosoft-Windows-PrintService/Operational; Id307} -Oldest | Select-Object TimeCreated, Message | Format-List事件 ID 307 代表打印作业完成消息体里包含文档名、用户名、打印机名和页数直接用它校正脚本输出。如果事件日志没开启也可以打印一页测试页用打印机状态页的页数和脚本统计值比对。能做这个验证流水线才算真正闭环。两个进阶用法值得投入。第一个是解析 EMF 里的文本绘制记录把文档名、用户名字段缺失的 SPL 文件按文本内容反向识别——这一招在 RAW 直通格式文件上特别有用因为打印机指令里经常嵌入页面文本。第二个是对采集到的多批次结果做时间线聚类把同一用户的打印任务按时间排序识别出短时间内的重复打印这往往能直接指向配置错误或自动化任务异常。我过去在这个方向吃过亏直接在打印服务正常运转的机器上停服务采集结果整队列任务全被冲掉从那以后我坚持只对副本做分析原始现场永远保持原样。这行当里让数据说话最靠谱希望帮到你。本文还有配套的精品资源点击获取